Skip to main content

copybook_codec/
numeric.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2//! # Numeric Type Codecs for COBOL Data
3//!
4//! This module provides encoding and decoding functions for the three main COBOL numeric
5//! data types:
6//!
7//! - **Zoned Decimal** (`PIC 9` with optional `SIGN SEPARATE`): External decimal format
8//!   where each digit is stored in a byte with a zone nibble and a digit nibble.
9//!   The sign may be encoded via overpunch or stored in a separate byte.
10//!
11//! - **Packed Decimal** (`COMP-3`): Compact binary format where each byte contains
12//!   two decimal digits (nibbles), with the last nibble containing the sign.
13//!
14//! - **Binary Integer** (`COMP-4`, `COMP-5`, `BINARY`): Standard binary integer
15//!   encoding in big-endian byte order.
16//!
17//! ## Module Organization
18//!
19//! The module is organized into three main categories:
20//!
21//! ### Decoding Functions
22//! - [`decode_zoned_decimal`](crate::numeric::decode_zoned_decimal) - Decode zoned decimal fields
23//! - [`decode_zoned_decimal_sign_separate`](crate::numeric::decode_zoned_decimal_sign_separate) - Decode SIGN SEPARATE zoned decimals
24//! - [`decode_zoned_decimal_with_encoding`](crate::numeric::decode_zoned_decimal_with_encoding) - Decode with encoding detection
25//! - [`decode_packed_decimal`](crate::numeric::decode_packed_decimal) - Decode COMP-3 packed decimals
26//! - [`decode_binary_int`](crate::numeric::decode_binary_int) - Decode binary integer fields
27//!
28//! ### Encoding Functions
29//! - [`encode_zoned_decimal`](crate::numeric::encode_zoned_decimal) - Encode zoned decimal fields
30//! - [`encode_zoned_decimal_with_format`](crate::numeric::encode_zoned_decimal_with_format) - Encode with explicit encoding format
31//! - [`encode_zoned_decimal_with_format_and_policy`](crate::numeric::encode_zoned_decimal_with_format_and_policy) - Encode with format and policy
32//! - [`encode_zoned_decimal_with_bwz`](crate::numeric::encode_zoned_decimal_with_bwz) - Encode with BLANK WHEN ZERO support
33//! - [`encode_packed_decimal`](crate::numeric::encode_packed_decimal) - Encode COMP-3 packed decimals
34//! - [`encode_binary_int`](crate::numeric::encode_binary_int) - Encode binary integer fields
35//!
36//! ### Utility Functions
37//! - [`get_binary_width_from_digits`](crate::numeric::get_binary_width_from_digits) - Map digit count to binary width
38//! - [`validate_explicit_binary_width`](crate::numeric::validate_explicit_binary_width) - Validate explicit BINARY(n) widths
39//! - [`should_encode_as_blank_when_zero`](crate::numeric::should_encode_as_blank_when_zero) - Check BLANK WHEN ZERO policy
40//!
41//! ### Internal Single-Responsibility Modules
42//! - `branch` owns internal branch-prediction hints for numeric hot paths.
43//! - `binary` owns COMP/BINARY integer codecs and binary width helpers.
44//! - `decimal` owns [`SmallDecimal`](crate::numeric::SmallDecimal), decimal formatting helpers,
45//!   and zoned encoding detection metadata.
46//! - `float` owns COMP-1/COMP-2 IEEE and IBM hexadecimal floating-point codecs.
47//!
48//! ## Performance Considerations
49//!
50//! This module is optimized for high-throughput enterprise data processing:
51//!
52//! - **Hot path optimization**: Common cases (1-5 byte COMP-3, ASCII zoned) use
53//!   specialized fast paths
54//! - **Branch prediction**: Manual hints mark error paths as unlikely
55//! - **Zero-allocation**: Scratch buffer variants avoid repeated allocations in loops
56//! - **Saturating arithmetic**: Prevents panics while maintaining correctness
57//!
58//! ## Encoding Formats
59//!
60//! ### Zoned Decimal Encoding
61//!
62//! Zoned decimals support two primary encoding formats:
63//!
64//! | Format | Zone Nibble | Example Digits | Sign Encoding |
65//! |---------|--------------|----------------|---------------|
66//! | ASCII | `0x3` | `0x30`-`0x39` | Overpunch or separate |
67//! | EBCDIC | `0xF` | `0xF0`-`0xF9` | Overpunch or separate |
68//!
69//! ### Packed Decimal Sign Nibbles
70//!
71//! COMP-3 uses the last nibble for sign encoding:
72//!
73//! | Sign | Nibble | Description |
74//! |------|---------|-------------|
75//! | Positive | `0xC`, `0xA`, `0xE`, `0xF` | Positive values |
76//! | Negative | `0xB`, `0xD` | Negative values |
77//! | Unsigned | `0xF` | Unsigned fields only |
78//!
79//! ### Binary Integer Widths
80//!
81//! Binary integers use the following width mappings:
82//!
83//! | Digits | Width | Bits | Range (signed) |
84//! |---------|--------|-------|----------------|
85//! | 1-4 | 2 bytes | 16 | -32,768 to 32,767 |
86//! | 5-9 | 4 bytes | 32 | -2,147,483,648 to 2,147,483,647 |
87//! | 10-18 | 8 bytes | 64 | -9,223,372,036,854,775,808 to 9,223,372,036,854,775,807 |
88//!
89//! ## Examples
90//!
91//! ### Decoding a Zoned Decimal
92//!
93//! ```no_run
94//! use copybook_codec::numeric::{decode_zoned_decimal};
95//! use copybook_codec::options::Codepage;
96//!
97//! // ASCII zoned decimal: "123" = [0x31, 0x32, 0x33]
98//! let data = b"123";
99//! let result = decode_zoned_decimal(data, 3, 0, false, Codepage::ASCII, false)?;
100//! assert_eq!(result.to_string(), "123");
101//! # Ok::<(), copybook_core::Error>(())
102//! ```
103//!
104//! ### Encoding a Packed Decimal
105//!
106//! ```no_run
107//! use copybook_codec::numeric::{encode_packed_decimal};
108//!
109//! // Encode "123.45" as 7-digit COMP-3 with 2 decimal places
110//! let encoded = encode_packed_decimal("123.45", 7, 2, true)?;
111//! // Result: [0x12, 0x34, 0x5C] (12345 positive)
112//! # Ok::<(), copybook_core::Error>(())
113//! ```
114//!
115//! ## See Also
116//!
117//! - [`crate::zoned_overpunch`] - Zoned decimal overpunch encoding/decoding
118//! - [`crate::SmallDecimal`] - Decimal representation without floating-point precision loss
119//! - [`crate::memory::ScratchBuffers`] - Reusable buffers for zero-allocation processing
120
121use crate::memory::ScratchBuffers;
122use crate::options::Codepage;
123use copybook_core::{Error, ErrorCode, Result, SignPlacement, SignSeparateInfo};
124use std::convert::TryFrom;
125use tracing::warn;
126
127mod alphanumeric;
128mod binary;
129mod branch;
130mod decimal;
131mod float;
132pub mod overpunch;
133pub mod zoned;
134
135pub use alphanumeric::encode_alphanumeric;
136pub use binary::{
137    decode_binary_int, decode_binary_int_fast, encode_binary_int, get_binary_width_from_digits,
138    validate_explicit_binary_width,
139};
140use branch::{likely, unlikely};
141pub use decimal::{SmallDecimal, ZonedEncodingInfo};
142use decimal::{create_normalized_decimal, digit_from_value, scale_abs_to_u32};
143pub use float::*;
144pub use overpunch::{
145    ZeroSignPolicy, decode_ebcdic_overpunch_zone, decode_overpunch_byte,
146    encode_ebcdic_overpunch_zone, encode_overpunch_byte, get_all_valid_overpunch_bytes,
147    is_valid_overpunch,
148};
149pub use zoned::{ParseZonedEncodingFormatError, ZonedEncodingFormat};
150
151/// Nibble zones for ASCII/EBCDIC digits (high bits in zoned bytes).
152const ASCII_DIGIT_ZONE: u8 = 0x3; // ASCII '0'..'9' => 0x30..0x39
153const EBCDIC_DIGIT_ZONE: u8 = 0xF; // EBCDIC '0'..'9' => 0xF0..0xF9
154
155/// Decode a zoned decimal using the configured code page with detailed error context.
156///
157/// Decodes zoned decimal (PIC 9) fields where each digit is stored in a byte
158/// with a zone nibble and a digit nibble. The sign may be encoded via overpunch
159/// in the last byte's zone nibble.
160///
161/// # Arguments
162/// * `data` - Raw byte data containing the zoned decimal
163/// * `digits` - Number of digit characters (field length)
164/// * `scale` - Number of decimal places (can be negative for scaling)
165/// * `signed` - Whether the field is signed (true) or unsigned (false)
166/// * `codepage` - Character encoding (ASCII or EBCDIC variant)
167/// * `blank_when_zero` - If true, all-space fields decode as zero
168///
169/// # Returns
170/// A `SmallDecimal` containing the decoded value
171///
172/// # Policy
173/// Applies the codec default: ASCII uses `ZeroSignPolicy::Positive`; EBCDIC zeros normalize via `ZeroSignPolicy::Preferred`.
174///
175/// # Errors
176/// * `CBKD410_ZONED_OVERFLOW` - if the decoded magnitude exceeds `i64` capacity
177/// * `CBKD411_ZONED_BAD_SIGN` - if the zoned digits, zones, or sign are invalid
178///
179/// # Examples
180///
181/// ## ASCII Zoned Decimal
182///
183/// ```no_run
184/// use copybook_codec::numeric::{decode_zoned_decimal};
185/// use copybook_codec::options::Codepage;
186///
187/// // ASCII "123" = [0x31, 0x32, 0x33]
188/// let data = b"123";
189/// let result = decode_zoned_decimal(data, 3, 0, false, Codepage::ASCII, false)?;
190/// assert_eq!(result.to_string(), "123");
191/// # Ok::<(), copybook_core::Error>(())
192/// ```
193///
194/// ## Signed ASCII Zoned Decimal (Overpunch)
195///
196/// ```no_run
197/// use copybook_codec::numeric::{decode_zoned_decimal};
198/// use copybook_codec::options::Codepage;
199///
200/// // ASCII "-123" with overpunch: [0x31, 0x32, 0x4D] (M = 3 with negative sign)
201/// let data = [0x31, 0x32, 0x4D];
202/// let result = decode_zoned_decimal(&data, 3, 0, true, Codepage::ASCII, false)?;
203/// assert_eq!(result.to_string(), "-123");
204/// # Ok::<(), copybook_core::Error>(())
205/// ```
206///
207/// ## EBCDIC Zoned Decimal
208///
209/// ```no_run
210/// use copybook_codec::numeric::{decode_zoned_decimal};
211/// use copybook_codec::options::Codepage;
212///
213/// // EBCDIC "123" = [0xF1, 0xF2, 0xF3]
214/// let data = [0xF1, 0xF2, 0xF3];
215/// let result = decode_zoned_decimal(&data, 3, 0, false, Codepage::CP037, false)?;
216/// assert_eq!(result.to_string(), "123");
217/// # Ok::<(), copybook_core::Error>(())
218/// ```
219///
220/// ## Decimal Scale
221///
222/// ```no_run
223/// use copybook_codec::numeric::{decode_zoned_decimal};
224/// use copybook_codec::options::Codepage;
225///
226/// // "12.34" with 2 decimal places
227/// let data = b"1234";
228/// let result = decode_zoned_decimal(data, 4, 2, false, Codepage::ASCII, false)?;
229/// assert_eq!(result.to_string(), "12.34");
230/// # Ok::<(), copybook_core::Error>(())
231/// ```
232///
233/// ## BLANK WHEN ZERO
234///
235/// ```no_run
236/// use copybook_codec::numeric::{decode_zoned_decimal};
237/// use copybook_codec::options::Codepage;
238///
239/// // All spaces decode as zero when blank_when_zero is true
240/// let data = b"   ";
241/// let result = decode_zoned_decimal(data, 3, 0, false, Codepage::ASCII, true)?;
242/// assert_eq!(result.to_string(), "0");
243/// # Ok::<(), copybook_core::Error>(())
244/// ```
245///
246/// # See Also
247/// * [`decode_zoned_decimal_sign_separate`] - For SIGN SEPARATE fields
248/// * [`decode_zoned_decimal_with_encoding`] - For encoding detection
249/// * [`encode_zoned_decimal`] - For encoding zoned decimals
250#[inline]
251#[must_use = "Handle the Result or propagate the error"]
252pub fn decode_zoned_decimal(
253    data: &[u8],
254    digits: u16,
255    scale: i16,
256    signed: bool,
257    codepage: Codepage,
258    blank_when_zero: bool,
259) -> Result<SmallDecimal> {
260    if unlikely(data.len() != usize::from(digits)) {
261        return Err(Error::new(
262            ErrorCode::CBKD411_ZONED_BAD_SIGN,
263            "Zoned decimal data length mismatch".to_string(),
264        ));
265    }
266
267    // Check for BLANK WHEN ZERO (all spaces)
268    let is_all_spaces = data.iter().all(|&b| {
269        match codepage {
270            Codepage::ASCII => b == b' ',
271            _ => b == 0x40, // EBCDIC space
272        }
273    });
274
275    if is_all_spaces {
276        if blank_when_zero {
277            warn!("CBKD412_ZONED_BLANK_IS_ZERO: Zoned field is blank, decoding as zero");
278            // Track this warning in RunSummary
279            crate::lib_api::increment_warning_counter();
280            return Ok(SmallDecimal::zero(scale));
281        }
282        return Err(Error::new(
283            ErrorCode::CBKD411_ZONED_BAD_SIGN,
284            "Zoned field contains all spaces but BLANK WHEN ZERO not specified",
285        ));
286    }
287
288    let mut value = 0i64;
289    let mut is_negative = false;
290    let expected_zone = match codepage {
291        Codepage::ASCII => ASCII_DIGIT_ZONE,
292        _ => EBCDIC_DIGIT_ZONE,
293    };
294
295    for (i, &byte) in data.iter().enumerate() {
296        if i == data.len() - 1 {
297            let (digit, negative) = crate::zoned_overpunch::decode_overpunch_byte(byte, codepage)?;
298
299            if signed {
300                is_negative = negative;
301            } else {
302                let zone = (byte >> 4) & 0x0F;
303                let zone_label = match codepage {
304                    Codepage::ASCII => "ASCII",
305                    _ => "EBCDIC",
306                };
307                if zone != expected_zone {
308                    return Err(Error::new(
309                        ErrorCode::CBKD411_ZONED_BAD_SIGN,
310                        format!(
311                            "Unsigned {zone_label} zoned decimal cannot contain sign zone 0x{zone:X} in last byte"
312                        ),
313                    ));
314                }
315                if negative {
316                    return Err(Error::new(
317                        ErrorCode::CBKD411_ZONED_BAD_SIGN,
318                        "Unsigned zoned decimal contains negative overpunch",
319                    ));
320                }
321            }
322
323            value = zoned_append_digit(value, digit, i)?;
324        } else {
325            let zone = (byte >> 4) & 0x0F;
326            let digit = byte & 0x0F;
327
328            if digit > 9 {
329                return Err(Error::new(
330                    ErrorCode::CBKD411_ZONED_BAD_SIGN,
331                    format!("Invalid digit nibble 0x{digit:X} at position {i}"),
332                ));
333            }
334
335            if zone != expected_zone {
336                let zone_label = match codepage {
337                    Codepage::ASCII => "ASCII",
338                    _ => "EBCDIC",
339                };
340                return Err(Error::new(
341                    ErrorCode::CBKD411_ZONED_BAD_SIGN,
342                    format!(
343                        "Invalid {zone_label} zone 0x{zone:X} at position {i}, expected 0x{expected_zone:X}"
344                    ),
345                ));
346            }
347
348            value = zoned_append_digit(value, digit, i)?;
349        }
350    }
351
352    let mut decimal = SmallDecimal::new(value, scale, is_negative);
353    decimal.normalize(); // Normalize -0 → 0 (NORMATIVE)
354    Ok(decimal)
355}
356
357/// Append one logical digit to a zoned-decimal magnitude without data loss.
358#[inline]
359fn zoned_append_digit(value: i64, digit: u8, position: usize) -> Result<i64> {
360    value
361        .checked_mul(10)
362        .and_then(|scaled| scaled.checked_add(i64::from(digit)))
363        .ok_or_else(|| {
364            Error::new(
365                ErrorCode::CBKD410_ZONED_OVERFLOW,
366                format!(
367                    "Zoned decimal magnitude exceeds i64 capacity while appending digit {digit} at position {position}"
368                ),
369            )
370        })
371}
372
373/// Decode a zoned decimal field with SIGN SEPARATE clause
374///
375/// SIGN SEPARATE stores the sign in a separate byte rather than overpunching
376/// it in the zone portion of the last digit. The sign byte can be leading
377/// (before digits) or trailing (after digits).
378///
379/// # Arguments
380/// * `data` - Raw byte data (includes sign byte + digit bytes)
381/// * `digits` - Number of digit characters (not including sign byte)
382/// * `scale` - Decimal places (can be negative for scaling)
383/// * `sign_separate` - SIGN SEPARATE clause information (placement)
384/// * `codepage` - Character encoding (ASCII or EBCDIC)
385///
386/// # Returns
387/// A `SmallDecimal` containing the decoded value
388///
389/// # Errors
390/// Returns an error if data length is incorrect or sign byte is invalid.
391///
392/// # Examples
393///
394/// ## Leading Sign (ASCII)
395///
396/// ```no_run
397/// use copybook_codec::numeric::{decode_zoned_decimal_sign_separate};
398/// use copybook_codec::options::Codepage;
399/// use copybook_core::SignPlacement;
400/// use copybook_core::SignSeparateInfo;
401///
402/// // "+123" with leading sign: [0x2B, 0x31, 0x32, 0x33]
403/// let sign_info = SignSeparateInfo { placement: SignPlacement::Leading };
404/// let data = [b'+', b'1', b'2', b'3'];
405/// let result = decode_zoned_decimal_sign_separate(&data, 3, 0, &sign_info, Codepage::ASCII)?;
406/// assert_eq!(result.to_string(), "123");
407/// # Ok::<(), copybook_core::Error>(())
408/// ```
409///
410/// ## Trailing Sign (ASCII)
411///
412/// ```no_run
413/// use copybook_codec::numeric::{decode_zoned_decimal_sign_separate};
414/// use copybook_codec::options::Codepage;
415/// use copybook_core::SignPlacement;
416/// use copybook_core::SignSeparateInfo;
417///
418/// // "-456" with trailing sign: [0x34, 0x35, 0x36, 0x2D]
419/// let sign_info = SignSeparateInfo { placement: SignPlacement::Trailing };
420/// let data = [b'4', b'5', b'6', b'-'];
421/// let result = decode_zoned_decimal_sign_separate(&data, 3, 0, &sign_info, Codepage::ASCII)?;
422/// assert_eq!(result.to_string(), "-456");
423/// # Ok::<(), copybook_core::Error>(())
424/// ```
425///
426/// ## EBCDIC Leading Sign
427///
428/// ```no_run
429/// use copybook_codec::numeric::{decode_zoned_decimal_sign_separate};
430/// use copybook_codec::options::Codepage;
431/// use copybook_core::SignPlacement;
432/// use copybook_core::SignSeparateInfo;
433///
434/// // "+789" with leading EBCDIC sign: [0x4E, 0xF7, 0xF8, 0xF9]
435/// let sign_info = SignSeparateInfo { placement: SignPlacement::Leading };
436/// let data = [0x4E, 0xF7, 0xF8, 0xF9];
437/// let result = decode_zoned_decimal_sign_separate(&data, 3, 0, &sign_info, Codepage::CP037)?;
438/// assert_eq!(result.to_string(), "789");
439/// # Ok::<(), copybook_core::Error>(())
440/// ```
441///
442/// # See Also
443/// * [`decode_zoned_decimal`] - For overpunch-encoded zoned decimals
444/// * [`decode_zoned_decimal_with_encoding`] - For encoding detection
445#[inline]
446#[must_use = "Handle the Result or propagate the error"]
447pub fn decode_zoned_decimal_sign_separate(
448    data: &[u8],
449    digits: u16,
450    scale: i16,
451    sign_separate: &SignSeparateInfo,
452    codepage: Codepage,
453) -> Result<SmallDecimal> {
454    // SIGN SEPARATE adds 1 byte for the sign
455    let expected_len = usize::from(digits) + 1;
456
457    if unlikely(data.len() != expected_len) {
458        return Err(Error::new(
459            ErrorCode::CBKD301_RECORD_TOO_SHORT,
460            format!(
461                "SIGN SEPARATE zoned decimal data length mismatch: expected {} bytes, got {}",
462                expected_len,
463                data.len()
464            ),
465        ));
466    }
467
468    // Determine sign byte and digit bytes based on placement
469    let (sign_byte, digit_bytes) = match sign_separate.placement {
470        SignPlacement::Leading => {
471            // Sign byte is first, digits follow
472            if data.is_empty() {
473                return Err(Error::new(
474                    ErrorCode::CBKD301_RECORD_TOO_SHORT,
475                    "SIGN SEPARATE field is empty",
476                ));
477            }
478            (data[0], &data[1..])
479        }
480        SignPlacement::Trailing => {
481            // Digits are first, sign byte is last
482            if data.is_empty() {
483                return Err(Error::new(
484                    ErrorCode::CBKD301_RECORD_TOO_SHORT,
485                    "SIGN SEPARATE field is empty",
486                ));
487            }
488            (data[data.len() - 1], &data[..data.len() - 1])
489        }
490    };
491
492    // Decode sign byte
493
494    let is_negative = if codepage.is_ascii() {
495        match sign_byte {
496            b'-' => true,
497
498            b'+' | b' ' | b'0' => false, // Space or zero means positive/unsigned
499
500            _ => {
501                return Err(Error::new(
502                    ErrorCode::CBKD411_ZONED_BAD_SIGN,
503                    format!("Invalid sign byte in SIGN SEPARATE field: 0x{sign_byte:02X} (ASCII)"),
504                ));
505            }
506        }
507    } else {
508        // EBCDIC codepage (CP037, CP273, CP500, CP1047, CP1140)
509
510        match sign_byte {
511            0x60 => true, // EBCDIC '-'
512
513            0x4E | 0x40 | 0xF0 => false, // Space or zero means positive/unsigned
514
515            _ => {
516                return Err(Error::new(
517                    ErrorCode::CBKD411_ZONED_BAD_SIGN,
518                    format!("Invalid sign byte in SIGN SEPARATE field: 0x{sign_byte:02X} (EBCDIC)"),
519                ));
520            }
521        }
522    };
523
524    // Decode digit bytes
525
526    let mut value: i64 = 0;
527
528    for &byte in digit_bytes {
529        let digit = if codepage.is_ascii() {
530            if !byte.is_ascii_digit() {
531                return Err(Error::new(
532                    ErrorCode::CBKD301_RECORD_TOO_SHORT,
533                    format!("Invalid digit byte in SIGN SEPARATE field: 0x{byte:02X} (ASCII)"),
534                ));
535            }
536
537            byte - b'0'
538        } else {
539            // EBCDIC digits are 0xF0-0xF9
540
541            if !(0xF0..=0xF9).contains(&byte) {
542                return Err(Error::new(
543                    ErrorCode::CBKD301_RECORD_TOO_SHORT,
544                    format!("Invalid digit byte in SIGN SEPARATE field: 0x{byte:02X} (EBCDIC)"),
545                ));
546            }
547
548            byte - 0xF0
549        };
550
551        value = value
552            .checked_mul(10)
553            .and_then(|v| v.checked_add(i64::from(digit)))
554            .ok_or_else(|| {
555                Error::new(
556                    ErrorCode::CBKD410_ZONED_OVERFLOW,
557                    format!("SIGN SEPARATE zoned decimal value overflow for {digits} digits"),
558                )
559            })?;
560    }
561
562    let mut decimal = SmallDecimal::new(value, scale, is_negative);
563    decimal.normalize(); // Normalize -0 → 0 (NORMATIVE)
564    Ok(decimal)
565}
566
567/// Encode a zoned decimal value with SIGN SEPARATE clause.
568///
569/// The SIGN SEPARATE clause places the sign character in a separate byte
570/// (leading or trailing) rather than overpunching the last digit.
571///
572/// Total encoded length = digits + 1 (for the separate sign byte).
573///
574/// # Arguments
575/// * `value` - String representation of the numeric value (e.g., "123", "-456.78")
576/// * `digits` - Number of digit positions in the field
577/// * `scale` - Number of implied decimal places
578/// * `sign_separate` - Sign placement information (leading or trailing)
579/// * `codepage` - Character encoding (determines sign byte encoding)
580/// * `buffer` - Output buffer (must be at least digits + 1 bytes)
581///
582/// # Errors
583/// Returns `CBKE530_SIGN_SEPARATE_ENCODE_ERROR` if the value cannot be encoded.
584#[inline]
585#[must_use = "Handle the Result or propagate the error"]
586pub fn encode_zoned_decimal_sign_separate(
587    value: &str,
588    digits: u16,
589    scale: i16,
590    sign_separate: &SignSeparateInfo,
591    codepage: Codepage,
592    buffer: &mut [u8],
593) -> Result<()> {
594    let expected_len = usize::from(digits) + 1;
595    if buffer.len() < expected_len {
596        return Err(Error::new(
597            ErrorCode::CBKE530_SIGN_SEPARATE_ENCODE_ERROR,
598            format!(
599                "SIGN SEPARATE encode buffer too small: need {expected_len} bytes, got {}",
600                buffer.len()
601            ),
602        ));
603    }
604
605    // Parse value string to determine sign and digit characters
606    let trimmed = value.trim();
607    let (is_negative, abs_str) = if let Some(rest) = trimmed.strip_prefix('-') {
608        (true, rest)
609    } else if let Some(rest) = trimmed.strip_prefix('+') {
610        (false, rest)
611    } else {
612        (false, trimmed)
613    };
614
615    // Validate input characters before scaling
616    for ch in abs_str.chars() {
617        if !ch.is_ascii_digit() && ch != '.' {
618            return Err(Error::new(
619                ErrorCode::CBKE530_SIGN_SEPARATE_ENCODE_ERROR,
620                format!("Unexpected character '{ch}' in numeric value '{value}'"),
621            ));
622        }
623    }
624
625    // Structural validation: reject ambiguous/empty numeric input
626    let dot_count = abs_str.chars().filter(|&c| c == '.').count();
627    let digit_count = abs_str.chars().filter(char::is_ascii_digit).count();
628
629    if digit_count == 0 {
630        return Err(Error::new(
631            ErrorCode::CBKE530_SIGN_SEPARATE_ENCODE_ERROR,
632            format!("No digits found in numeric value '{value}'"),
633        ));
634    }
635    if dot_count > 1 {
636        return Err(Error::new(
637            ErrorCode::CBKE530_SIGN_SEPARATE_ENCODE_ERROR,
638            format!("Multiple decimal points in numeric value '{value}'"),
639        ));
640    }
641    if scale <= 0 && dot_count == 1 {
642        return Err(Error::new(
643            ErrorCode::CBKE530_SIGN_SEPARATE_ENCODE_ERROR,
644            format!("Unexpected decimal point for scale {scale} in value '{value}'"),
645        ));
646    }
647
648    // Build the scaled digit string
649    let scaled = build_scaled_digit_string(abs_str, scale);
650
651    // Pad with leading zeros or truncate to match digit count
652    let digits_usize = usize::from(digits);
653    let padded = match scaled.len().cmp(&digits_usize) {
654        std::cmp::Ordering::Less => {
655            format!("{scaled:0>digits_usize$}")
656        }
657        std::cmp::Ordering::Greater => {
658            return Err(Error::new(
659                ErrorCode::CBKE530_SIGN_SEPARATE_ENCODE_ERROR,
660                format!(
661                    "SIGN SEPARATE overflow: value requires {} digits but field allows {}",
662                    scaled.len(),
663                    digits_usize
664                ),
665            ));
666        }
667        std::cmp::Ordering::Equal => scaled,
668    };
669
670    // Determine sign byte and digit encoding based on codepage
671    let (sign_byte, digit_base): (u8, u8) = if codepage.is_ascii() {
672        (if is_negative { b'-' } else { b'+' }, b'0')
673    } else {
674        // EBCDIC codepages
675        (if is_negative { 0x60 } else { 0x4E }, 0xF0)
676    };
677
678    // Write digits to buffer
679    let digit_offset = match sign_separate.placement {
680        SignPlacement::Leading => {
681            buffer[0] = sign_byte;
682            1
683        }
684        SignPlacement::Trailing => 0,
685    };
686
687    for (i, byte) in padded.bytes().enumerate() {
688        let digit = byte.wrapping_sub(b'0');
689        if digit > 9 {
690            return Err(Error::new(
691                ErrorCode::CBKE530_SIGN_SEPARATE_ENCODE_ERROR,
692                format!("Invalid digit byte 0x{byte:02X} in value"),
693            ));
694        }
695        buffer[digit_offset + i] = digit_base + digit;
696    }
697
698    if matches!(sign_separate.placement, SignPlacement::Trailing) {
699        buffer[digits_usize] = sign_byte;
700    }
701
702    Ok(())
703}
704
705/// Build a digit string scaled to the given number of decimal places.
706///
707/// Splits the absolute value string at the decimal point (if present),
708/// pads or truncates the fractional part to `scale` digits, and concatenates.
709fn build_scaled_digit_string(abs_str: &str, scale: i16) -> String {
710    // Extract only digit characters (ignoring other formatting)
711    let digit_str: String = abs_str.chars().filter(char::is_ascii_digit).collect();
712
713    if scale <= 0 {
714        return digit_str;
715    }
716
717    let scale_usize = usize::try_from(scale).unwrap_or(0);
718    let (integer_part, fractional_part) = if let Some(pos) = abs_str.find('.') {
719        (&abs_str[..pos], &abs_str[pos + 1..])
720    } else {
721        (abs_str, "")
722    };
723
724    let int_digits: String = integer_part.chars().filter(char::is_ascii_digit).collect();
725    let frac_digits: String = fractional_part
726        .chars()
727        .filter(char::is_ascii_digit)
728        .collect();
729
730    // Pad or truncate fractional part to match scale
731    let padded_frac = if frac_digits.len() >= scale_usize {
732        frac_digits[..scale_usize].to_string()
733    } else {
734        format!("{frac_digits:0<scale_usize$}")
735    };
736    format!("{int_digits}{padded_frac}")
737}
738
739/// Decode zoned decimal field with encoding detection and preservation
740///
741/// Returns both the decoded decimal and encoding information for preservation.
742/// When `preserve_encoding` is true, analyzes the input data to detect
743/// whether it uses ASCII or EBCDIC encoding, and whether mixed encodings
744/// are present within the field.
745///
746/// # Arguments
747/// * `data` - Raw byte data containing the zoned decimal
748/// * `digits` - Number of digit characters (field length)
749/// * `scale` - Number of decimal places (can be negative for scaling)
750/// * `signed` - Whether the field is signed (true) or unsigned (false)
751/// * `codepage` - Character encoding (ASCII or EBCDIC variant)
752/// * `blank_when_zero` - If true, all-space fields decode as zero
753/// * `preserve_encoding` - If true, detect and return encoding information
754///
755/// # Returns
756/// A tuple of (`SmallDecimal`, `Option<ZonedEncodingInfo>`) containing:
757/// - The decoded decimal value
758/// - Encoding information (if `preserve_encoding` was true)
759///
760/// # Policy
761/// Mirrors [`decode_zoned_decimal`], defaulting to preferred-zero handling for EBCDIC unless a preserved format dictates otherwise.
762///
763/// # Errors
764/// * `CBKD410_ZONED_OVERFLOW` - if the decoded magnitude exceeds `i64` capacity
765/// * `CBKD411_ZONED_BAD_SIGN` - if the zoned digits, zones, or sign are invalid
766/// * `CBKD413_ZONED_INVALID_ENCODING` - if preserved encoding zones are invalid
767/// * `CBKD414_ZONED_MIXED_ENCODING` - if preserved encoding mixes formats
768/// * `CBKD415_ZONED_ENCODING_AMBIGUOUS` - if preserved encoding cannot be detected
769///
770/// # Examples
771///
772/// ## Basic Decoding Without Preservation
773///
774/// ```no_run
775/// use copybook_codec::numeric::{decode_zoned_decimal_with_encoding};
776/// use copybook_codec::options::Codepage;
777///
778/// let data = b"123";
779/// let (decimal, encoding_info) = decode_zoned_decimal_with_encoding(
780///     data, 3, 0, false, Codepage::ASCII, false, false
781/// )?;
782/// assert_eq!(decimal.to_string(), "123");
783/// assert!(encoding_info.is_none());
784/// # Ok::<(), copybook_core::Error>(())
785/// ```
786///
787/// ## Encoding Detection
788///
789/// ```no_run
790/// use copybook_codec::numeric::{decode_zoned_decimal_with_encoding};
791/// use copybook_codec::options::Codepage;
792/// use copybook_codec::options::ZonedEncodingFormat;
793///
794/// let data = b"123";
795/// let (decimal, encoding_info) = decode_zoned_decimal_with_encoding(
796///     data, 3, 0, false, Codepage::ASCII, false, true
797/// )?;
798/// assert_eq!(decimal.to_string(), "123");
799/// let info = encoding_info.unwrap();
800/// assert_eq!(info.detected_format, ZonedEncodingFormat::Ascii);
801/// assert!(!info.has_mixed_encoding);
802/// # Ok::<(), copybook_core::Error>(())
803/// ```
804///
805/// # See Also
806/// * [`decode_zoned_decimal`] - For basic zoned decimal decoding
807/// * [`ZonedEncodingInfo`] - For encoding detection results
808#[inline]
809#[must_use = "Handle the Result or propagate the error"]
810pub fn decode_zoned_decimal_with_encoding(
811    data: &[u8],
812    digits: u16,
813    scale: i16,
814    signed: bool,
815    codepage: Codepage,
816    blank_when_zero: bool,
817    preserve_encoding: bool,
818) -> Result<(SmallDecimal, Option<ZonedEncodingInfo>)> {
819    if data.len() != usize::from(digits) {
820        return Err(Error::new(
821            ErrorCode::CBKD411_ZONED_BAD_SIGN,
822            format!(
823                "Zoned decimal data length {} doesn't match digits {}",
824                data.len(),
825                digits
826            ),
827        ));
828    }
829
830    // Check for BLANK WHEN ZERO (all spaces)
831    let is_all_spaces = data.iter().all(|&b| {
832        match codepage {
833            Codepage::ASCII => b == b' ',
834            _ => b == 0x40, // EBCDIC space
835        }
836    });
837
838    if is_all_spaces {
839        if blank_when_zero {
840            warn!("CBKD412_ZONED_BLANK_IS_ZERO: Zoned field is blank, decoding as zero");
841            crate::lib_api::increment_warning_counter();
842            return Ok((SmallDecimal::zero(scale), None));
843        }
844        return Err(Error::new(
845            ErrorCode::CBKD411_ZONED_BAD_SIGN,
846            "Zoned field contains all spaces but BLANK WHEN ZERO not specified",
847        ));
848    }
849
850    // Detect encoding if preservation is enabled
851    let encoding_info = if preserve_encoding {
852        Some(ZonedEncodingInfo::detect_from_data(data)?)
853    } else {
854        None
855    };
856
857    // Reject mixed encoding and data with no determinable zoned encoding
858    if let Some(ref info) = encoding_info
859        && info.detected_format == ZonedEncodingFormat::Auto
860    {
861        let (code, message) = if info.has_mixed_encoding {
862            (
863                ErrorCode::CBKD414_ZONED_MIXED_ENCODING,
864                "Mixed ASCII/EBCDIC encoding detected within zoned decimal field",
865            )
866        } else {
867            (
868                ErrorCode::CBKD415_ZONED_ENCODING_AMBIGUOUS,
869                "Unable to determine ASCII or EBCDIC encoding for zoned decimal field",
870            )
871        };
872        return Err(Error::new(code, message));
873    }
874
875    let (value, is_negative) =
876        zoned_decode_digits_with_encoding(data, signed, codepage, preserve_encoding)?;
877
878    let mut decimal = SmallDecimal::new(value, scale, is_negative);
879    decimal.normalize(); // Normalize -0 → 0 (NORMATIVE)
880    Ok((decimal, encoding_info))
881}
882
883/// Internal helper to decode zoned decimal digits with encoding detection
884///
885/// This function handles the core logic of iterating through zoned decimal bytes,
886/// accumulating the numeric value, and optionally detecting/validating the encoding.
887///
888/// # Arguments
889/// * `data` - Raw byte data containing the zoned decimal
890/// * `signed` - Whether the field is signed
891/// * `codepage` - Character encoding (ASCII or EBCDIC variant)
892/// * `preserve_encoding` - If true, validate consistent encoding throughout the field
893///
894/// # Returns
895/// A tuple of (`accumulated_value`, `is_negative`)
896///
897/// # Errors
898/// Returns `CBKD410_ZONED_OVERFLOW` if the magnitude exceeds `i64` capacity,
899/// or a typed zoned-format error if a digit, zone, or sign is invalid.
900#[inline]
901fn zoned_decode_digits_with_encoding(
902    data: &[u8],
903    signed: bool,
904    codepage: Codepage,
905    preserve_encoding: bool,
906) -> Result<(i64, bool)> {
907    let mut value = 0i64;
908    let mut is_negative = false;
909
910    for (index, &byte) in data.iter().enumerate() {
911        let zone = (byte >> 4) & 0x0F;
912
913        if index == data.len() - 1 {
914            let (digit, negative) = crate::zoned_overpunch::decode_overpunch_byte(byte, codepage)?;
915
916            if signed {
917                is_negative = negative;
918            } else {
919                let zone_valid = if preserve_encoding {
920                    matches!(zone, 0x3 | 0xF)
921                } else {
922                    match codepage {
923                        Codepage::ASCII => zone == 0x3,
924                        _ => zone == 0xF,
925                    }
926                };
927
928                if !zone_valid {
929                    let message = if preserve_encoding {
930                        format!(
931                            "Invalid zone 0x{zone:X} in unsigned zoned decimal, expected 0x3 (ASCII) or 0xF (EBCDIC)"
932                        )
933                    } else {
934                        let zone_label = zoned_zone_label(codepage);
935                        format!(
936                            "Unsigned {zone_label} zoned decimal cannot contain sign zone 0x{zone:X} in last byte"
937                        )
938                    };
939                    let code = if preserve_encoding {
940                        ErrorCode::CBKD413_ZONED_INVALID_ENCODING
941                    } else {
942                        ErrorCode::CBKD411_ZONED_BAD_SIGN
943                    };
944                    return Err(Error::new(code, message));
945                }
946
947                if negative {
948                    return Err(Error::new(
949                        ErrorCode::CBKD411_ZONED_BAD_SIGN,
950                        "Unsigned zoned decimal contains negative overpunch",
951                    ));
952                }
953            }
954
955            value = zoned_append_digit(value, digit, index)?;
956        } else {
957            let digit = byte & 0x0F;
958            if digit > 9 {
959                return Err(Error::new(
960                    ErrorCode::CBKD411_ZONED_BAD_SIGN,
961                    format!("Invalid digit nibble 0x{digit:X} at position {index}"),
962                ));
963            }
964
965            if preserve_encoding {
966                match zone {
967                    0x3 | 0xF => {}
968                    _ => {
969                        return Err(Error::new(
970                            ErrorCode::CBKD413_ZONED_INVALID_ENCODING,
971                            format!(
972                                "Invalid zone 0x{zone:X} at position {index}, expected 0x3 (ASCII) or 0xF (EBCDIC)"
973                            ),
974                        ));
975                    }
976                }
977            } else {
978                match codepage {
979                    Codepage::ASCII => {
980                        if zone != 0x3 {
981                            return Err(Error::new(
982                                ErrorCode::CBKD411_ZONED_BAD_SIGN,
983                                format!(
984                                    "Invalid ASCII zone 0x{zone:X} at position {index}, expected 0x3"
985                                ),
986                            ));
987                        }
988                    }
989                    _ => {
990                        if zone != 0xF {
991                            return Err(Error::new(
992                                ErrorCode::CBKD411_ZONED_BAD_SIGN,
993                                format!(
994                                    "Invalid EBCDIC zone 0x{zone:X} at position {index}, expected 0xF"
995                                ),
996                            ));
997                        }
998                    }
999                }
1000            }
1001
1002            value = zoned_append_digit(value, digit, index)?;
1003        }
1004    }
1005
1006    Ok((value, is_negative))
1007}
1008
1009/// Decode packed decimal (COMP-3) field with comprehensive error context
1010///
1011/// Decodes COMP-3 packed decimal format where each byte contains two decimal
1012/// digits (nibbles), with the last nibble containing the sign. This function
1013/// uses optimized fast paths for common enterprise data patterns.
1014///
1015/// # Arguments
1016/// * `data` - Raw byte data containing the packed decimal
1017/// * `digits` - Number of decimal digits in the field (1-18 supported)
1018/// * `scale` - Number of decimal places (can be negative for scaling)
1019/// * `signed` - Whether the field is signed (true) or unsigned (false)
1020///
1021/// # Returns
1022/// A `SmallDecimal` containing the decoded value
1023///
1024/// # Errors
1025/// Returns an error if the packed decimal data contains invalid nibbles.
1026/// All errors include proper context information (`record_index`, `field_path`, `byte_offset`).
1027///
1028/// # Performance
1029/// This function uses specialized fast paths for common cases:
1030/// - 1-5 byte fields: Direct decoding with minimal validation
1031/// - Empty data: Immediate zero return
1032/// - Digits > 18: Error (maximum supported precision)
1033///
1034/// # Examples
1035///
1036/// ## Basic Positive Value
1037///
1038/// ```no_run
1039/// use copybook_codec::numeric::{decode_packed_decimal};
1040///
1041/// // "123" as COMP-3: [0x12, 0x3C] (12 positive, 3C = positive sign)
1042/// let data = [0x12, 0x3C];
1043/// let result = decode_packed_decimal(&data, 3, 0, true)?;
1044/// assert_eq!(result.to_string(), "123");
1045/// # Ok::<(), copybook_core::Error>(())
1046/// ```
1047///
1048/// ## Negative Value
1049///
1050/// ```no_run
1051/// use copybook_codec::numeric::{decode_packed_decimal};
1052///
1053/// // "-456" as COMP-3: [0x04, 0x56, 0xD] (456 negative)
1054/// let data = [0x04, 0x56, 0xD];
1055/// let result = decode_packed_decimal(&data, 3, 0, true)?;
1056/// assert_eq!(result.to_string(), "-456");
1057/// # Ok::<(), copybook_core::Error>(())
1058/// ```
1059///
1060/// ## Decimal Scale
1061///
1062/// ```no_run
1063/// use copybook_codec::numeric::{decode_packed_decimal};
1064///
1065/// // "12.34" with 2 decimal places: [0x12, 0x34, 0xC]
1066/// let data = [0x12, 0x34, 0xC];
1067/// let result = decode_packed_decimal(&data, 4, 2, true)?;
1068/// assert_eq!(result.to_string(), "12.34");
1069/// # Ok::<(), copybook_core::Error>(())
1070/// ```
1071///
1072/// ## Unsigned Field
1073///
1074/// ```no_run
1075/// use copybook_codec::numeric::{decode_packed_decimal};
1076///
1077/// // Unsigned "789": [0x07, 0x89, 0xF] (F = unsigned sign)
1078/// let data = [0x07, 0x89, 0xF];
1079/// let result = decode_packed_decimal(&data, 3, 0, false)?;
1080/// assert_eq!(result.to_string(), "789");
1081/// # Ok::<(), copybook_core::Error>(())
1082/// ```
1083///
1084/// ## Zero Value
1085///
1086/// ```no_run
1087/// use copybook_codec::numeric::{decode_packed_decimal};
1088///
1089/// // Zero: [0x00, 0x0C]
1090/// let data = [0x00, 0x0C];
1091/// let result = decode_packed_decimal(&data, 2, 0, true)?;
1092/// assert_eq!(result.to_string(), "0");
1093/// # Ok::<(), copybook_core::Error>(())
1094/// ```
1095///
1096/// # See Also
1097/// * [`encode_packed_decimal`] - For encoding packed decimals
1098/// * [`decode_packed_decimal_with_scratch`] - For zero-allocation decoding
1099/// * [`decode_packed_decimal_to_string_with_scratch`] - For direct string output
1100#[inline]
1101#[must_use = "Handle the Result or propagate the error"]
1102pub fn decode_packed_decimal(
1103    data: &[u8],
1104    digits: u16,
1105    scale: i16,
1106    signed: bool,
1107) -> Result<SmallDecimal> {
1108    // CRITICAL PERFORMANCE OPTIMIZATION: Ultra-fast path with minimal safety overhead
1109    let expected_bytes = usize::from((digits + 1).div_ceil(2));
1110    // PERFORMANCE CRITICAL: Single branch validation optimized for happy path
1111    if likely(data.len() == expected_bytes && !data.is_empty() && digits <= 18) {
1112        // ULTRA-FAST PATH: Most common enterprise cases with minimal validation
1113        return decode_packed_decimal_fast_path(data, digits, scale, signed);
1114    }
1115
1116    // FALLBACK PATH: Full validation for edge cases
1117    if data.len() != expected_bytes {
1118        return Err(Error::new(
1119            ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1120            "Packed decimal data length mismatch".to_string(),
1121        ));
1122    }
1123
1124    if data.is_empty() {
1125        return Ok(SmallDecimal::zero(scale));
1126    }
1127
1128    if digits > 18 {
1129        return Err(Error::new(
1130            ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1131            format!(
1132                "COMP-3 field with {digits} digits exceeds maximum supported precision (18 digits max for current implementation)"
1133            ),
1134        ));
1135    }
1136
1137    // Delegate to ultra-fast path
1138    decode_packed_decimal_fast_path(data, digits, scale, signed)
1139}
1140
1141/// Ultra-optimized COMP-3 decoder for hot path performance
1142///
1143/// This function is highly optimized for the 95% case of enterprise COBOL processing
1144/// where COMP-3 fields are 1-5 bytes and well-formed. It selects the appropriate
1145/// specialized decoder based on the data length.
1146#[inline]
1147fn decode_packed_decimal_fast_path(
1148    data: &[u8],
1149    digits: u16,
1150    scale: i16,
1151    signed: bool,
1152) -> Result<SmallDecimal> {
1153    match data.len() {
1154        1 => decode_packed_fast_len1(data[0], digits, scale, signed),
1155        2 => decode_packed_fast_len2(data, digits, scale, signed),
1156        3 => decode_packed_fast_len3(data, scale, signed),
1157        _ => decode_packed_fast_general(data, digits, scale, signed),
1158    }
1159}
1160
1161/// Specialized COMP-3 decoder for 1-byte fields (1 digit)
1162///
1163/// # Arguments
1164/// * `byte` - The single byte of packed decimal data
1165/// * `digits` - Number of digits (should be 1)
1166/// * `scale` - Decimal scale
1167/// * `signed` - Whether the field is signed
1168#[inline]
1169fn decode_packed_fast_len1(
1170    byte: u8,
1171    digits: u16,
1172    scale: i16,
1173    signed: bool,
1174) -> Result<SmallDecimal> {
1175    let high_nibble = (byte >> 4) & 0x0F;
1176    let low_nibble = byte & 0x0F;
1177    let mut value = 0i64;
1178
1179    if !digits.is_multiple_of(2) {
1180        if unlikely(high_nibble > 9) {
1181            return Err(Error::new(
1182                ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1183                "Invalid digit nibble in packed decimal".to_string(),
1184            ));
1185        }
1186        value = i64::from(high_nibble);
1187    }
1188
1189    if signed {
1190        let is_negative = match low_nibble {
1191            0xA | 0xC | 0xE | 0xF => false,
1192            0xB | 0xD => true,
1193            _ => {
1194                return Err(Error::new(
1195                    ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1196                    "Invalid sign nibble in packed decimal".to_string(),
1197                ));
1198            }
1199        };
1200        return Ok(create_normalized_decimal(value, scale, is_negative));
1201    }
1202
1203    if unlikely(low_nibble != 0xF) {
1204        return Err(Error::new(
1205            ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1206            "Invalid unsigned sign nibble, expected 0xF".to_string(),
1207        ));
1208    }
1209
1210    Ok(create_normalized_decimal(value, scale, false))
1211}
1212
1213/// Specialized COMP-3 decoder for 2-byte fields (2-3 digits)
1214///
1215/// # Arguments
1216/// * `data` - The 2 bytes of packed decimal data
1217/// * `digits` - Number of digits (2 or 3)
1218/// * `scale` - Decimal scale
1219/// * `signed` - Whether the field is signed
1220#[inline]
1221fn decode_packed_fast_len2(
1222    data: &[u8],
1223    digits: u16,
1224    scale: i16,
1225    signed: bool,
1226) -> Result<SmallDecimal> {
1227    let byte0 = data[0];
1228    let byte1 = data[1];
1229
1230    let d1 = (byte0 >> 4) & 0x0F;
1231    let d2 = byte0 & 0x0F;
1232    let d3 = (byte1 >> 4) & 0x0F;
1233    let sign_nibble = byte1 & 0x0F;
1234
1235    let value = if digits == 2 {
1236        if unlikely(d1 != 0) {
1237            return Err(Error::new(
1238                ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1239                format!("Expected padding nibble 0 for 2-digit field, got 0x{d1:X}"),
1240            ));
1241        }
1242
1243        if unlikely(d2 > 9 || d3 > 9) {
1244            return Err(Error::new(
1245                ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1246                "Invalid digit in 2-digit COMP-3 field".to_string(),
1247            ));
1248        }
1249
1250        i64::from(d2) * 10 + i64::from(d3)
1251    } else {
1252        if unlikely(d1 > 9 || d2 > 9 || d3 > 9) {
1253            return Err(Error::new(
1254                ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1255                "Invalid digit in 3-digit COMP-3 field".to_string(),
1256            ));
1257        }
1258
1259        i64::from(d1) * 100 + i64::from(d2) * 10 + i64::from(d3)
1260    };
1261
1262    let is_negative = if signed {
1263        match sign_nibble {
1264            0xA | 0xC | 0xE | 0xF => false,
1265            0xB | 0xD => true,
1266            _ => {
1267                return Err(Error::new(
1268                    ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1269                    "Invalid sign nibble in packed decimal".to_string(),
1270                ));
1271            }
1272        }
1273    } else {
1274        if unlikely(sign_nibble != 0xF) {
1275            return Err(Error::new(
1276                ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1277                "Invalid unsigned sign nibble, expected 0xF".to_string(),
1278            ));
1279        }
1280        false
1281    };
1282
1283    Ok(create_normalized_decimal(value, scale, is_negative))
1284}
1285
1286/// Specialized COMP-3 decoder for 3-byte fields (4-5 digits)
1287///
1288/// # Arguments
1289/// * `data` - The 3 bytes of packed decimal data
1290/// * `scale` - Decimal scale
1291/// * `signed` - Whether the field is signed
1292#[inline]
1293fn decode_packed_fast_len3(data: &[u8], scale: i16, signed: bool) -> Result<SmallDecimal> {
1294    let byte0 = data[0];
1295    let byte1 = data[1];
1296    let byte2 = data[2];
1297
1298    let d1 = (byte0 >> 4) & 0x0F;
1299    let d2 = byte0 & 0x0F;
1300    let d3 = (byte1 >> 4) & 0x0F;
1301    let d4 = byte1 & 0x0F;
1302    let d5 = (byte2 >> 4) & 0x0F;
1303    let sign_nibble = byte2 & 0x0F;
1304
1305    if unlikely(d1 > 9 || d2 > 9 || d3 > 9 || d4 > 9 || d5 > 9) {
1306        return Err(Error::new(
1307            ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1308            "Invalid digit in 3-byte COMP-3 field".to_string(),
1309        ));
1310    }
1311
1312    let value = i64::from(d1) * 10000
1313        + i64::from(d2) * 1000
1314        + i64::from(d3) * 100
1315        + i64::from(d4) * 10
1316        + i64::from(d5);
1317
1318    let is_negative = if signed {
1319        match sign_nibble {
1320            0xA | 0xC | 0xE | 0xF => false,
1321            0xB | 0xD => true,
1322            _ => {
1323                return Err(Error::new(
1324                    ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1325                    "Invalid sign nibble in packed decimal".to_string(),
1326                ));
1327            }
1328        }
1329    } else {
1330        if unlikely(sign_nibble != 0xF) {
1331            return Err(Error::new(
1332                ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1333                "Invalid unsigned sign nibble, expected 0xF".to_string(),
1334            ));
1335        }
1336        false
1337    };
1338
1339    Ok(create_normalized_decimal(value, scale, is_negative))
1340}
1341
1342/// General-purpose COMP-3 decoder for fields longer than 3 bytes
1343///
1344/// Handles multi-byte packed decimal decoding with support for padding
1345/// nibbles and variable digit counts.
1346///
1347/// # Arguments
1348/// * `data` - The packed decimal data bytes
1349/// * `digits` - Number of decimal digits in the field
1350/// * `scale` - Decimal scale
1351/// * `signed` - Whether the field is signed
1352#[inline]
1353fn decode_packed_fast_general(
1354    data: &[u8],
1355    digits: u16,
1356    scale: i16,
1357    signed: bool,
1358) -> Result<SmallDecimal> {
1359    let total_nibbles = digits + 1;
1360    let has_padding = (total_nibbles & 1) == 1;
1361    let digit_count = usize::from(digits);
1362
1363    let Some((last_byte, prefix_bytes)) = data.split_last() else {
1364        return Err(Error::new(
1365            ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1366            "Packed decimal data is empty".to_string(),
1367        ));
1368    };
1369    let mut value = 0i64;
1370    let mut digit_pos = 0;
1371
1372    for &byte in prefix_bytes {
1373        let high_nibble = (byte >> 4) & 0x0F;
1374        let low_nibble = byte & 0x0F;
1375
1376        if likely(!(digit_pos == 0 && has_padding)) {
1377            if unlikely(high_nibble > 9) {
1378                return Err(Error::new(
1379                    ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1380                    "Invalid digit nibble".to_string(),
1381                ));
1382            }
1383            value = value * 10 + i64::from(high_nibble);
1384            digit_pos += 1;
1385        }
1386
1387        if unlikely(low_nibble > 9) {
1388            return Err(Error::new(
1389                ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1390                "Invalid digit nibble".to_string(),
1391            ));
1392        }
1393        value = value * 10 + i64::from(low_nibble);
1394        digit_pos += 1;
1395    }
1396
1397    let last_high = (*last_byte >> 4) & 0x0F;
1398    let sign_nibble = *last_byte & 0x0F;
1399
1400    if likely(digit_pos < digit_count) {
1401        if unlikely(last_high > 9) {
1402            return Err(Error::new(
1403                ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1404                "Invalid digit nibble".to_string(),
1405            ));
1406        }
1407        value = value * 10 + i64::from(last_high);
1408    }
1409
1410    let is_negative = if signed {
1411        match sign_nibble {
1412            0xA | 0xC | 0xE | 0xF => false,
1413            0xB | 0xD => true,
1414            _ => {
1415                return Err(Error::new(
1416                    ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1417                    "Invalid sign nibble".to_string(),
1418                ));
1419            }
1420        }
1421    } else {
1422        if unlikely(sign_nibble != 0xF) {
1423            return Err(Error::new(
1424                ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1425                "Invalid unsigned sign nibble".to_string(),
1426            ));
1427        }
1428        false
1429    };
1430
1431    Ok(create_normalized_decimal(value, scale, is_negative))
1432}
1433
1434/// Encode a zoned decimal using the configured code page defaults.
1435///
1436/// Encodes decimal values to zoned decimal format (PIC 9) where each digit is stored
1437/// in a byte with a zone nibble and a digit nibble. For signed fields, the
1438/// last byte uses overpunch encoding for the sign.
1439///
1440/// # Arguments
1441/// * `value` - String representation of the decimal value to encode
1442/// * `digits` - Number of digit characters (field length)
1443/// * `scale` - Number of decimal places (can be negative for scaling)
1444/// * `signed` - Whether the field is signed (true) or unsigned (false)
1445/// * `codepage` - Character encoding (ASCII or EBCDIC variant)
1446///
1447/// # Returns
1448/// A vector of bytes containing the encoded zoned decimal
1449///
1450/// # Policy
1451/// Applies `ZeroSignPolicy::Positive` for ASCII and `ZeroSignPolicy::Preferred` for EBCDIC when no overrides are provided.
1452///
1453/// # Errors
1454/// Returns an error if the value cannot be encoded as a zoned decimal with the specified parameters.
1455///
1456/// # Examples
1457///
1458/// ## Basic ASCII Encoding
1459///
1460/// ```no_run
1461/// use copybook_codec::numeric::{encode_zoned_decimal};
1462/// use copybook_codec::options::Codepage;
1463///
1464/// // Encode "123" as ASCII zoned decimal
1465/// let encoded = encode_zoned_decimal("123", 3, 0, false, Codepage::ASCII)?;
1466/// assert_eq!(encoded, b"123"); // [0x31, 0x32, 0x33]
1467/// # Ok::<(), copybook_core::Error>(())
1468/// ```
1469///
1470/// ## Signed ASCII Encoding (Overpunch)
1471///
1472/// ```no_run
1473/// use copybook_codec::numeric::{encode_zoned_decimal};
1474/// use copybook_codec::options::Codepage;
1475///
1476/// // Encode "-456" with overpunch sign
1477/// let encoded = encode_zoned_decimal("-456", 3, 0, true, Codepage::ASCII)?;
1478/// // Last byte 0x4D = 'M' = digit 3 with negative sign
1479/// assert_eq!(encoded, [0x34, 0x35, 0x4D]);
1480/// # Ok::<(), copybook_core::Error>(())
1481/// ```
1482///
1483/// ## EBCDIC Encoding
1484///
1485/// ```no_run
1486/// use copybook_codec::numeric::{encode_zoned_decimal};
1487/// use copybook_codec::options::Codepage;
1488///
1489/// // Encode "789" as EBCDIC zoned decimal
1490/// let encoded = encode_zoned_decimal("789", 3, 0, false, Codepage::CP037)?;
1491/// assert_eq!(encoded, [0xF7, 0xF8, 0xF9]);
1492/// # Ok::<(), copybook_core::Error>(())
1493/// ```
1494///
1495/// ## Decimal Scale
1496///
1497/// ```no_run
1498/// use copybook_codec::numeric::{encode_zoned_decimal};
1499/// use copybook_codec::options::Codepage;
1500///
1501/// // Encode "12.34" with 2 decimal places
1502/// let encoded = encode_zoned_decimal("12.34", 4, 2, false, Codepage::ASCII)?;
1503/// assert_eq!(encoded, b"1234"); // [0x31, 0x32, 0x33, 0x34]
1504/// # Ok::<(), copybook_core::Error>(())
1505/// ```
1506///
1507/// # See Also
1508/// * [`encode_zoned_decimal_with_format`] - For encoding with explicit format
1509/// * [`encode_zoned_decimal_with_format_and_policy`] - For encoding with format and policy
1510/// * [`encode_zoned_decimal_with_bwz`] - For encoding with BLANK WHEN ZERO support
1511/// * [`decode_zoned_decimal`] - For decoding zoned decimals
1512#[inline]
1513#[must_use = "Handle the Result or propagate the error"]
1514pub fn encode_zoned_decimal(
1515    value: &str,
1516    digits: u16,
1517    scale: i16,
1518    signed: bool,
1519    codepage: Codepage,
1520) -> Result<Vec<u8>> {
1521    let zero_policy = if codepage.is_ascii() {
1522        ZeroSignPolicy::Positive
1523    } else {
1524        ZeroSignPolicy::Preferred
1525    };
1526
1527    encode_zoned_decimal_with_format_and_policy(
1528        value,
1529        digits,
1530        scale,
1531        signed,
1532        codepage,
1533        None,
1534        zero_policy,
1535    )
1536}
1537
1538/// Encode a zoned decimal using an explicit encoding override when supplied.
1539///
1540/// Encodes zoned decimal values with an explicit encoding format (ASCII or EBCDIC).
1541/// When `encoding_override` is provided, it takes precedence over the codepage default.
1542/// When `Auto` is specified, the codepage default is used.
1543///
1544/// # Arguments
1545/// * `value` - String representation of the decimal value to encode
1546/// * `digits` - Number of digit characters (field length)
1547/// * `scale` - Number of decimal places (can be negative for scaling)
1548/// * `signed` - Whether the field is signed (true) or unsigned (false)
1549/// * `codepage` - Character encoding (ASCII or EBCDIC variant)
1550/// * `encoding_override` - Optional explicit encoding format (ASCII/EBCDIC/Auto)
1551///
1552/// # Returns
1553/// A vector of bytes containing the encoded zoned decimal
1554///
1555/// # Policy
1556/// Resolves `ZeroSignPolicy` from `encoding_override` first; when unset or `Auto`, falls back to the code page defaults.
1557///
1558/// # Errors
1559/// Returns an error if the value cannot be encoded as a zoned decimal with the specified parameters.
1560///
1561/// # Examples
1562///
1563/// ## ASCII Encoding (Explicit)
1564///
1565/// ```no_run
1566/// use copybook_codec::numeric::{encode_zoned_decimal_with_format};
1567/// use copybook_codec::options::Codepage;
1568/// use copybook_codec::options::ZonedEncodingFormat;
1569///
1570/// // Encode "123" with explicit ASCII encoding
1571/// let encoded = encode_zoned_decimal_with_format(
1572///     "123", 3, 0, false, Codepage::ASCII, Some(ZonedEncodingFormat::Ascii)
1573/// )?;
1574/// assert_eq!(encoded, b"123");
1575/// # Ok::<(), copybook_core::Error>(())
1576/// ```
1577///
1578/// ## EBCDIC Encoding (Explicit)
1579///
1580/// ```no_run
1581/// use copybook_codec::numeric::{encode_zoned_decimal_with_format};
1582/// use copybook_codec::options::Codepage;
1583/// use copybook_codec::options::ZonedEncodingFormat;
1584///
1585/// // Encode "789" with explicit EBCDIC encoding
1586/// let encoded = encode_zoned_decimal_with_format(
1587///     "789", 3, 0, false, Codepage::CP037, Some(ZonedEncodingFormat::Ebcdic)
1588/// )?;
1589/// assert_eq!(encoded, [0xF7, 0xF8, 0xF9]);
1590/// # Ok::<(), copybook_core::Error>(())
1591/// ```
1592///
1593/// ## Auto Encoding (Codepage Default)
1594///
1595/// ```no_run
1596/// use copybook_codec::numeric::{encode_zoned_decimal_with_format};
1597/// use copybook_codec::options::Codepage;
1598/// use copybook_codec::options::ZonedEncodingFormat;
1599///
1600/// // Encode "456" with Auto encoding (uses EBCDIC default for CP037)
1601/// let encoded = encode_zoned_decimal_with_format(
1602///     "456", 3, 0, false, Codepage::CP037, Some(ZonedEncodingFormat::Auto)
1603/// )?;
1604/// assert_eq!(encoded, [0xF4, 0xF5, 0xF6]);
1605/// # Ok::<(), copybook_core::Error>(())
1606/// ```
1607///
1608/// # See Also
1609/// * [`encode_zoned_decimal`] - For encoding with codepage defaults
1610/// * [`encode_zoned_decimal_with_format_and_policy`] - For encoding with format and policy
1611#[inline]
1612#[must_use = "Handle the Result or propagate the error"]
1613pub fn encode_zoned_decimal_with_format(
1614    value: &str,
1615    digits: u16,
1616    scale: i16,
1617    signed: bool,
1618    codepage: Codepage,
1619    encoding_override: Option<ZonedEncodingFormat>,
1620) -> Result<Vec<u8>> {
1621    let zero_policy = match encoding_override {
1622        Some(ZonedEncodingFormat::Ascii) => ZeroSignPolicy::Positive,
1623        Some(ZonedEncodingFormat::Ebcdic) => ZeroSignPolicy::Preferred,
1624        Some(ZonedEncodingFormat::Auto) | None => {
1625            if codepage.is_ascii() {
1626                ZeroSignPolicy::Positive
1627            } else {
1628                ZeroSignPolicy::Preferred
1629            }
1630        }
1631    };
1632
1633    encode_zoned_decimal_with_format_and_policy(
1634        value,
1635        digits,
1636        scale,
1637        signed,
1638        codepage,
1639        encoding_override,
1640        zero_policy,
1641    )
1642}
1643
1644/// Encode a zoned decimal using a caller-resolved format and zero-sign policy.
1645///
1646/// This is the lowest-level zoned decimal encoder. The caller supplies both the
1647/// encoding format override (ASCII vs EBCDIC) and the zero-sign policy, which
1648/// together govern how the sign nibble of the last byte is produced.  Higher-level
1649/// wrappers such as [`encode_zoned_decimal`] and [`encode_zoned_decimal_with_format`]
1650/// resolve these parameters from codec defaults and then delegate here.
1651///
1652/// # Arguments
1653/// * `value` - String representation of the decimal value to encode (e.g. `"123"`, `"-45.67"`)
1654/// * `digits` - Number of digit positions in the COBOL field (PIC digit count)
1655/// * `scale` - Number of implied decimal places (can be negative for scaling)
1656/// * `signed` - Whether the field carries a sign (PIC S9 vs PIC 9)
1657/// * `codepage` - Target character encoding (ASCII or EBCDIC variant)
1658/// * `encoding_override` - Explicit format override; `None` falls back to codepage default
1659/// * `zero_policy` - How the sign nibble is encoded for zero values
1660///
1661/// # Returns
1662/// A vector of bytes containing the encoded zoned decimal in the target encoding.
1663///
1664/// # Policy
1665/// Callers provide the resolved policy in precedence order:
1666/// override → preserved metadata → preferred for the target code page.
1667///
1668/// # Errors
1669/// * `CBKE510_NUMERIC_OVERFLOW` - if the value is too large for the digit count
1670/// * `CBKE501_JSON_TYPE_MISMATCH` - if the input contains non-digit characters
1671///
1672/// # See Also
1673/// * [`encode_zoned_decimal`] - Convenience wrapper that resolves policy from the codepage
1674/// * [`encode_zoned_decimal_with_format`] - Accepts a format override without an explicit policy
1675/// * [`encode_zoned_decimal_with_bwz`] - Adds BLANK WHEN ZERO support
1676#[inline]
1677#[must_use = "Handle the Result or propagate the error"]
1678pub fn encode_zoned_decimal_with_format_and_policy(
1679    value: &str,
1680    digits: u16,
1681    scale: i16,
1682    signed: bool,
1683    codepage: Codepage,
1684    encoding_override: Option<ZonedEncodingFormat>,
1685    zero_policy: ZeroSignPolicy,
1686) -> Result<Vec<u8>> {
1687    // Parse the input value with scale validation (NORMATIVE)
1688    let decimal = SmallDecimal::from_str(value, scale)?;
1689
1690    // Convert to string representation of digits
1691    let abs_value = decimal.value.abs();
1692    let width = usize::from(digits);
1693    let digit_str = format!("{abs_value:0width$}");
1694
1695    if digit_str.len() > width {
1696        return Err(Error::new(
1697            ErrorCode::CBKE510_NUMERIC_OVERFLOW,
1698            format!("Value too large for {digits} digits"),
1699        ));
1700    }
1701
1702    // Determine the encoding format to use
1703    // Precedence: explicit override > codepage default
1704    let mut target_format = encoding_override.unwrap_or(match codepage {
1705        Codepage::ASCII => ZonedEncodingFormat::Ascii,
1706        _ => ZonedEncodingFormat::Ebcdic,
1707    });
1708    if target_format == ZonedEncodingFormat::Auto {
1709        target_format = if codepage.is_ascii() {
1710            ZonedEncodingFormat::Ascii
1711        } else {
1712            ZonedEncodingFormat::Ebcdic
1713        };
1714    }
1715
1716    let mut result = Vec::with_capacity(width);
1717    let digit_bytes = digit_str.as_bytes();
1718
1719    // Encode each digit
1720    for (i, &ascii_digit) in digit_bytes.iter().enumerate() {
1721        let digit = ascii_digit - b'0';
1722        if digit > 9 {
1723            return Err(Error::new(
1724                ErrorCode::CBKE501_JSON_TYPE_MISMATCH,
1725                format!("Invalid digit character: {}", ascii_digit as char),
1726            ));
1727        }
1728
1729        if i == digit_bytes.len() - 1 && signed {
1730            if target_format == ZonedEncodingFormat::Ascii {
1731                let overpunch_byte = encode_overpunch_byte(
1732                    digit,
1733                    decimal.negative,
1734                    Codepage::ASCII,
1735                    ZeroSignPolicy::Positive,
1736                )?;
1737                result.push(overpunch_byte);
1738            } else {
1739                let encode_codepage = if codepage == Codepage::ASCII {
1740                    Codepage::CP037
1741                } else {
1742                    codepage
1743                };
1744                let overpunch_byte =
1745                    encode_overpunch_byte(digit, decimal.negative, encode_codepage, zero_policy)?;
1746                result.push(overpunch_byte);
1747            }
1748        } else {
1749            let zone = match target_format {
1750                ZonedEncodingFormat::Ascii => ASCII_DIGIT_ZONE,
1751                _ => EBCDIC_DIGIT_ZONE,
1752            };
1753            result.push((zone << 4) | digit);
1754        }
1755    }
1756
1757    Ok(result)
1758}
1759
1760/// Encode packed decimal (COMP-3) field
1761///
1762/// Encodes decimal values to COMP-3 packed decimal format where each byte contains
1763/// two decimal digits (nibbles), with the last nibble containing the sign.
1764/// This function is optimized for high-throughput enterprise data processing.
1765///
1766/// # Arguments
1767/// * `value` - String representation of the decimal value to encode
1768/// * `digits` - Number of decimal digits in the field (1-18 supported)
1769/// * `scale` - Number of decimal places (can be negative for scaling)
1770/// * `signed` - Whether the field is signed (true) or unsigned (false)
1771///
1772/// # Returns
1773/// A vector of bytes containing the encoded packed decimal
1774///
1775/// # Errors
1776/// Returns an error if the value cannot be encoded as a packed decimal with the specified parameters.
1777///
1778/// # Performance
1779/// This function uses optimized digit extraction to avoid `format!()` allocation overhead.
1780///
1781/// # Examples
1782///
1783/// ## Basic Positive Value
1784///
1785/// ```no_run
1786/// use copybook_codec::numeric::{encode_packed_decimal};
1787///
1788/// // Encode "123" as COMP-3: [0x12, 0x3C] (12 positive, 3C = positive sign)
1789/// let encoded = encode_packed_decimal("123", 3, 0, true)?;
1790/// assert_eq!(encoded, [0x12, 0x3C]);
1791/// # Ok::<(), copybook_core::Error>(())
1792/// ```
1793///
1794/// ## Negative Value
1795///
1796/// ```no_run
1797/// use copybook_codec::numeric::{encode_packed_decimal};
1798///
1799/// // Encode "-456" as COMP-3: [0x04, 0x56, 0xD] (456 negative)
1800/// let encoded = encode_packed_decimal("-456", 3, 0, true)?;
1801/// assert_eq!(encoded, [0x04, 0x56, 0xD]);
1802/// # Ok::<(), copybook_core::Error>(())
1803/// ```
1804///
1805/// ## Decimal Scale
1806///
1807/// ```no_run
1808/// use copybook_codec::numeric::{encode_packed_decimal};
1809///
1810/// // Encode "12.34" with 2 decimal places: [0x12, 0x34, 0xC]
1811/// let encoded = encode_packed_decimal("12.34", 4, 2, true)?;
1812/// assert_eq!(encoded, [0x12, 0x34, 0xC]);
1813/// # Ok::<(), copybook_core::Error>(())
1814/// ```
1815///
1816/// ## Unsigned Field
1817///
1818/// ```no_run
1819/// use copybook_codec::numeric::{encode_packed_decimal};
1820///
1821/// // Unsigned "789": [0x07, 0x89, 0xF] (F = unsigned sign)
1822/// let encoded = encode_packed_decimal("789", 3, 0, false)?;
1823/// assert_eq!(encoded, [0x07, 0x89, 0xF]);
1824/// # Ok::<(), copybook_core::Error>(())
1825/// ```
1826///
1827/// ## Zero Value
1828///
1829/// ```no_run
1830/// use copybook_codec::numeric::{encode_packed_decimal};
1831///
1832/// // Zero: [0x00, 0x0C]
1833/// let encoded = encode_packed_decimal("0", 2, 0, true)?;
1834/// assert_eq!(encoded, [0x00, 0x0C]);
1835/// # Ok::<(), copybook_core::Error>(())
1836/// ```
1837///
1838/// # See Also
1839/// * [`decode_packed_decimal`] - For decoding packed decimals
1840/// * [`encode_packed_decimal_with_scratch`] - For zero-allocation encoding
1841#[inline]
1842#[must_use = "Handle the Result or propagate the error"]
1843pub fn encode_packed_decimal(
1844    value: &str,
1845    digits: u16,
1846    scale: i16,
1847    signed: bool,
1848) -> Result<Vec<u8>> {
1849    // Parse the input value with scale validation (NORMATIVE)
1850    let decimal = SmallDecimal::from_str(value, scale)?;
1851
1852    // CRITICAL PERFORMANCE OPTIMIZATION: Avoid format!() allocation
1853    // Direct integer-to-digits conversion for massive speedup
1854    let abs_value = decimal.value.abs();
1855
1856    // Fast path for zero
1857    if abs_value == 0 {
1858        let expected_bytes = usize::from((digits + 1).div_ceil(2));
1859        let mut result = vec![0u8; expected_bytes];
1860        // Set sign in last byte
1861        let sign_nibble = if signed {
1862            if decimal.negative { 0x0D } else { 0x0C }
1863        } else {
1864            0x0F
1865        };
1866        result[expected_bytes - 1] = sign_nibble;
1867        return Ok(result);
1868    }
1869
1870    // Pre-allocate digit buffer on stack for speed (up to 18 digits for i64::MAX)
1871    let mut digit_buffer: [u8; 20] = [0; 20];
1872    let mut digit_count = 0;
1873    let mut temp_value = abs_value;
1874
1875    // Extract digits in reverse order using fast division
1876    while temp_value > 0 {
1877        digit_buffer[digit_count] = digit_from_value(temp_value % 10);
1878        temp_value /= 10;
1879        digit_count += 1;
1880    }
1881
1882    // Validate digit count
1883    let digits_usize = usize::from(digits);
1884    if unlikely(digit_count > digits_usize) {
1885        return Err(Error::new(
1886            ErrorCode::CBKE510_NUMERIC_OVERFLOW,
1887            format!("Value too large for {digits} digits"),
1888        ));
1889    }
1890
1891    let expected_bytes = usize::from((digits + 1).div_ceil(2));
1892    let mut result = Vec::with_capacity(expected_bytes);
1893
1894    // CRITICAL FIX: Handle digit positioning correctly for even/odd digit counts
1895    // For packed decimal, we have:
1896    // - Total nibbles needed: digits + 1 (for sign)
1897    // - If digits is even: first nibble is padding (0), then digits, then sign
1898    // - If digits is odd: no padding, digits fill completely, then sign
1899
1900    let has_padding = digits.is_multiple_of(2); // Even digit count requires padding
1901    let total_nibbles = digits_usize + 1 + usize::from(has_padding);
1902
1903    for byte_idx in 0..expected_bytes {
1904        let mut byte_val = 0u8;
1905
1906        // Calculate which nibbles belong to this byte
1907        let nibble_offset = byte_idx * 2;
1908
1909        // High nibble
1910        let high_nibble_idx = nibble_offset;
1911        if high_nibble_idx < total_nibbles - 1 {
1912            // Not the sign nibble
1913            if has_padding && high_nibble_idx == 0 {
1914                // First nibble is padding for even digit count
1915                byte_val |= 0x00 << 4;
1916            } else {
1917                // Calculate which digit this represents
1918                let digit_idx = if has_padding {
1919                    high_nibble_idx - 1
1920                } else {
1921                    high_nibble_idx
1922                };
1923
1924                // CRITICAL FIX: Right-align digits in COMP-3 field (leading zeros, not trailing)
1925                // For field width of 'digits', actual digits should occupy the rightmost positions
1926                if digit_idx >= (digits_usize - digit_count) {
1927                    // This position should contain an actual digit
1928                    let actual_digit_idx = digit_idx - (digits_usize - digit_count);
1929                    if actual_digit_idx < digit_count {
1930                        // Digits are stored in reverse order (least significant first)
1931                        let digit_pos_from_right = digit_count - 1 - actual_digit_idx;
1932                        let digit = digit_buffer[digit_pos_from_right];
1933                        byte_val |= digit << 4;
1934                    }
1935                }
1936                // else: leading zero for large digit field (byte_val already initialized to 0)
1937            }
1938        }
1939
1940        // Low nibble
1941        let low_nibble_idx = nibble_offset + 1;
1942        if low_nibble_idx == total_nibbles - 1 {
1943            // This is the sign nibble
1944            byte_val |= if signed {
1945                if decimal.negative { 0x0D } else { 0x0C }
1946            } else {
1947                0x0F
1948            };
1949        } else if low_nibble_idx < total_nibbles - 1 {
1950            // Calculate which digit this represents
1951            let digit_idx = if has_padding {
1952                low_nibble_idx - 1
1953            } else {
1954                low_nibble_idx
1955            };
1956
1957            // CRITICAL FIX: Right-align digits in COMP-3 field (leading zeros, not trailing)
1958            // For field width of 'digits', actual digits should occupy the rightmost positions
1959            if digit_idx >= (digits_usize - digit_count) {
1960                // This position should contain an actual digit
1961                let actual_digit_idx = digit_idx - (digits_usize - digit_count);
1962                if actual_digit_idx < digit_count {
1963                    // Digits are stored in reverse order (least significant first)
1964                    let digit_pos_from_right = digit_count - 1 - actual_digit_idx;
1965                    let digit = digit_buffer[digit_pos_from_right];
1966                    byte_val |= digit;
1967                }
1968            }
1969            // else: leading zero for large digit field (byte_val already initialized to 0)
1970        }
1971
1972        result.push(byte_val);
1973    }
1974
1975    Ok(result)
1976}
1977
1978/// Determine whether a value should be encoded as all spaces under the
1979/// COBOL `BLANK WHEN ZERO` clause.
1980///
1981/// Returns `true` when `bwz_encode` is enabled **and** the string value
1982/// represents zero (including decimal zeros such as `"0.00"`).  Callers
1983/// use this check before encoding to decide whether to emit a
1984/// space-filled field instead of the normal numeric encoding.
1985///
1986/// # Arguments
1987/// * `value` - String representation of the numeric value to test
1988/// * `bwz_encode` - Whether the BLANK WHEN ZERO clause is active for this field
1989///
1990/// # Returns
1991/// `true` if the field should be encoded as all spaces; `false` otherwise.
1992///
1993/// # Examples
1994///
1995/// ```
1996/// use copybook_codec::numeric::should_encode_as_blank_when_zero;
1997///
1998/// assert!(should_encode_as_blank_when_zero("0", true));
1999/// assert!(should_encode_as_blank_when_zero("0.00", true));
2000/// assert!(!should_encode_as_blank_when_zero("42", true));
2001/// assert!(!should_encode_as_blank_when_zero("0", false)); // BWZ disabled
2002/// ```
2003///
2004/// # See Also
2005/// * [`encode_zoned_decimal_with_bwz`] - Uses this function to apply the BWZ policy
2006#[inline]
2007#[must_use]
2008pub fn should_encode_as_blank_when_zero(value: &str, bwz_encode: bool) -> bool {
2009    if !bwz_encode {
2010        return false;
2011    }
2012
2013    // Check if value is zero (with any scale)
2014    let trimmed = value.trim();
2015    if trimmed.is_empty() || trimmed == "0" {
2016        return true;
2017    }
2018
2019    // Check for decimal zero (0.00, 0.000, etc.)
2020    if let Some(dot_pos) = trimmed.find('.') {
2021        let integer_part = &trimmed[..dot_pos];
2022        let fractional_part = &trimmed[dot_pos + 1..];
2023
2024        if integer_part == "0" && fractional_part.chars().all(|c| c == '0') {
2025            return true;
2026        }
2027    }
2028
2029    false
2030}
2031
2032/// Encode a zoned decimal with COBOL `BLANK WHEN ZERO` support.
2033///
2034/// When `bwz_encode` is `true` and the value is zero (including decimal zeros
2035/// like `"0.00"`), the entire field is filled with space bytes (ASCII `0x20`
2036/// or EBCDIC `0x40`).  Otherwise, encoding delegates to [`encode_zoned_decimal`].
2037///
2038/// # Arguments
2039/// * `value` - String representation of the decimal value to encode
2040/// * `digits` - Number of digit positions in the COBOL field
2041/// * `scale` - Number of implied decimal places
2042/// * `signed` - Whether the field carries a sign
2043/// * `codepage` - Target character encoding
2044/// * `bwz_encode` - Whether the BLANK WHEN ZERO clause is active
2045///
2046/// # Returns
2047/// A vector of bytes containing the encoded zoned decimal, or all-space bytes
2048/// when the BWZ policy triggers.
2049///
2050/// # Errors
2051/// Returns an error if the value cannot be represented in the target zoned
2052/// decimal format (delegates error handling to [`encode_zoned_decimal`]).
2053///
2054/// # See Also
2055/// * [`should_encode_as_blank_when_zero`] - The predicate used to test zero values
2056/// * [`encode_zoned_decimal`] - Non-BWZ zoned decimal encoding
2057#[inline]
2058#[must_use = "Handle the Result or propagate the error"]
2059pub fn encode_zoned_decimal_with_bwz(
2060    value: &str,
2061    digits: u16,
2062    scale: i16,
2063    signed: bool,
2064    codepage: Codepage,
2065    bwz_encode: bool,
2066) -> Result<Vec<u8>> {
2067    // Check BWZ policy first
2068    if should_encode_as_blank_when_zero(value, bwz_encode) {
2069        let space_byte = match codepage {
2070            Codepage::ASCII => b' ',
2071            _ => 0x40, // EBCDIC space
2072        };
2073        return Ok(vec![space_byte; usize::from(digits)]);
2074    }
2075
2076    encode_zoned_decimal(value, digits, scale, signed, codepage)
2077}
2078
2079/// Get the space byte value for a given codepage
2080///
2081/// Returns the appropriate space character byte for ASCII or EBCDIC codepages.
2082///
2083/// # Arguments
2084/// * `codepage` - The target codepage
2085///
2086/// # Returns
2087/// * `0x20` (ASCII space) for ASCII codepage
2088/// * `0x40` (EBCDIC space) for EBCDIC codepages
2089///
2090/// # Examples
2091/// ```
2092/// use copybook_codec::options::Codepage;
2093/// # fn zoned_space_byte(codepage: Codepage) -> u8 {
2094/// #     match codepage {
2095/// #         Codepage::ASCII => b' ',
2096/// #         _ => 0x40,
2097/// #     }
2098/// # }
2099///
2100/// assert_eq!(zoned_space_byte(Codepage::ASCII), b' ');
2101/// assert_eq!(zoned_space_byte(Codepage::CP037), 0x40);
2102/// ```
2103#[inline]
2104const fn zoned_space_byte(codepage: Codepage) -> u8 {
2105    match codepage {
2106        Codepage::ASCII => b' ',
2107        _ => 0x40,
2108    }
2109}
2110
2111/// Get the expected zone nibble for valid digits
2112///
2113/// Returns the zone nibble value expected for digit bytes in zoned decimal
2114/// encoding for the given codepage.
2115///
2116/// # Arguments
2117/// * `codepage` - The target codepage
2118///
2119/// # Returns
2120/// * `0x3` for ASCII (digits 0x30-0x39)
2121/// * `0xF` for EBCDIC (digits 0xF0-0xF9)
2122///
2123/// # Examples
2124/// ```
2125/// use copybook_codec::options::Codepage;
2126/// # const ASCII_DIGIT_ZONE: u8 = 0x3;
2127/// # const EBCDIC_DIGIT_ZONE: u8 = 0xF;
2128/// # fn zoned_expected_zone(codepage: Codepage) -> u8 {
2129/// #     match codepage {
2130/// #         Codepage::ASCII => ASCII_DIGIT_ZONE,
2131/// #         _ => EBCDIC_DIGIT_ZONE,
2132/// #     }
2133/// # }
2134///
2135/// assert_eq!(zoned_expected_zone(Codepage::ASCII), 0x3);
2136/// assert_eq!(zoned_expected_zone(Codepage::CP037), 0xF);
2137/// ```
2138#[inline]
2139const fn zoned_expected_zone(codepage: Codepage) -> u8 {
2140    match codepage {
2141        Codepage::ASCII => ASCII_DIGIT_ZONE,
2142        _ => EBCDIC_DIGIT_ZONE,
2143    }
2144}
2145
2146/// Get a human-readable label for the encoding zone type
2147///
2148/// Returns a string label describing the encoding zone type for error messages.
2149///
2150/// # Arguments
2151/// * `codepage` - The target codepage
2152///
2153/// # Returns
2154/// * `"ASCII"` for ASCII codepage
2155/// * `"EBCDIC"` for EBCDIC codepages
2156///
2157/// # Examples
2158/// ```
2159/// use copybook_codec::options::Codepage;
2160/// # fn zoned_zone_label(codepage: Codepage) -> &'static str {
2161/// #     match codepage {
2162/// #         Codepage::ASCII => "ASCII",
2163/// #         _ => "EBCDIC",
2164/// #     }
2165/// # }
2166///
2167/// assert_eq!(zoned_zone_label(Codepage::ASCII), "ASCII");
2168/// assert_eq!(zoned_zone_label(Codepage::CP037), "EBCDIC");
2169/// ```
2170#[inline]
2171const fn zoned_zone_label(codepage: Codepage) -> &'static str {
2172    match codepage {
2173        Codepage::ASCII => "ASCII",
2174        _ => "EBCDIC",
2175    }
2176}
2177
2178/// Validate a non-final byte in a zoned decimal field
2179///
2180/// Checks that the byte contains a valid digit nibble (0-9) and the expected
2181/// zone nibble for the codepage. Non-final bytes should not contain sign information.
2182///
2183/// # Arguments
2184/// * `byte` - The byte to validate
2185/// * `index` - Position of the byte in the field (for error messages)
2186/// * `expected_zone` - Expected zone nibble value (0x3 for ASCII, 0xF for EBCDIC)
2187/// * `codepage` - Target codepage for zone validation
2188///
2189/// # Returns
2190/// The digit nibble value (0-9) extracted from the byte
2191///
2192/// # Errors
2193/// * `CBKD411_ZONED_BAD_SIGN` - Invalid digit nibble or mismatched zone
2194///
2195/// # Examples
2196/// ```text
2197/// // ASCII '5' is 0x35 (zone 0x3, digit 0x5)
2198/// let digit = zoned_validate_non_final_byte(0x35, 0, 0x3, Codepage::ASCII)?;
2199/// assert_eq!(digit, 5);
2200/// ```
2201#[inline]
2202fn zoned_validate_non_final_byte(
2203    byte: u8,
2204    index: usize,
2205    expected_zone: u8,
2206    codepage: Codepage,
2207) -> Result<u8> {
2208    let zone = (byte >> 4) & 0x0F;
2209    let digit = byte & 0x0F;
2210
2211    if digit > 9 {
2212        return Err(Error::new(
2213            ErrorCode::CBKD411_ZONED_BAD_SIGN,
2214            format!("Invalid digit nibble 0x{digit:X} at position {index}"),
2215        ));
2216    }
2217
2218    if zone != expected_zone {
2219        let zone_label = zoned_zone_label(codepage);
2220        return Err(Error::new(
2221            ErrorCode::CBKD411_ZONED_BAD_SIGN,
2222            format!(
2223                "Invalid {zone_label} zone 0x{zone:X} at position {index}, expected 0x{expected_zone:X}"
2224            ),
2225        ));
2226    }
2227
2228    Ok(digit)
2229}
2230
2231/// Process all non-final digits in a zoned decimal field
2232///
2233/// Validates each byte's zone and digit nibbles, accumulates the numeric value,
2234/// and stores digits in the scratch buffer for verification.
2235///
2236/// # Arguments
2237/// * `data` - Non-final bytes of the zoned decimal field
2238/// * `expected_zone` - Expected zone nibble (0x3 for ASCII, 0xF for EBCDIC)
2239/// * `codepage` - Target codepage
2240/// * `scratch` - Scratch buffers for digit accumulation
2241///
2242/// # Returns
2243/// Accumulated integer value from non-final digits
2244///
2245/// # Errors
2246/// * `CBKD410_ZONED_OVERFLOW` - Magnitude exceeds `i64` capacity
2247/// * `CBKD411_ZONED_BAD_SIGN` - Invalid zone or digit nibble encountered
2248#[inline]
2249fn zoned_process_non_final_digits(
2250    data: &[u8],
2251    expected_zone: u8,
2252    codepage: Codepage,
2253    scratch: &mut ScratchBuffers,
2254) -> Result<i64> {
2255    let mut value = 0i64;
2256
2257    for (index, &byte) in data.iter().enumerate() {
2258        let digit = zoned_validate_non_final_byte(byte, index, expected_zone, codepage)?;
2259        scratch.digit_buffer.push(digit);
2260        value = zoned_append_digit(value, digit, index)?;
2261    }
2262
2263    Ok(value)
2264}
2265
2266/// Decode the last byte of a zoned decimal field
2267///
2268/// The last byte contains both a digit and sign information encoded as an
2269/// overpunch character. Delegates to the overpunch decoder for extraction.
2270///
2271/// # Arguments
2272/// * `byte` - The final byte of the zoned decimal field
2273/// * `codepage` - Target codepage for overpunch interpretation
2274///
2275/// # Returns
2276/// Tuple of (digit, `is_negative`) extracted from the overpunch byte
2277///
2278/// # Errors
2279/// * `CBKD411_ZONED_BAD_SIGN` - Invalid overpunch encoding
2280///
2281/// # See Also
2282/// * `zoned_overpunch::decode_overpunch_byte` - Underlying overpunch decoder
2283#[inline]
2284fn zoned_decode_last_byte(byte: u8, codepage: Codepage) -> Result<(u8, bool)> {
2285    crate::zoned_overpunch::decode_overpunch_byte(byte, codepage)
2286}
2287
2288/// Ensure unsigned zoned decimal has no sign information
2289///
2290/// Validates that an unsigned zoned decimal field contains only unsigned zone
2291/// nibbles and no negative overpunch encoding.
2292///
2293/// # Arguments
2294/// * `last_byte` - The final byte of the field
2295/// * `expected_zone` - Expected unsigned zone (0x3 for ASCII, 0xF for EBCDIC)
2296/// * `codepage` - Target codepage
2297/// * `negative` - Whether overpunch decoding detected a negative sign
2298///
2299/// # Returns
2300/// Always returns `Ok(false)` for valid unsigned fields
2301///
2302/// # Errors
2303/// * `CBKD411_ZONED_BAD_SIGN` - Sign zone or negative overpunch in unsigned field
2304///
2305/// # Examples
2306/// ```text
2307/// // Valid unsigned ASCII zoned decimal ends with zone 0x3
2308/// let result = zoned_ensure_unsigned(0x35, 0x3, Codepage::ASCII, false)?;
2309/// assert_eq!(result, false);
2310/// ```
2311#[inline]
2312fn zoned_ensure_unsigned(
2313    last_byte: u8,
2314    expected_zone: u8,
2315    codepage: Codepage,
2316    negative: bool,
2317) -> Result<bool> {
2318    let zone = (last_byte >> 4) & 0x0F;
2319    if zone != expected_zone {
2320        let zone_label = zoned_zone_label(codepage);
2321        return Err(Error::new(
2322            ErrorCode::CBKD411_ZONED_BAD_SIGN,
2323            format!(
2324                "Unsigned {zone_label} zoned decimal cannot contain sign zone 0x{zone:X} in last byte"
2325            ),
2326        ));
2327    }
2328
2329    if negative {
2330        return Err(Error::new(
2331            ErrorCode::CBKD411_ZONED_BAD_SIGN,
2332            "Unsigned zoned decimal contains negative overpunch",
2333        ));
2334    }
2335
2336    Ok(false)
2337}
2338
2339/// Decode a zoned decimal using the configured code page and policy while reusing scratch buffers.
2340///
2341/// Decodes zoned decimal fields while reusing scratch buffers to avoid repeated allocations.
2342/// This is optimized for high-throughput processing where the same scratch buffers
2343/// are used across multiple decode operations.
2344///
2345/// # Arguments
2346/// * `data` - Raw byte data containing the zoned decimal
2347/// * `digits` - Number of digit characters (field length)
2348/// * `scale` - Number of decimal places (can be negative for scaling)
2349/// * `signed` - Whether the field is signed (true) or unsigned (false)
2350/// * `codepage` - Character encoding (ASCII or EBCDIC variant)
2351/// * `blank_when_zero` - If true, all-space fields decode as zero
2352/// * `scratch` - Mutable reference to scratch buffers for reuse
2353///
2354/// # Returns
2355/// A `SmallDecimal` containing of decoded value
2356///
2357/// # Policy
2358/// Defaults to *preferred zero sign* (`ZeroSignPolicy::Preferred`) for EBCDIC zeros unless
2359/// `preserve_zoned_encoding` captured an explicit format at decode.
2360///
2361/// # Errors
2362/// * `CBKD410_ZONED_OVERFLOW` - if the decoded magnitude exceeds `i64` capacity
2363/// * `CBKD411_ZONED_BAD_SIGN` - if the zone nibbles or last-byte sign are invalid
2364///
2365/// # Performance
2366/// This function avoids allocations by reusing scratch buffers across decode operations.
2367/// Use this for processing multiple zoned decimal fields in a loop.
2368///
2369/// # Examples
2370///
2371/// ## Basic Decoding
2372///
2373/// ```no_run
2374/// use copybook_codec::numeric::{decode_zoned_decimal_with_scratch};
2375/// use copybook_codec::runtime::ScratchBuffers;
2376/// use copybook_codec::options::Codepage;
2377///
2378/// let mut scratch = ScratchBuffers::new();
2379/// let data = b"123";
2380/// let result = decode_zoned_decimal_with_scratch(data, 3, 0, false, Codepage::ASCII, false, &mut scratch)?;
2381/// assert_eq!(result.to_string(), "123");
2382/// # Ok::<(), copybook_core::Error>(())
2383/// ```
2384///
2385/// ## With BLANK WHEN ZERO
2386///
2387/// ```no_run
2388/// use copybook_codec::numeric::{decode_zoned_decimal_with_scratch};
2389/// use copybook_codec::runtime::ScratchBuffers;
2390/// use copybook_codec::options::Codepage;
2391///
2392/// let mut scratch = ScratchBuffers::new();
2393/// let data = b"   "; // 3 ASCII spaces
2394/// let result = decode_zoned_decimal_with_scratch(data, 3, 0, false, Codepage::ASCII, true, &mut scratch)?;
2395/// assert_eq!(result.to_string(), "0");
2396/// # Ok::<(), copybook_core::Error>(())
2397/// ```
2398///
2399/// # See Also
2400/// * [`decode_zoned_decimal`] - For basic zoned decimal decoding
2401/// * [`crate::runtime::ScratchBuffers`] - For scratch buffer management
2402#[inline]
2403#[must_use = "Handle the Result or propagate the error"]
2404pub fn decode_zoned_decimal_with_scratch(
2405    data: &[u8],
2406    digits: u16,
2407    scale: i16,
2408    signed: bool,
2409    codepage: Codepage,
2410    blank_when_zero: bool,
2411    scratch: &mut ScratchBuffers,
2412) -> Result<SmallDecimal> {
2413    if data.len() != usize::from(digits) {
2414        return Err(Error::new(
2415            ErrorCode::CBKD411_ZONED_BAD_SIGN,
2416            format!(
2417                "Zoned decimal data length {} doesn't match digits {}",
2418                data.len(),
2419                digits
2420            ),
2421        ));
2422    }
2423
2424    // Check for BLANK WHEN ZERO (all spaces) - optimized check
2425    let space_byte = zoned_space_byte(codepage);
2426
2427    let is_all_spaces = data.iter().all(|&b| b == space_byte);
2428    if is_all_spaces {
2429        if blank_when_zero {
2430            warn!("CBKD412_ZONED_BLANK_IS_ZERO: Zoned field is blank, decoding as zero");
2431            crate::lib_api::increment_warning_counter();
2432            return Ok(SmallDecimal::zero(scale));
2433        }
2434        return Err(Error::new(
2435            ErrorCode::CBKD411_ZONED_BAD_SIGN,
2436            "Zoned field contains all spaces but BLANK WHEN ZERO not specified",
2437        ));
2438    }
2439
2440    // Clear and prepare digit buffer for reuse
2441    scratch.digit_buffer.clear();
2442    scratch.digit_buffer.reserve(usize::from(digits));
2443
2444    let expected_zone = zoned_expected_zone(codepage);
2445    let Some((&last_byte, non_final)) = data.split_last() else {
2446        return Err(Error::new(
2447            ErrorCode::CBKD411_ZONED_BAD_SIGN,
2448            "Zoned decimal field is empty",
2449        ));
2450    };
2451    let partial_value =
2452        zoned_process_non_final_digits(non_final, expected_zone, codepage, scratch)?;
2453    let (last_digit, negative) = zoned_decode_last_byte(last_byte, codepage)?;
2454    scratch.digit_buffer.push(last_digit);
2455    let is_negative = if signed {
2456        negative
2457    } else {
2458        zoned_ensure_unsigned(last_byte, expected_zone, codepage, negative)?
2459    };
2460    let value = zoned_append_digit(partial_value, last_digit, non_final.len())?;
2461    let mut decimal = SmallDecimal::new(value, scale, is_negative);
2462    decimal.normalize();
2463
2464    debug_assert!(
2465        scratch.digit_buffer.iter().all(|&d| d <= 9),
2466        "scratch digit buffer must contain only logical digits"
2467    );
2468    Ok(decimal)
2469}
2470
2471/// Decode a single-byte packed decimal value
2472///
2473/// Handles the special case where the entire packed decimal fits in one byte.
2474/// For 1-digit fields, the high nibble contains the digit and low nibble contains
2475/// the sign. For 0-digit fields (just sign), only the low nibble is significant.
2476///
2477/// # Arguments
2478/// * `byte` - The packed decimal byte
2479/// * `digits` - Number of digits (0 or 1 for single byte)
2480/// * `scale` - Decimal scale
2481/// * `signed` - Whether the field is signed
2482///
2483/// # Returns
2484/// Decoded `SmallDecimal` value
2485///
2486/// # Errors
2487/// * `CBKD401_COMP3_INVALID_NIBBLE` - Invalid digit or sign nibble
2488///
2489/// # Format
2490/// Single-byte packed decimals:
2491/// - 1 digit: `[digit][sign]` (e.g., 0x5C = 5 positive)
2492/// - 0 digits: `[0][sign]` (just sign, high nibble must be 0)
2493///
2494/// Valid sign nibbles:
2495/// - Positive: 0xA, 0xC, 0xE, 0xF
2496/// - Negative: 0xB, 0xD
2497/// - Unsigned: 0xF only
2498#[inline]
2499fn packed_decode_single_byte(
2500    byte: u8,
2501    digits: u16,
2502    scale: i16,
2503    signed: bool,
2504) -> Result<SmallDecimal> {
2505    let high_nibble = (byte >> 4) & 0x0F;
2506    let low_nibble = byte & 0x0F;
2507    let mut value = 0i64;
2508
2509    if digits == 1 {
2510        if high_nibble > 9 {
2511            return Err(Error::new(
2512                ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2513                format!("Invalid digit nibble 0x{high_nibble:X}"),
2514            ));
2515        }
2516        value = i64::from(high_nibble);
2517    }
2518
2519    let is_negative = if signed {
2520        match low_nibble {
2521            0xA | 0xC | 0xE | 0xF => false,
2522            0xB | 0xD => true,
2523            _ => {
2524                return Err(Error::new(
2525                    ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2526                    format!("Invalid sign nibble 0x{low_nibble:X}"),
2527                ));
2528            }
2529        }
2530    } else {
2531        if low_nibble != 0xF {
2532            return Err(Error::new(
2533                ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2534                format!("Invalid unsigned sign nibble 0x{low_nibble:X}, expected 0xF"),
2535            ));
2536        }
2537        false
2538    };
2539
2540    Ok(create_normalized_decimal(value, scale, is_negative))
2541}
2542
2543/// Add a digit to the accumulating packed decimal value
2544///
2545/// Multiplies the current value by 10 and adds the new digit, with overflow checking.
2546///
2547/// # Arguments
2548/// * `value` - Mutable reference to the accumulating value
2549/// * `digit` - Digit to add (0-9)
2550///
2551/// # Returns
2552/// `Ok(())` on success
2553///
2554/// # Errors
2555/// * `CBKD411_ZONED_BAD_SIGN` - Numeric overflow during accumulation
2556///
2557/// # Performance
2558/// Uses checked arithmetic to prevent panics while detecting overflow conditions.
2559#[inline]
2560fn packed_push_digit(value: &mut i64, digit: u8) -> Result<()> {
2561    *value = value
2562        .checked_mul(10)
2563        .and_then(|v| v.checked_add(i64::from(digit)))
2564        .ok_or_else(|| {
2565            Error::new(
2566                ErrorCode::CBKD411_ZONED_BAD_SIGN,
2567                "Numeric overflow during zoned decimal conversion",
2568            )
2569        })?;
2570    Ok(())
2571}
2572
2573/// Process non-final bytes of a multi-byte packed decimal
2574///
2575/// Extracts digit nibbles from all bytes before the last one, handling padding
2576/// if the digit count is odd. Accumulates the numeric value and counts digits.
2577///
2578/// # Arguments
2579/// * `bytes` - Non-final bytes of the packed decimal
2580/// * `digits` - Total number of digits in the field
2581/// * `has_padding` - Whether the first nibble is padding (odd total nibbles)
2582///
2583/// # Returns
2584/// Tuple of (`accumulated_value`, `digit_count`)
2585///
2586/// # Errors
2587/// * `CBKD401_COMP3_INVALID_NIBBLE` - Invalid digit or padding nibble
2588///
2589/// # Format
2590/// Packed decimal nibble layout:
2591/// - Even digits: `[pad=0][d1][d2][d3]...[sign]`
2592/// - Odd digits: `[d1][d2][d3]...[sign]` (no padding)
2593#[inline]
2594fn packed_process_non_last_bytes(
2595    bytes: &[u8],
2596    digits: u16,
2597    has_padding: bool,
2598) -> Result<(i64, u16)> {
2599    let mut value = 0i64;
2600    let mut digit_count: u16 = 0;
2601
2602    for (index, &byte) in bytes.iter().enumerate() {
2603        let high_nibble = (byte >> 4) & 0x0F;
2604        let low_nibble = byte & 0x0F;
2605
2606        if index == 0 && has_padding {
2607            if high_nibble != 0 {
2608                return Err(Error::new(
2609                    ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2610                    format!("Expected padding nibble 0, got 0x{high_nibble:X}"),
2611                ));
2612            }
2613        } else {
2614            if high_nibble > 9 {
2615                return Err(Error::new(
2616                    ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2617                    format!("Invalid digit nibble 0x{high_nibble:X}"),
2618                ));
2619            }
2620            packed_push_digit(&mut value, high_nibble)?;
2621            digit_count += 1;
2622        }
2623
2624        if digit_count >= digits {
2625            break;
2626        }
2627
2628        if low_nibble > 9 {
2629            return Err(Error::new(
2630                ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2631                format!("Invalid digit nibble 0x{low_nibble:X}"),
2632            ));
2633        }
2634        packed_push_digit(&mut value, low_nibble)?;
2635        digit_count += 1;
2636
2637        if digit_count >= digits {
2638            break;
2639        }
2640    }
2641
2642    Ok((value, digit_count))
2643}
2644
2645/// Process the last byte of a packed decimal field
2646///
2647/// Extracts the final digit (if needed) and sign nibble from the last byte.
2648/// Creates the final normalized `SmallDecimal` value.
2649///
2650/// # Arguments
2651/// * `value` - Accumulated value from previous bytes
2652/// * `last_byte` - The final byte containing digit and sign
2653/// * `digits` - Total number of digits expected
2654/// * `digit_count` - Number of digits already processed
2655/// * `scale` - Decimal scale
2656/// * `signed` - Whether the field is signed
2657///
2658/// # Returns
2659/// Decoded and normalized `SmallDecimal`
2660///
2661/// # Errors
2662/// * `CBKD401_COMP3_INVALID_NIBBLE` - Invalid digit or sign nibble
2663///
2664/// # Format
2665/// Last byte always ends with sign nibble:
2666/// - If `digit_count` < digits: `[digit][sign]`
2667/// - If `digit_count` == digits: `[unused][sign]` (high nibble ignored)
2668#[inline]
2669fn packed_finish_last_byte(
2670    mut value: i64,
2671    last_byte: u8,
2672    digits: u16,
2673    digit_count: u16,
2674    scale: i16,
2675    signed: bool,
2676) -> Result<SmallDecimal> {
2677    let high_nibble = (last_byte >> 4) & 0x0F;
2678    let low_nibble = last_byte & 0x0F;
2679
2680    if digit_count < digits {
2681        if high_nibble > 9 {
2682            return Err(Error::new(
2683                ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2684                format!("Invalid digit nibble 0x{high_nibble:X}"),
2685            ));
2686        }
2687        packed_push_digit(&mut value, high_nibble)?;
2688    }
2689
2690    let is_negative = if signed {
2691        match low_nibble {
2692            0xA | 0xC | 0xE | 0xF => false,
2693            0xB | 0xD => true,
2694            _ => {
2695                return Err(Error::new(
2696                    ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2697                    format!("Invalid sign nibble 0x{low_nibble:X}"),
2698                ));
2699            }
2700        }
2701    } else {
2702        if low_nibble != 0xF {
2703            return Err(Error::new(
2704                ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2705                format!("Invalid unsigned sign nibble 0x{low_nibble:X}, expected 0xF"),
2706            ));
2707        }
2708        false
2709    };
2710
2711    Ok(create_normalized_decimal(value, scale, is_negative))
2712}
2713
2714/// Decode a multi-byte packed decimal value
2715///
2716/// Orchestrates the decoding of packed decimals that span multiple bytes by
2717/// processing non-final bytes and the final byte separately.
2718///
2719/// # Arguments
2720/// * `data` - Complete packed decimal byte array
2721/// * `digits` - Number of digits in the field
2722/// * `scale` - Decimal scale
2723/// * `signed` - Whether the field is signed
2724///
2725/// # Returns
2726/// Decoded `SmallDecimal` value
2727///
2728/// # Errors
2729/// * `CBKD401_COMP3_INVALID_NIBBLE` - Invalid nibbles or empty input
2730///
2731/// # Algorithm
2732/// 1. Calculate if padding nibble is present (odd total nibbles)
2733/// 2. Process all non-final bytes to extract digits
2734/// 3. Process final byte to extract last digit and sign
2735/// 4. Construct normalized `SmallDecimal`
2736#[inline]
2737fn packed_decode_multi_byte(
2738    data: &[u8],
2739    digits: u16,
2740    scale: i16,
2741    signed: bool,
2742) -> Result<SmallDecimal> {
2743    let total_nibbles = digits + 1;
2744    let has_padding = (total_nibbles & 1) == 1;
2745    let Some((&last_byte, non_last)) = data.split_last() else {
2746        return Err(Error::new(
2747            ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2748            "Packed decimal input is empty",
2749        ));
2750    };
2751    let (value, digit_count) = packed_process_non_last_bytes(non_last, digits, has_padding)?;
2752    packed_finish_last_byte(value, last_byte, digits, digit_count, scale, signed)
2753}
2754
2755/// Optimized packed decimal decoder using scratch buffers
2756/// Minimizes allocations by reusing digit buffer
2757///
2758/// # Errors
2759/// Returns an error when the packed decimal data has an invalid length or contains bad digit/sign nibbles.
2760#[inline]
2761#[must_use = "Handle the Result or propagate the error"]
2762pub fn decode_packed_decimal_with_scratch(
2763    data: &[u8],
2764    digits: u16,
2765    scale: i16,
2766    signed: bool,
2767    scratch: &mut ScratchBuffers,
2768) -> Result<SmallDecimal> {
2769    let expected_bytes = usize::from((digits + 1).div_ceil(2));
2770    if data.len() != expected_bytes {
2771        return Err(Error::new(
2772            ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2773            format!(
2774                "Packed decimal data length {} doesn't match expected {} bytes for {} digits",
2775                data.len(),
2776                expected_bytes,
2777                digits
2778            ),
2779        ));
2780    }
2781
2782    if data.is_empty() {
2783        return Ok(SmallDecimal::zero(scale));
2784    }
2785
2786    // Use the original implementation - the "optimized" path actually hurts performance
2787    // Clear and prepare digit buffer for reuse
2788    scratch.digit_buffer.clear();
2789    scratch.digit_buffer.reserve(usize::from(digits));
2790
2791    // Optimized nibble processing - unify handling for multi-byte cases
2792    let decimal = if data.len() == 1 {
2793        packed_decode_single_byte(data[0], digits, scale, signed)?
2794    } else {
2795        packed_decode_multi_byte(data, digits, scale, signed)?
2796    };
2797
2798    debug_assert!(
2799        scratch.digit_buffer.iter().all(|&d| d <= 9),
2800        "scratch digit buffer must contain only logical digits"
2801    );
2802
2803    Ok(decimal)
2804}
2805
2806/// Encode a zoned decimal while reusing caller-owned scratch buffers to avoid
2807/// per-call heap allocations on the hot path.
2808///
2809/// Converts the pre-parsed [`SmallDecimal`] to its string representation using
2810/// the scratch buffer and then delegates to [`encode_zoned_decimal`].  The
2811/// `_bwz_encode` parameter is reserved for future BLANK WHEN ZERO integration
2812/// but is currently unused.
2813///
2814/// # Arguments
2815/// * `decimal` - Pre-parsed decimal value to encode
2816/// * `digits` - Number of digit positions in the COBOL field
2817/// * `signed` - Whether the field carries a sign
2818/// * `codepage` - Target character encoding (ASCII or EBCDIC variant)
2819/// * `_bwz_encode` - Reserved for BLANK WHEN ZERO support (currently unused)
2820/// * `scratch` - Reusable scratch buffers for zero-allocation string processing
2821///
2822/// # Returns
2823/// A vector of bytes containing the encoded zoned decimal.
2824///
2825/// # Policy
2826/// Callers typically resolve policy using `zoned_encoding_override` → preserved
2827/// metadata → `preferred_zoned_encoding`, matching the documented library
2828/// behavior for zoned decimals.
2829///
2830/// # Errors
2831/// Returns an error when the decimal value cannot be represented with the
2832/// requested digit count or encoding format.
2833///
2834/// # See Also
2835/// * [`encode_zoned_decimal`] - Underlying encoder
2836/// * [`encode_packed_decimal_with_scratch`] - Scratch-based packed decimal encoder
2837#[inline]
2838#[must_use = "Handle the Result or propagate the error"]
2839pub fn encode_zoned_decimal_with_scratch(
2840    decimal: &SmallDecimal,
2841    digits: u16,
2842    signed: bool,
2843    codepage: Codepage,
2844    _bwz_encode: bool,
2845    scratch: &mut ScratchBuffers,
2846) -> Result<Vec<u8>> {
2847    // Clear and prepare buffers
2848    scratch.digit_buffer.clear();
2849    scratch.byte_buffer.clear();
2850    scratch.byte_buffer.reserve(usize::from(digits));
2851
2852    // Convert decimal to string using scratch buffer
2853    scratch.string_buffer.clear();
2854    scratch.string_buffer.push_str(&decimal.to_string());
2855
2856    // Use the standard encode function but with optimized digit processing
2857    // This is a placeholder for now - the actual optimization would involve
2858    // rewriting the encode logic to use the scratch buffers
2859    encode_zoned_decimal(
2860        &scratch.string_buffer,
2861        digits,
2862        decimal.scale,
2863        signed,
2864        codepage,
2865    )
2866}
2867
2868/// Encode a packed decimal (COMP-3) while reusing caller-owned scratch buffers
2869/// to minimize per-call allocations.
2870///
2871/// Converts the pre-parsed [`SmallDecimal`] to its string representation using
2872/// the scratch buffer and then delegates to [`encode_packed_decimal`].  Intended
2873/// for use on codec hot paths where many records are encoded sequentially with
2874/// the same [`ScratchBuffers`] instance.
2875///
2876/// # Arguments
2877/// * `decimal` - Pre-parsed decimal value to encode
2878/// * `digits` - Number of decimal digits in the field (1-18)
2879/// * `signed` - Whether the field is signed (`true`) or unsigned (`false`)
2880/// * `scratch` - Reusable scratch buffers for zero-allocation string processing
2881///
2882/// # Returns
2883/// A vector of bytes containing the encoded packed decimal (COMP-3 format).
2884///
2885/// # Errors
2886/// Returns an error when the decimal value cannot be encoded into the
2887/// requested packed representation (delegates to [`encode_packed_decimal`]).
2888///
2889/// # See Also
2890/// * [`encode_packed_decimal`] - Underlying packed decimal encoder
2891/// * [`encode_zoned_decimal_with_scratch`] - Scratch-based zoned decimal encoder
2892/// * [`decode_packed_decimal_to_string_with_scratch`] - Scratch-based decoder
2893#[inline]
2894#[must_use = "Handle the Result or propagate the error"]
2895pub fn encode_packed_decimal_with_scratch(
2896    decimal: &SmallDecimal,
2897    digits: u16,
2898    signed: bool,
2899    scratch: &mut ScratchBuffers,
2900) -> Result<Vec<u8>> {
2901    // Clear and prepare buffers
2902    scratch.digit_buffer.clear();
2903    scratch.byte_buffer.clear();
2904    let expected_bytes = usize::from((digits + 1).div_ceil(2));
2905    scratch.byte_buffer.reserve(expected_bytes);
2906
2907    // Convert decimal to string using scratch buffer
2908    scratch.string_buffer.clear();
2909    scratch.string_buffer.push_str(&decimal.to_string());
2910
2911    // Use the standard encode function but with optimized nibble processing
2912    // This is a placeholder for now - the actual optimization would involve
2913    // rewriting the encode logic to use the scratch buffers
2914    encode_packed_decimal(&scratch.string_buffer, digits, decimal.scale, signed)
2915}
2916
2917/// Decode a packed decimal (COMP-3) directly to a `String`, bypassing the
2918/// intermediate [`SmallDecimal`] allocation.
2919///
2920/// This is a critical performance optimization for COMP-3 JSON conversion.
2921/// By decoding nibbles and formatting the result in a single pass using the
2922/// caller-owned scratch buffer, it avoids the `SmallDecimal` -> `String`
2923/// allocation overhead that caused 94-96% throughput regression in COMP-3
2924/// processing benchmarks.
2925///
2926/// # Arguments
2927/// * `data` - Raw byte data containing the packed decimal (BCD with trailing sign nibble)
2928/// * `digits` - Number of decimal digits in the field (1-18)
2929/// * `scale` - Number of implied decimal places (can be negative for scaling)
2930/// * `signed` - Whether the field is signed (`true`) or unsigned (`false`)
2931/// * `scratch` - Reusable scratch buffers; the `string_buffer` is consumed via
2932///   `std::mem::take` and returned as the result string.
2933///
2934/// # Returns
2935/// The decoded value formatted as a string (e.g. `"123"`, `"-45.67"`, `"0"`).
2936///
2937/// # Errors
2938/// * `CBKD401_COMP3_INVALID_NIBBLE` - if any data nibble is > 9 or the sign
2939///   nibble is invalid
2940///
2941/// # Performance
2942/// Includes a fast path for single-digit packed decimals (1 byte) and falls
2943/// back to [`decode_packed_decimal_with_scratch`] plus
2944/// [`SmallDecimal::format_to_scratch_buffer`] for larger values.
2945///
2946/// # See Also
2947/// * [`decode_packed_decimal`] - Returns a `SmallDecimal` instead of a string
2948/// * [`decode_packed_decimal_with_scratch`] - Scratch-based decoder returning `SmallDecimal`
2949/// * [`encode_packed_decimal_with_scratch`] - Scratch-based packed decimal encoder
2950#[inline]
2951#[must_use = "Handle the Result or propagate the error"]
2952pub fn decode_packed_decimal_to_string_with_scratch(
2953    data: &[u8],
2954    digits: u16,
2955    scale: i16,
2956    signed: bool,
2957    scratch: &mut ScratchBuffers,
2958) -> Result<String> {
2959    // SIMD-friendly sign lookup table for faster branch-free sign detection
2960    // Index by nibble value: 0=invalid, 1=positive, 2=negative
2961    const SIGN_TABLE: [u8; 16] = [
2962        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0x0-0x9: invalid
2963        1, 2, 1, 2, 1, 1, // 0xA=pos, 0xB=neg, 0xC=pos, 0xD=neg, 0xE=pos, 0xF=pos
2964    ];
2965
2966    // CRITICAL OPTIMIZATION: Direct decode-to-string path to avoid SmallDecimal allocation
2967    if data.is_empty() {
2968        return Ok("0".to_string());
2969    }
2970
2971    // Fast path for common single-digit packed decimals
2972    if data.len() == 1 && digits == 1 {
2973        let byte = data[0];
2974        let high_nibble = (byte >> 4) & 0x0F;
2975        let low_nibble = byte & 0x0F;
2976
2977        let mut is_negative = false;
2978
2979        // Single digit: high nibble is unused (should be 0), low nibble is sign
2980        if high_nibble > 9 {
2981            return Err(Error::new(
2982                ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2983                format!("Invalid digit nibble 0x{high_nibble:X}"),
2984            ));
2985        }
2986        let value = i64::from(high_nibble);
2987
2988        if signed {
2989            // SIMD-friendly branch-free sign detection
2990            let sign_code = SIGN_TABLE[usize::from(low_nibble)];
2991            if sign_code == 0 {
2992                return Err(Error::new(
2993                    ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2994                    format!("Invalid sign nibble 0x{low_nibble:X}"),
2995                ));
2996            }
2997            is_negative = sign_code == 2;
2998        } else if low_nibble != 0xF {
2999            return Err(Error::new(
3000                ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
3001                format!("Invalid unsigned sign nibble 0x{low_nibble:X}, expected 0xF"),
3002            ));
3003        }
3004
3005        // Format directly to string without SmallDecimal
3006        scratch.string_buffer.clear();
3007        if is_negative && value != 0 {
3008            scratch.string_buffer.push('-');
3009        }
3010
3011        if scale <= 0 {
3012            // Integer format
3013            let scaled_value = if scale < 0 {
3014                value * 10_i64.pow(scale_abs_to_u32(scale))
3015            } else {
3016                value
3017            };
3018            format_integer_to_buffer(scaled_value, &mut scratch.string_buffer);
3019        } else {
3020            // Decimal format
3021            let divisor = 10_i64.pow(scale_abs_to_u32(scale));
3022            let integer_part = value / divisor;
3023            let fractional_part = value % divisor;
3024
3025            format_integer_to_buffer(integer_part, &mut scratch.string_buffer);
3026            scratch.string_buffer.push('.');
3027            format_integer_with_leading_zeros_to_buffer(
3028                fractional_part,
3029                scale_abs_to_u32(scale),
3030                &mut scratch.string_buffer,
3031            );
3032        }
3033
3034        // CRITICAL OPTIMIZATION: Move string content without cloning
3035        let result = std::mem::take(&mut scratch.string_buffer);
3036        return Ok(result);
3037    }
3038
3039    // Fall back to general case for larger packed decimals
3040    let decimal = decode_packed_decimal_with_scratch(data, digits, scale, signed, scratch)?;
3041
3042    // Now format to string using the optimized scratch buffer method
3043    decimal.format_to_scratch_buffer(scale, &mut scratch.string_buffer);
3044
3045    // CRITICAL OPTIMIZATION: Move string content without cloning
3046    let result = std::mem::take(&mut scratch.string_buffer);
3047    Ok(result)
3048}
3049
3050/// Format a binary integer into the caller-owned scratch buffer.
3051///
3052/// ## Why scratch?
3053/// Avoids hot-path allocations in codec routes that emit integers frequently
3054/// (zoned/packed/binary). This writes into `scratch` and returns that buffer,
3055/// so callers must reuse the same `ScratchBuffers` instance across a walk.
3056///
3057/// ## Contract
3058/// - No allocations on the hot path
3059/// - Returns the scratch-backed `String` (valid until next reuse/clear)
3060#[inline]
3061#[must_use = "Use the formatted string or continue mutating the scratch buffer"]
3062pub fn format_binary_int_to_string_with_scratch(
3063    value: i64,
3064    scratch: &mut ScratchBuffers,
3065) -> String {
3066    scratch.string_buffer.clear();
3067
3068    if value < 0 {
3069        scratch.string_buffer.push('-');
3070        if value == i64::MIN {
3071            // Avoid overflow when negating i64::MIN
3072            scratch.string_buffer.push_str("9223372036854775808");
3073            return std::mem::take(&mut scratch.string_buffer);
3074        }
3075        format_integer_to_buffer(-value, &mut scratch.string_buffer);
3076    } else {
3077        format_integer_to_buffer(value, &mut scratch.string_buffer);
3078    }
3079
3080    std::mem::take(&mut scratch.string_buffer)
3081}
3082
3083/// Format an integer to a string buffer with optimized performance
3084///
3085/// Provides ultra-fast integer-to-string conversion optimized for COBOL numeric
3086/// decoding hot paths. Uses manual digit extraction to avoid format macro overhead.
3087///
3088/// # Arguments
3089/// * `value` - Integer value to format
3090/// * `buffer` - String buffer to append digits to
3091///
3092/// # Performance
3093/// Critical optimization for COMP-3 and zoned decimal JSON conversion. Avoids
3094/// the overhead of Rust's standard formatting macros through manual digit extraction.
3095///
3096/// # Examples
3097/// ```text
3098/// let mut buffer = String::new();
3099/// format_integer_to_buffer(12345, &mut buffer);
3100/// assert_eq!(buffer, "12345");
3101/// ```
3102#[inline]
3103fn format_integer_to_buffer(value: i64, buffer: &mut String) {
3104    SmallDecimal::format_integer_manual(value, buffer);
3105}
3106
3107/// Format an integer with leading zeros to a string buffer
3108///
3109/// Formats an integer with exactly `width` digits, padding with leading zeros
3110/// if necessary. Optimized for decimal formatting where fractional parts must
3111/// maintain precise digit counts.
3112///
3113/// # Arguments
3114/// * `value` - Integer value to format
3115/// * `width` - Number of digits in output (with leading zeros)
3116/// * `buffer` - String buffer to append formatted digits to
3117///
3118/// # Performance
3119/// Optimized for common COBOL scales (0-4 decimal places) with specialized
3120/// fast paths. Critical for maintaining COMP-3 decimal precision.
3121///
3122/// # Examples
3123/// ```text
3124/// let mut buffer = String::new();
3125/// format_integer_with_leading_zeros_to_buffer(45, 4, &mut buffer);
3126/// assert_eq!(buffer, "0045");
3127/// ```
3128#[inline]
3129fn format_integer_with_leading_zeros_to_buffer(value: i64, width: u32, buffer: &mut String) {
3130    SmallDecimal::format_integer_with_leading_zeros(value, width, buffer);
3131}
3132
3133/// Decode a zoned decimal directly to a `String`, bypassing the intermediate
3134/// [`SmallDecimal`] allocation.
3135///
3136/// Analogous to [`decode_packed_decimal_to_string_with_scratch`] but for zoned
3137/// decimal (PIC 9 / PIC S9) fields.  Decodes via
3138/// [`decode_zoned_decimal_with_scratch`] and then formats the result into the
3139/// scratch string buffer, avoiding a separate heap allocation.
3140///
3141/// # Arguments
3142/// * `data` - Raw byte data containing the zoned decimal
3143/// * `digits` - Number of digit characters (field length)
3144/// * `scale` - Number of implied decimal places (can be negative for scaling)
3145/// * `signed` - Whether the field carries a sign (overpunch in last byte)
3146/// * `codepage` - Character encoding (ASCII or EBCDIC variant)
3147/// * `blank_when_zero` - If `true`, all-space fields decode as `"0"`
3148/// * `scratch` - Reusable scratch buffers; the `string_buffer` is consumed via
3149///   `std::mem::take` and returned as the result string.
3150///
3151/// # Returns
3152/// The decoded value formatted as a string (e.g. `"123"`, `"-45.67"`, `"0"`).
3153///
3154/// # Policy
3155/// Mirrors [`decode_zoned_decimal_with_scratch`], inheriting its default
3156/// preferred-zero handling for EBCDIC data.
3157///
3158/// # Errors
3159/// * `CBKD410_ZONED_OVERFLOW` - if the decoded magnitude exceeds `i64` capacity
3160/// * `CBKD411_ZONED_BAD_SIGN` - if the zone nibbles or sign are invalid
3161///
3162/// # See Also
3163/// * [`decode_zoned_decimal`] - Returns a `SmallDecimal` instead of a string
3164/// * [`decode_zoned_decimal_with_scratch`] - Scratch-based decoder returning `SmallDecimal`
3165/// * [`decode_packed_decimal_to_string_with_scratch`] - Equivalent for packed decimals
3166#[inline]
3167#[must_use = "Handle the Result or propagate the error"]
3168pub fn decode_zoned_decimal_to_string_with_scratch(
3169    data: &[u8],
3170    digits: u16,
3171    scale: i16,
3172    signed: bool,
3173    codepage: Codepage,
3174    blank_when_zero: bool,
3175    scratch: &mut ScratchBuffers,
3176) -> Result<String> {
3177    // First decode to SmallDecimal using existing optimized decoder
3178    let decimal = decode_zoned_decimal_with_scratch(
3179        data,
3180        digits,
3181        scale,
3182        signed,
3183        codepage,
3184        blank_when_zero,
3185        scratch,
3186    )?;
3187
3188    // Special-case integer zoned decimals for digit padding consistency
3189    if scale == 0 && !blank_when_zero {
3190        if decimal.value == 0 {
3191            scratch.string_buffer.clear();
3192            scratch.string_buffer.push('0');
3193        } else {
3194            scratch.string_buffer.clear();
3195            if decimal.negative && decimal.value != 0 {
3196                scratch.string_buffer.push('-');
3197            }
3198
3199            let magnitude = if decimal.scale < 0 {
3200                decimal.value * 10_i64.pow(scale_abs_to_u32(decimal.scale))
3201            } else {
3202                decimal.value
3203            };
3204
3205            SmallDecimal::format_integer_with_leading_zeros(
3206                magnitude,
3207                u32::from(digits),
3208                &mut scratch.string_buffer,
3209            );
3210        }
3211
3212        return Ok(std::mem::take(&mut scratch.string_buffer));
3213    }
3214
3215    // Fallback to general fixed-scale formatting using scratch buffer
3216    decimal.format_to_scratch_buffer(scale, &mut scratch.string_buffer);
3217    Ok(std::mem::take(&mut scratch.string_buffer))
3218}
3219
3220// =============================================================================
3221// Floating-Point Codecs (COMP-1 / COMP-2)
3222// =============================================================================
3223
3224#[cfg(test)]
3225#[allow(clippy::expect_used)]
3226#[allow(clippy::unwrap_used)]
3227#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
3228mod tests {
3229    use super::*;
3230    use crate::zoned_overpunch::{ZeroSignPolicy, encode_overpunch_byte, is_valid_overpunch};
3231    use proptest::prelude::*;
3232    use proptest::test_runner::RngSeed;
3233    use std::collections::hash_map::DefaultHasher;
3234    use std::hash::{Hash, Hasher};
3235
3236    fn proptest_case_count() -> u32 {
3237        option_env!("PROPTEST_CASES")
3238            .and_then(|s| s.parse().ok())
3239            .unwrap_or(256)
3240    }
3241
3242    fn numeric_proptest_config() -> ProptestConfig {
3243        let mut cfg = ProptestConfig {
3244            cases: proptest_case_count(),
3245            max_shrink_time: 0,
3246            ..ProptestConfig::default()
3247        };
3248
3249        if let Ok(seed_value) = std::env::var("PROPTEST_SEED")
3250            && !seed_value.is_empty()
3251        {
3252            let parsed_seed = seed_value.parse::<u64>().unwrap_or_else(|_| {
3253                let mut hasher = DefaultHasher::new();
3254                seed_value.hash(&mut hasher);
3255                hasher.finish()
3256            });
3257            cfg.rng_seed = RngSeed::Fixed(parsed_seed);
3258        }
3259
3260        cfg
3261    }
3262
3263    #[test]
3264    fn test_small_decimal_normalization() {
3265        let mut decimal = SmallDecimal::new(0, 2, true);
3266        decimal.normalize();
3267        assert!(!decimal.negative); // -0 should become 0
3268    }
3269
3270    #[test]
3271    fn test_small_decimal_formatting() {
3272        // Integer format (scale=0)
3273        let decimal = SmallDecimal::new(123, 0, false);
3274        assert_eq!(decimal.to_string(), "123");
3275
3276        // Decimal format with fixed scale
3277        let decimal = SmallDecimal::new(12345, 2, false);
3278        assert_eq!(decimal.to_string(), "123.45");
3279
3280        // Negative decimal
3281        let decimal = SmallDecimal::new(12345, 2, true);
3282        assert_eq!(decimal.to_string(), "-123.45");
3283    }
3284
3285    #[test]
3286    fn test_zero_with_scale_preserves_decimal_places() {
3287        // Zero with scale=2 must produce "0.00" (not "0")
3288        let decimal = SmallDecimal::new(0, 2, false);
3289        assert_eq!(decimal.to_string(), "0.00");
3290
3291        // Zero with scale=1
3292        let decimal = SmallDecimal::new(0, 1, false);
3293        assert_eq!(decimal.to_string(), "0.0");
3294
3295        // Zero with scale=4 and negative flag (normalizes sign away)
3296        let decimal = SmallDecimal::new(0, 4, true);
3297        assert_eq!(decimal.to_string(), "0.0000");
3298    }
3299
3300    proptest! {
3301        #![proptest_config(numeric_proptest_config())]
3302        #[test]
3303        fn prop_zoned_digit_buffer_contains_only_digits(
3304            digits_vec in prop::collection::vec(0u8..=9, 1..=12),
3305            signed in any::<bool>(),
3306            allow_negative in any::<bool>(),
3307            codepage in prop_oneof![
3308                Just(Codepage::ASCII),
3309                Just(Codepage::CP037),
3310                Just(Codepage::CP273),
3311                Just(Codepage::CP500),
3312                Just(Codepage::CP1047),
3313                Just(Codepage::CP1140),
3314            ],
3315            policy in prop_oneof![Just(ZeroSignPolicy::Positive), Just(ZeroSignPolicy::Preferred)],
3316        ) {
3317            let digit_count = u16::try_from(digits_vec.len()).expect("vector length <= 12");
3318            let mut bytes = Vec::with_capacity(digits_vec.len());
3319
3320            for digit in digits_vec.iter().take(digits_vec.len().saturating_sub(1)) {
3321                let byte = if codepage.is_ascii() {
3322                    0x30 + digit
3323                } else {
3324                    0xF0 + digit
3325                };
3326                bytes.push(byte);
3327            }
3328
3329            let is_negative = signed && allow_negative;
3330            let last_digit = *digits_vec.last().expect("vector is non-empty");
3331            let last_byte = if signed {
3332                let encoded = encode_overpunch_byte(last_digit, is_negative, codepage, policy)
3333                    .expect("valid overpunch for digit 0-9");
3334                prop_assume!(is_valid_overpunch(encoded, codepage));
3335                encoded
3336            } else if codepage.is_ascii() {
3337                0x30 + last_digit
3338            } else {
3339                0xF0 + last_digit
3340            };
3341            bytes.push(last_byte);
3342
3343            let mut scratch = ScratchBuffers::new();
3344            let _ = decode_zoned_decimal_with_scratch(
3345                &bytes,
3346                digit_count,
3347                0,
3348                signed,
3349                codepage,
3350                false,
3351                &mut scratch,
3352            ).expect("decoding constructed zoned bytes should succeed");
3353
3354            prop_assert_eq!(scratch.digit_buffer.len(), digits_vec.len());
3355            prop_assert!(scratch.digit_buffer.iter().all(|&d| d <= 9));
3356            prop_assert_eq!(&scratch.digit_buffer[..], &digits_vec[..]);
3357        }
3358    }
3359
3360    #[test]
3361    fn test_zoned_decimal_blank_when_zero() {
3362        // EBCDIC spaces (0x40)
3363        let data = vec![0x40, 0x40, 0x40];
3364        let result = decode_zoned_decimal(&data, 3, 0, false, Codepage::CP037, true).unwrap();
3365        assert_eq!(result.to_string(), "0");
3366
3367        // ASCII spaces
3368        let data = vec![b' ', b' ', b' '];
3369        let result = decode_zoned_decimal(&data, 3, 0, false, Codepage::ASCII, true).unwrap();
3370        assert_eq!(result.to_string(), "0");
3371    }
3372
3373    #[test]
3374    fn test_packed_decimal_signs() {
3375        // Positive packed decimal: 123C (123 positive)
3376        let data = vec![0x12, 0x3C];
3377        let result = decode_packed_decimal(&data, 3, 0, true).unwrap();
3378        assert_eq!(result.to_string(), "123");
3379
3380        // Negative packed decimal: 123D (123 negative)
3381        let data = vec![0x12, 0x3D];
3382        let result = decode_packed_decimal(&data, 3, 0, true).unwrap();
3383        assert_eq!(result.to_string(), "-123");
3384
3385        // Test the failing case from property tests: -11 (2 digits)
3386        // Test that round-trip encoding/decoding preserves the sign
3387        let encoded = encode_packed_decimal("-11", 2, 0, true).unwrap();
3388        let result = decode_packed_decimal(&encoded, 2, 0, true).unwrap();
3389        assert_eq!(
3390            result.to_string(),
3391            "-11",
3392            "Failed to round-trip -11 correctly"
3393        );
3394
3395        // Test that the old buggy format is now rejected
3396        let data = vec![0x11, 0xDD]; // Invalid format with sign in both nibbles
3397        let result = decode_packed_decimal(&data, 2, 0, true);
3398        assert!(
3399            result.is_err(),
3400            "Should reject invalid format with sign in both nibbles"
3401        );
3402    }
3403
3404    #[test]
3405    fn test_binary_int_big_endian() {
3406        // 16-bit big-endian: 0x0123 = 291
3407        let data = vec![0x01, 0x23];
3408        let result = decode_binary_int(&data, 16, false).unwrap();
3409        assert_eq!(result, 291);
3410
3411        // 32-bit big-endian: 0x01234567 = 19088743
3412        let data = vec![0x01, 0x23, 0x45, 0x67];
3413        let result = decode_binary_int(&data, 32, false).unwrap();
3414        assert_eq!(result, 19_088_743);
3415    }
3416
3417    #[test]
3418    fn test_alphanumeric_encoding() {
3419        // ASCII encoding with padding
3420        let result = encode_alphanumeric("HELLO", 10, Codepage::ASCII).unwrap();
3421        assert_eq!(result, b"HELLO     ");
3422
3423        // Over-length should error
3424        let result = encode_alphanumeric("HELLO WORLD", 5, Codepage::ASCII);
3425        assert!(result.is_err());
3426    }
3427
3428    #[test]
3429    fn test_bwz_policy() {
3430        // Zero values should trigger BWZ
3431        assert!(should_encode_as_blank_when_zero("0", true));
3432        assert!(should_encode_as_blank_when_zero("0.00", true));
3433        assert!(should_encode_as_blank_when_zero("0.000", true));
3434
3435        // Non-zero values should not trigger BWZ
3436        assert!(!should_encode_as_blank_when_zero("1", true));
3437        assert!(!should_encode_as_blank_when_zero("0.01", true));
3438
3439        // BWZ disabled should never trigger
3440        assert!(!should_encode_as_blank_when_zero("0", false));
3441    }
3442
3443    #[test]
3444    fn test_binary_width_mapping() {
3445        // Test digit-to-width mapping (NORMATIVE)
3446        assert_eq!(get_binary_width_from_digits(1), 16); // ≤4 → 2B
3447        assert_eq!(get_binary_width_from_digits(4), 16); // ≤4 → 2B
3448        assert_eq!(get_binary_width_from_digits(5), 32); // 5-9 → 4B
3449        assert_eq!(get_binary_width_from_digits(9), 32); // 5-9 → 4B
3450        assert_eq!(get_binary_width_from_digits(10), 64); // 10-18 → 8B
3451        assert_eq!(get_binary_width_from_digits(18), 64); // 10-18 → 8B
3452    }
3453
3454    #[test]
3455    fn test_explicit_binary_width_validation() {
3456        // Valid explicit widths
3457        assert_eq!(validate_explicit_binary_width(1).unwrap(), 8);
3458        assert_eq!(validate_explicit_binary_width(2).unwrap(), 16);
3459        assert_eq!(validate_explicit_binary_width(4).unwrap(), 32);
3460        assert_eq!(validate_explicit_binary_width(8).unwrap(), 64);
3461
3462        // Invalid explicit widths
3463        assert!(validate_explicit_binary_width(3).is_err());
3464        assert!(validate_explicit_binary_width(16).is_err());
3465    }
3466
3467    #[test]
3468    fn test_zoned_decimal_with_bwz() {
3469        // BWZ enabled with zero value should return spaces
3470        let result =
3471            encode_zoned_decimal_with_bwz("0", 3, 0, false, Codepage::ASCII, true).unwrap();
3472        assert_eq!(result, vec![b' ', b' ', b' ']);
3473
3474        // BWZ disabled with zero value should return normal encoding
3475        let result =
3476            encode_zoned_decimal_with_bwz("0", 3, 0, false, Codepage::ASCII, false).unwrap();
3477        assert_eq!(result, vec![0x30, 0x30, 0x30]); // ASCII "000"
3478
3479        // Non-zero value should return normal encoding regardless of BWZ
3480        let result =
3481            encode_zoned_decimal_with_bwz("123", 3, 0, false, Codepage::ASCII, true).unwrap();
3482        assert_eq!(result, vec![0x31, 0x32, 0x33]); // ASCII "123"
3483    }
3484
3485    #[test]
3486    fn test_error_handling_invalid_numeric_inputs() {
3487        // Test packed decimal with invalid input - should return specific CBKD error
3488        let invalid_data = vec![0xFF]; // Invalid packed decimal
3489        let result = decode_packed_decimal(&invalid_data, 2, 0, false);
3490        assert!(
3491            result.is_err(),
3492            "Invalid packed decimal should return error"
3493        );
3494
3495        let error = result.unwrap_err();
3496        assert!(
3497            error.to_string().contains("CBKD"),
3498            "Error should be CBKD code"
3499        );
3500
3501        // Test binary int with insufficient data
3502        let short_data = vec![0x01]; // Only 1 byte for 4-byte int
3503        let result = decode_binary_int(&short_data, 32, false);
3504        assert!(
3505            result.is_err(),
3506            "Insufficient binary data should return error"
3507        );
3508
3509        // Test zoned decimal with invalid characters
3510        let invalid_zoned = b"12X"; // Contains non-digit
3511        let result = decode_zoned_decimal(invalid_zoned, 3, 0, false, Codepage::ASCII, false);
3512        assert!(result.is_err(), "Invalid zoned decimal should return error");
3513
3514        // Test alphanumeric encoding with oversized input
3515        let result = encode_alphanumeric("TOOLONGFORFIELD", 5, Codepage::ASCII);
3516        assert!(
3517            result.is_err(),
3518            "Oversized alphanumeric should return error"
3519        );
3520
3521        let error = result.unwrap_err();
3522        assert!(
3523            error.to_string().contains("CBKE"),
3524            "Error should be CBKE code"
3525        );
3526    }
3527
3528    #[test]
3529    fn test_boundary_conditions_numeric_operations() {
3530        // Test maximum values for different data types
3531
3532        // Test maximum packed decimal
3533        let max_packed_bytes = vec![0x99, 0x9C]; // 999 positive (3 digits)
3534        let result = decode_packed_decimal(&max_packed_bytes, 3, 0, true);
3535        assert!(
3536            result.is_ok(),
3537            "Valid maximum packed decimal should succeed"
3538        );
3539
3540        // Test zero packed decimal
3541        let zero_packed = vec![0x00, 0x0C]; // 00 positive
3542        let result = decode_packed_decimal(&zero_packed, 2, 0, true);
3543        assert!(result.is_ok(), "Zero packed decimal should succeed");
3544
3545        // Test edge case with maximum binary values
3546        let max_u16_bytes = vec![0xFF, 0xFF];
3547        let result = decode_binary_int(&max_u16_bytes, 16, false);
3548        assert!(result.is_ok(), "Maximum unsigned 16-bit should succeed");
3549
3550        let max_signed_16_bytes = vec![0x7F, 0xFF];
3551        let result = decode_binary_int(&max_signed_16_bytes, 16, true);
3552        assert!(result.is_ok(), "Maximum signed 16-bit should succeed");
3553
3554        // Test edge case with minimum signed values
3555        let min_i16_bytes = vec![0x80, 0x00];
3556        let result = decode_binary_int(&min_i16_bytes, 16, true);
3557        assert!(result.is_ok(), "Minimum signed 16-bit should succeed");
3558    }
3559
3560    #[test]
3561    fn test_comp3_decimal_scale_fix() {
3562        // Test case for PIC S9(7)V99 COMP-3 with decimal positioning fix
3563        let input_value = "123.45";
3564        let digits = 9; // 7 integer + 2 decimal = 9 total digits
3565        let scale = 2; // 2 decimal places
3566        let signed = true;
3567
3568        // Test round-trip encoding/decoding
3569        let encoded_data = encode_packed_decimal(input_value, digits, scale, signed).unwrap();
3570        let decoded = decode_packed_decimal(&encoded_data, digits, scale, signed).unwrap();
3571
3572        assert_eq!(decoded.to_string(), "123.45", "COMP-3 round-trip failed");
3573
3574        // Test negative case
3575        let negative_value = "-999.99";
3576        let encoded_neg = encode_packed_decimal(negative_value, digits, scale, signed).unwrap();
3577        let decoded_neg = decode_packed_decimal(&encoded_neg, digits, scale, signed).unwrap();
3578
3579        assert_eq!(
3580            decoded_neg.to_string(),
3581            "-999.99",
3582            "Negative COMP-3 round-trip failed"
3583        );
3584    }
3585
3586    #[test]
3587    fn test_error_path_coverage_arithmetic_operations() {
3588        // Test SmallDecimal creation and basic operations
3589        let decimal = SmallDecimal::new(i64::MAX, 0, false);
3590        assert_eq!(decimal.value, i64::MAX);
3591        assert_eq!(decimal.scale, 0);
3592        assert!(!decimal.negative);
3593
3594        // Test boundary conditions for large values
3595        let large_decimal = SmallDecimal::new(999_999_999, 0, false);
3596        assert_eq!(large_decimal.value, 999_999_999);
3597
3598        // Test boundary conditions for scale normalization
3599        let mut small_decimal = SmallDecimal::new(1, 10, false);
3600        small_decimal.normalize(); // Should handle high scale
3601        assert!(small_decimal.scale >= 0);
3602
3603        // Test signed/unsigned conversions with boundary values
3604        let negative_decimal = SmallDecimal::new(-1, 0, true);
3605        assert!(
3606            negative_decimal.is_negative(),
3607            "Signed negative should be negative"
3608        );
3609
3610        let positive_decimal = SmallDecimal::new(1, 0, false);
3611        assert!(
3612            !positive_decimal.is_negative(),
3613            "Unsigned should not be negative"
3614        );
3615    }
3616}