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//! ## Performance Considerations
42//!
43//! This module is optimized for high-throughput enterprise data processing:
44//!
45//! - **Hot path optimization**: Common cases (1-5 byte COMP-3, ASCII zoned) use
46//!   specialized fast paths
47//! - **Branch prediction**: Manual hints mark error paths as unlikely
48//! - **Zero-allocation**: Scratch buffer variants avoid repeated allocations in loops
49//! - **Saturating arithmetic**: Prevents panics while maintaining correctness
50//!
51//! ## Encoding Formats
52//!
53//! ### Zoned Decimal Encoding
54//!
55//! Zoned decimals support two primary encoding formats:
56//!
57//! | Format | Zone Nibble | Example Digits | Sign Encoding |
58//! |---------|--------------|----------------|---------------|
59//! | ASCII | `0x3` | `0x30`-`0x39` | Overpunch or separate |
60//! | EBCDIC | `0xF` | `0xF0`-`0xF9` | Overpunch or separate |
61//!
62//! ### Packed Decimal Sign Nibbles
63//!
64//! COMP-3 uses the last nibble for sign encoding:
65//!
66//! | Sign | Nibble | Description |
67//! |------|---------|-------------|
68//! | Positive | `0xC`, `0xA`, `0xE`, `0xF` | Positive values |
69//! | Negative | `0xB`, `0xD` | Negative values |
70//! | Unsigned | `0xF` | Unsigned fields only |
71//!
72//! ### Binary Integer Widths
73//!
74//! Binary integers use the following width mappings:
75//!
76//! | Digits | Width | Bits | Range (signed) |
77//! |---------|--------|-------|----------------|
78//! | 1-4 | 2 bytes | 16 | -32,768 to 32,767 |
79//! | 5-9 | 4 bytes | 32 | -2,147,483,648 to 2,147,483,647 |
80//! | 10-18 | 8 bytes | 64 | -9,223,372,036,854,775,808 to 9,223,372,036,854,775,807 |
81//!
82//! ## Examples
83//!
84//! ### Decoding a Zoned Decimal
85//!
86//! ```no_run
87//! use copybook_codec::numeric::{decode_zoned_decimal};
88//! use copybook_codec::options::Codepage;
89//!
90//! // ASCII zoned decimal: "123" = [0x31, 0x32, 0x33]
91//! let data = b"123";
92//! let result = decode_zoned_decimal(data, 3, 0, false, Codepage::ASCII, false)?;
93//! assert_eq!(result.to_string(), "123");
94//! # Ok::<(), copybook_core::Error>(())
95//! ```
96//!
97//! ### Encoding a Packed Decimal
98//!
99//! ```no_run
100//! use copybook_codec::numeric::{encode_packed_decimal};
101//!
102//! // Encode "123.45" as 7-digit COMP-3 with 2 decimal places
103//! let encoded = encode_packed_decimal("123.45", 7, 2, true)?;
104//! // Result: [0x12, 0x34, 0x5C] (12345 positive)
105//! # Ok::<(), copybook_core::Error>(())
106//! ```
107//!
108//! ## See Also
109//!
110//! - [`crate::zoned_overpunch`] - Zoned decimal overpunch encoding/decoding
111//! - [`crate::SmallDecimal`] - Decimal representation without floating-point precision loss
112//! - [`crate::memory::ScratchBuffers`] - Reusable buffers for zero-allocation processing
113
114use crate::memory::ScratchBuffers;
115use crate::options::{Codepage, FloatFormat, ZonedEncodingFormat};
116use crate::zoned_overpunch::{ZeroSignPolicy, encode_overpunch_byte};
117use copybook_core::{Error, ErrorCode, Result, SignPlacement, SignSeparateInfo};
118use std::convert::TryFrom;
119use std::fmt::Write;
120use tracing::warn;
121
122/// Nibble zones for ASCII/EBCDIC digits (high bits in zoned bytes).
123const ASCII_DIGIT_ZONE: u8 = 0x3; // ASCII '0'..'9' => 0x30..0x39
124const EBCDIC_DIGIT_ZONE: u8 = 0xF; // EBCDIC '0'..'9' => 0xF0..0xF9
125
126/// Branch prediction hint for likely-true conditions
127///
128/// Provides a manual branch prediction hint to the compiler that the condition
129/// is likely to be true. This optimization helps keep hot paths efficient by
130/// marking the false case as cold.
131///
132/// # Arguments
133/// * `b` - Boolean condition to evaluate
134///
135/// # Returns
136/// The input boolean value unchanged
137///
138/// # Performance
139/// This function is critical for COBOL data decoding hot paths where valid
140/// data is the common case and errors are exceptional.
141///
142/// # Examples
143/// ```text
144/// use copybook_codec::numeric::likely;
145///
146/// let valid_data = true;
147/// if likely(valid_data) {
148///     // Hot path - optimized for this case
149/// }
150/// ```
151#[inline]
152pub(crate) fn likely(b: bool) -> bool {
153    // CRITICAL PERFORMANCE OPTIMIZATION: Manual branch prediction optimization
154    // The true case is expected to be taken most of the time (likely path)
155    // Mark the false case as cold to optimize for the common true case
156    if b {
157        true
158    } else {
159        cold_branch_hint();
160        false
161    }
162}
163
164/// Branch prediction hint for unlikely-true conditions
165///
166/// Provides a manual branch prediction hint to the compiler that the condition
167/// is unlikely to be true. This optimization keeps error paths cold and hot
168/// paths optimized.
169///
170/// # Arguments
171/// * `b` - Boolean condition to evaluate
172///
173/// # Returns
174/// The input boolean value unchanged
175///
176/// # Performance
177/// Critical for error handling in COBOL numeric decoding where validation
178/// failures are exceptional cases.
179///
180/// # Examples
181/// ```text
182/// use copybook_codec::numeric::unlikely;
183///
184/// let error_condition = false;
185/// if unlikely(error_condition) {
186///     // Cold path - marked as unlikely
187/// }
188/// ```
189#[inline]
190pub(crate) fn unlikely(b: bool) -> bool {
191    // CRITICAL PERFORMANCE OPTIMIZATION: Manual branch prediction optimization
192    // Use explicit cold annotation to hint that error paths are unlikely
193    // This provides significant speedup by keeping hot paths optimized
194    if b {
195        // Cold path: mark as unlikely taken
196        cold_branch_hint();
197        true
198    } else {
199        false
200    }
201}
202
203/// Manual branch prediction hint for cold paths
204///
205/// This function serves as a branch prediction hint that the calling path is cold/unlikely.
206/// The `#[cold]` attribute tells the compiler this is an unlikely execution path, and
207/// `#[inline(never)]` ensures the cold path doesn't bloat the hot path.
208#[cold]
209#[inline(never)]
210fn cold_branch_hint() {
211    // This function serves as a branch prediction hint that the calling path is cold/unlikely
212    // The #[cold] attribute tells the compiler this is an unlikely execution path
213}
214
215/// Create a normalized `SmallDecimal` from raw components
216///
217/// Constructs a `SmallDecimal` from value, scale, and sign components, then
218/// normalizes it to ensure -0 becomes 0.
219///
220/// # Arguments
221/// * `value` - The unscaled integer value
222/// * `scale` - Number of decimal places (positive for fractions, 0 for integers)
223/// * `is_negative` - Whether the value is negative
224///
225/// # Returns
226/// A normalized `SmallDecimal` instance
227///
228/// # Performance
229/// This inline function is optimized for hot paths in packed decimal decoding
230/// where decimals are frequently constructed from parsed nibbles.
231///
232/// # Examples
233/// ```
234/// use copybook_codec::numeric::SmallDecimal;
235///
236/// // Create 123.45 (value=12345, scale=2)
237/// let decimal = SmallDecimal::new(12345, 2, false);
238/// assert_eq!(decimal.to_string(), "123.45");
239/// ```
240#[inline]
241fn create_normalized_decimal(value: i64, scale: i16, is_negative: bool) -> SmallDecimal {
242    let mut decimal = SmallDecimal::new(value, scale, is_negative);
243    decimal.normalize();
244    decimal
245}
246
247/// Statistics collector for encoding analysis
248///
249/// Tracks the distribution of encoding formats within a zoned decimal field
250/// to determine the overall format and detect mixed encoding patterns.
251#[derive(Debug, Default)]
252#[allow(clippy::struct_field_names)] // Fields are descriptive counters
253struct EncodingAnalysisStats {
254    ascii_count: usize,
255    ebcdic_count: usize,
256    invalid_count: usize,
257}
258
259impl EncodingAnalysisStats {
260    /// Create a new statistics collector
261    fn new() -> Self {
262        Self::default()
263    }
264
265    /// Record a detected format in the statistics
266    fn record_format(&mut self, format: Option<ZonedEncodingFormat>) {
267        match format {
268            Some(ZonedEncodingFormat::Ascii) => self.ascii_count += 1,
269            Some(ZonedEncodingFormat::Ebcdic) => self.ebcdic_count += 1,
270            Some(ZonedEncodingFormat::Auto) => { /* Should not occur in detection */ }
271            None => self.invalid_count += 1,
272        }
273    }
274
275    /// Determine the overall format and mixed encoding status from collected statistics
276    ///
277    /// # Logic
278    /// - If invalid zones exist: Auto format with mixed encoding flag
279    /// - If both ASCII and EBCDIC zones exist: Auto format with mixed encoding flag
280    /// - If only ASCII zones: ASCII format, no mixing
281    /// - If only EBCDIC zones: EBCDIC format, no mixing
282    /// - If no valid zones: Auto format, no mixing (empty or all invalid)
283    fn determine_overall_format(&self) -> (ZonedEncodingFormat, bool) {
284        if self.invalid_count > 0 {
285            // Invalid zones detected - cannot determine format reliably
286            (ZonedEncodingFormat::Auto, true)
287        } else if self.ascii_count > 0 && self.ebcdic_count > 0 {
288            // Mixed ASCII and EBCDIC zones within the same field
289            (ZonedEncodingFormat::Auto, true)
290        } else if self.ascii_count > 0 {
291            // Consistent ASCII encoding throughout the field
292            (ZonedEncodingFormat::Ascii, false)
293        } else if self.ebcdic_count > 0 {
294            // Consistent EBCDIC encoding throughout the field
295            (ZonedEncodingFormat::Ebcdic, false)
296        } else {
297            // No valid zones found (empty field or all invalid)
298            (ZonedEncodingFormat::Auto, false)
299        }
300    }
301}
302
303/// Comprehensive encoding detection result for zoned decimal fields
304///
305/// Provides detailed analysis of zoned decimal encoding patterns within a field,
306/// enabling detection of mixed encodings and validation of data consistency.
307#[derive(Debug, Clone, PartialEq, Eq)]
308pub struct ZonedEncodingInfo {
309    /// Overall detected encoding format for the field
310    pub detected_format: ZonedEncodingFormat,
311    /// True if mixed ASCII/EBCDIC encoding was detected within the field
312    pub has_mixed_encoding: bool,
313    /// Per-byte encoding detection results for detailed analysis
314    pub byte_formats: Vec<Option<ZonedEncodingFormat>>,
315}
316
317impl ZonedEncodingInfo {
318    /// Create new encoding info with the specified format and mixed encoding status
319    ///
320    /// # Arguments
321    /// * `detected_format` - The overall encoding format determined for the field
322    /// * `has_mixed_encoding` - Whether mixed encoding patterns were detected
323    #[inline]
324    #[must_use]
325    pub fn new(detected_format: ZonedEncodingFormat, has_mixed_encoding: bool) -> Self {
326        Self {
327            detected_format,
328            has_mixed_encoding,
329            byte_formats: Vec::new(),
330        }
331    }
332
333    /// Analyze zoned decimal data bytes to detect encoding patterns
334    ///
335    /// Analyze bytes to identify the zoned encoding mix for downstream encode.
336    ///
337    /// # Errors
338    /// Returns an error if detection fails (currently never fails).
339    #[inline]
340    #[must_use = "Handle the Result or propagate the error"]
341    pub fn detect_from_data(data: &[u8]) -> Result<Self> {
342        if data.is_empty() {
343            return Ok(Self::new(ZonedEncodingFormat::Auto, false));
344        }
345
346        let mut byte_formats = Vec::with_capacity(data.len());
347        let mut encoding_stats = EncodingAnalysisStats::new();
348
349        // Analyze each byte's zone nibble for encoding patterns
350        for &byte in data {
351            let format = Self::analyze_zone_nibble(byte);
352            byte_formats.push(format);
353            encoding_stats.record_format(format);
354        }
355
356        // Determine overall encoding and mixed status from statistics
357        let (detected_format, has_mixed_encoding) = encoding_stats.determine_overall_format();
358
359        Ok(Self {
360            detected_format,
361            has_mixed_encoding,
362            byte_formats,
363        })
364    }
365
366    /// Analyze a single byte's zone nibble to determine its encoding format
367    ///
368    /// # Zone Nibble Analysis
369    /// - `0x3`: ASCII digit zone (0x30-0x39 range)
370    /// - `0xF`: EBCDIC digit zone (0xF0-0xF9 range)
371    /// - ASCII overpunch characters: 0x4X, 0x7B, 0x7D (sign encoding)
372    /// - Others: Invalid or non-standard zones
373    fn analyze_zone_nibble(byte: u8) -> Option<ZonedEncodingFormat> {
374        const ASCII_ZONE: u8 = 0x3;
375        const EBCDIC_ZONE: u8 = 0xF;
376        const ZONE_MASK: u8 = 0x0F;
377
378        // Check for specific ASCII overpunch characters first
379        match byte {
380            // ASCII overpunch sign bytes and overpunch characters (A-I, J-R)
381            0x7B | 0x7D | 0x41..=0x52 => return Some(ZonedEncodingFormat::Ascii),
382            _ => {}
383        }
384
385        let zone_nibble = (byte >> 4) & ZONE_MASK;
386        match zone_nibble {
387            ASCII_ZONE => Some(ZonedEncodingFormat::Ascii),
388            EBCDIC_ZONE | 0xC | 0xD => Some(ZonedEncodingFormat::Ebcdic),
389            _ => None, // Invalid or mixed zone
390        }
391    }
392}
393
394/// Small decimal structure for parsing/formatting without floats
395/// This avoids floating-point precision issues for financial data
396#[derive(Debug, Clone, PartialEq, Eq)]
397pub struct SmallDecimal {
398    /// The integer value (unscaled)
399    pub value: i64,
400    /// The scale (number of decimal places)
401    pub scale: i16,
402    /// Whether the value is negative
403    pub negative: bool,
404}
405
406impl SmallDecimal {
407    /// Create a new `SmallDecimal`.
408    #[inline]
409    #[must_use]
410    pub fn new(value: i64, scale: i16, negative: bool) -> Self {
411        Self {
412            value,
413            scale,
414            negative,
415        }
416    }
417
418    /// Create a zero value with the given scale
419    #[inline]
420    #[must_use]
421    pub fn zero(scale: i16) -> Self {
422        Self {
423            value: 0,
424            scale,
425            negative: false,
426        }
427    }
428
429    /// Normalize -0 to 0 (NORMATIVE)
430    #[inline]
431    pub fn normalize(&mut self) {
432        if self.value == 0 {
433            self.negative = false;
434        }
435    }
436
437    /// Format as string with fixed scale (NORMATIVE)
438    ///
439    /// Always render with exactly `scale` digits after decimal point.
440    #[allow(clippy::inherent_to_string)] // Intentional - this is a specific numeric formatting
441    #[inline]
442    #[must_use = "Use the formatted string output"]
443    pub fn to_string(&self) -> String {
444        let mut result = String::new();
445        self.append_sign_if_negative(&mut result);
446        self.append_formatted_value(&mut result);
447        result
448    }
449
450    /// Check if this decimal represents a zero value
451    fn is_zero_value(&self) -> bool {
452        self.value == 0
453    }
454
455    /// Append negative sign to the result if the value is negative and non-zero
456    fn append_sign_if_negative(&self, result: &mut String) {
457        if self.negative && !self.is_zero_value() {
458            result.push('-');
459        }
460    }
461
462    /// Append the formatted numeric value (integer or decimal) to the result
463    fn append_formatted_value(&self, result: &mut String) {
464        if self.scale <= 0 {
465            self.append_integer_format(result);
466        } else {
467            self.append_decimal_format(result);
468        }
469    }
470
471    /// Append integer format or scale extension to the result
472    fn append_integer_format(&self, result: &mut String) {
473        let scaled_value = if self.scale < 0 {
474            // Scale extension: multiply by 10^(-scale)
475            self.value * 10_i64.pow(scale_abs_to_u32(self.scale))
476        } else {
477            // Normal integer format (scale = 0)
478            self.value
479        };
480        // Writing to String should never fail, but handle gracefully for panic elimination
481        if write!(result, "{scaled_value}").is_err() {
482            // Fallback: append a placeholder if formatting somehow fails
483            result.push_str("ERR");
484        }
485    }
486
487    /// Append decimal format with exactly `scale` digits after decimal point
488    fn append_decimal_format(&self, result: &mut String) {
489        let divisor = 10_i64.pow(scale_abs_to_u32(self.scale));
490        let integer_part = self.value / divisor;
491        let fractional_part = self.value % divisor;
492
493        // Writing to String should never fail, but handle gracefully for panic elimination
494        let width = usize::try_from(self.scale).unwrap_or_else(|_| {
495            debug_assert!(false, "scale should be positive when formatting decimal");
496            0
497        });
498
499        if write!(result, "{integer_part}.{fractional_part:0width$}").is_err() {
500            // Fallback: append a placeholder if formatting somehow fails
501            result.push_str("ERR");
502        }
503    }
504
505    /// Parse a decimal string into a `SmallDecimal` using the expected scale.
506    ///
507    /// # Errors
508    /// Returns an error when the text violates the expected numeric format.
509    #[inline]
510    #[must_use = "Handle the Result or propagate the error"]
511    pub fn from_str(s: &str, expected_scale: i16) -> Result<Self> {
512        let trimmed = s.trim();
513        if trimmed.is_empty() {
514            return Ok(Self::zero(expected_scale));
515        }
516
517        let (negative, numeric_part) = Self::extract_sign(trimmed);
518
519        if let Some(dot_pos) = numeric_part.find('.') {
520            Self::parse_decimal_format(numeric_part, dot_pos, expected_scale, negative)
521        } else {
522            Self::parse_integer_format(numeric_part, expected_scale, negative)
523        }
524    }
525
526    /// Extract sign information from the numeric string
527    ///
528    /// Returns (`is_negative`, `numeric_part_without_sign`).
529    fn extract_sign(s: &str) -> (bool, &str) {
530        if let Some(without_minus) = s.strip_prefix('-') {
531            (true, without_minus)
532        } else {
533            (false, s)
534        }
535    }
536
537    /// Parse decimal format string (contains decimal point)
538    fn parse_decimal_format(
539        numeric_part: &str,
540        dot_pos: usize,
541        expected_scale: i16,
542        negative: bool,
543    ) -> Result<Self> {
544        let integer_part = &numeric_part[..dot_pos];
545        let fractional_part = &numeric_part[dot_pos + 1..];
546
547        // Validate scale matches exactly (NORMATIVE)
548        let expected_len = usize::try_from(expected_scale).map_err(|_| {
549            Error::new(
550                ErrorCode::CBKE505_SCALE_MISMATCH,
551                format!(
552                    "Scale mismatch: expected {expected_scale} decimal places, got {}",
553                    fractional_part.len()
554                ),
555            )
556        })?;
557
558        if fractional_part.len() != expected_len {
559            return Err(Error::new(
560                ErrorCode::CBKE505_SCALE_MISMATCH,
561                format!(
562                    "Scale mismatch: expected {expected_scale} decimal places, got {}",
563                    fractional_part.len()
564                ),
565            ));
566        }
567
568        let integer_value = Self::parse_integer_component(integer_part)?;
569        let fractional_value = Self::parse_integer_component(fractional_part)?;
570        let total_value =
571            Self::combine_integer_and_fractional(integer_value, fractional_value, expected_scale)?;
572
573        let mut result = Self::new(total_value, expected_scale, negative);
574        result.normalize();
575        Ok(result)
576    }
577
578    /// Parse integer format string (no decimal point)
579    fn parse_integer_format(
580        numeric_part: &str,
581        expected_scale: i16,
582        negative: bool,
583    ) -> Result<Self> {
584        // Defense-in-depth: accept bare "0" for scaled fields (e.g., user-provided JSON)
585        if expected_scale != 0 {
586            let value = Self::parse_integer_component(numeric_part)?;
587            if value == 0 {
588                let mut result = Self::new(0, expected_scale, negative);
589                result.normalize();
590                return Ok(result);
591            }
592            return Err(Error::new(
593                ErrorCode::CBKE505_SCALE_MISMATCH,
594                format!("Scale mismatch: expected {expected_scale} decimal places, got integer"),
595            ));
596        }
597
598        let value = Self::parse_integer_component(numeric_part)?;
599        let mut result = Self::new(value, expected_scale, negative);
600        result.normalize();
601        Ok(result)
602    }
603
604    /// Parse a string component as an integer with error handling
605    fn parse_integer_component(s: &str) -> Result<i64> {
606        s.parse().map_err(|_| {
607            Error::new(
608                ErrorCode::CBKE501_JSON_TYPE_MISMATCH,
609                format!("Invalid numeric component: '{s}'"),
610            )
611        })
612    }
613
614    /// Combine integer and fractional parts into a scaled value
615    fn combine_integer_and_fractional(
616        integer_value: i64,
617        fractional_value: i64,
618        scale: i16,
619    ) -> Result<i64> {
620        let divisor = 10_i64.pow(scale_abs_to_u32(scale));
621        integer_value
622            .checked_mul(divisor)
623            .and_then(|v| v.checked_add(fractional_value))
624            .ok_or_else(|| {
625                Error::new(
626                    ErrorCode::CBKE510_NUMERIC_OVERFLOW,
627                    "Numeric value too large - would cause overflow",
628                )
629            })
630    }
631
632    /// Format as string with the given fixed scale.
633    ///
634    /// Always renders with exactly `scale` digits after the decimal point,
635    /// independent of the `SmallDecimal`'s own scale. Used for lossless JSON output.
636    #[inline]
637    #[must_use]
638    pub fn to_fixed_scale_string(&self, scale: i16) -> String {
639        let mut result = String::new();
640
641        if self.negative && self.value != 0 {
642            result.push('-');
643        }
644
645        if scale <= 0 {
646            // Integer format (scale=0) or scale extension
647            let scaled_value = if scale < 0 {
648                self.value * 10_i64.pow(scale_abs_to_u32(scale))
649            } else {
650                self.value
651            };
652            // Writing to String should never fail, but handle gracefully for panic elimination
653            if write!(result, "{scaled_value}").is_err() {
654                result.push_str("ERR");
655            }
656        } else {
657            // Decimal format with exactly `scale` digits after decimal
658            let divisor = 10_i64.pow(scale_abs_to_u32(scale));
659            let integer_part = self.value / divisor;
660            let fractional_part = self.value % divisor;
661
662            // Writing to String should never fail, but handle gracefully for panic elimination
663            let width = usize::try_from(scale).unwrap_or_else(|_| {
664                debug_assert!(false, "scale should be positive in decimal formatting");
665                0
666            });
667
668            if write!(result, "{integer_part}.{fractional_part:0width$}").is_err() {
669                result.push_str("ERR");
670            }
671        }
672
673        result
674    }
675
676    /// Format the decimal into a caller-owned string buffer to avoid allocation.
677    ///
678    /// This is the hot-path formatter used in COMP-3 JSON conversion. The buffer
679    /// is cleared before writing.
680    #[inline]
681    pub fn format_to_scratch_buffer(&self, scale: i16, scratch_buffer: &mut String) {
682        scratch_buffer.clear();
683
684        if self.negative && self.value != 0 {
685            scratch_buffer.push('-');
686        }
687
688        if scale <= 0 {
689            // Integer format (scale=0) or scale extension
690            let scaled_value = if scale < 0 {
691                self.value * 10_i64.pow(scale_abs_to_u32(scale))
692            } else {
693                self.value
694            };
695            // CRITICAL OPTIMIZATION: Manual integer formatting to avoid write!() overhead
696            Self::format_integer_manual(scaled_value, scratch_buffer);
697        } else {
698            // Decimal format with exactly `scale` digits after decimal
699            let divisor = 10_i64.pow(scale_abs_to_u32(scale));
700            let integer_part = self.value / divisor;
701            let fractional_part = self.value % divisor;
702
703            // CRITICAL OPTIMIZATION: Manual decimal formatting to avoid write!() overhead
704            Self::format_integer_manual(integer_part, scratch_buffer);
705            scratch_buffer.push('.');
706            Self::format_integer_with_leading_zeros(
707                fractional_part,
708                scale_abs_to_u32(scale),
709                scratch_buffer,
710            );
711        }
712    }
713
714    /// Ultra-fast manual integer formatting to avoid write!() macro overhead
715    #[inline]
716    fn format_integer_manual(mut value: i64, buffer: &mut String) {
717        if value == 0 {
718            buffer.push('0');
719            return;
720        }
721
722        // Optimized formatting with fewer divisions for common cases
723        if value < 100 {
724            // Fast path for 1-2 digit numbers (very common in COMP-3)
725            if value < 10 {
726                push_digit(buffer, value);
727            } else {
728                let tens = value / 10;
729                let ones = value % 10;
730                push_digit(buffer, tens);
731                push_digit(buffer, ones);
732            }
733            return;
734        }
735
736        // Use a small stack buffer for digits for larger numbers
737        let mut digits = [0u8; 20]; // More than enough for i64::MAX
738        let mut count = 0;
739
740        // Safe: i64::MAX has 19 digits, array has 20 elements
741        while value > 0 && count < 20 {
742            digits[count] = digit_from_value(value % 10);
743            value /= 10;
744            count += 1;
745        }
746
747        // Add digits in reverse order
748        for i in (0..count).rev() {
749            buffer.push(char::from(b'0' + digits[i]));
750        }
751    }
752
753    /// Ultra-fast manual integer formatting with leading zeros
754    #[inline]
755    fn format_integer_with_leading_zeros(mut value: i64, width: u32, buffer: &mut String) {
756        // Optimized for common small widths (most COMP-3 scales are 0-4)
757        if width <= 4 && value < 10000 {
758            match width {
759                1 => {
760                    push_digit(buffer, value);
761                }
762                2 => {
763                    push_digit(buffer, value / 10);
764                    push_digit(buffer, value % 10);
765                }
766                3 => {
767                    push_digit(buffer, value / 100);
768                    push_digit(buffer, (value / 10) % 10);
769                    push_digit(buffer, value % 10);
770                }
771                4 => {
772                    push_digit(buffer, value / 1000);
773                    push_digit(buffer, (value / 100) % 10);
774                    push_digit(buffer, (value / 10) % 10);
775                    push_digit(buffer, value % 10);
776                }
777                _ => {}
778            }
779            return;
780        }
781
782        // General case for larger widths
783        let mut digits = [0u8; 20]; // More than enough for i64::MAX
784        let mut count = 0;
785        // Clamp target_width to array size to prevent out-of-bounds access
786        let target_width = usize::try_from(width).unwrap_or(usize::MAX).min(20);
787
788        // Extract digits (safe: i64::MAX has at most 19 digits, array has 20 elements)
789        loop {
790            digits[count] = digit_from_value(value % 10);
791            value /= 10;
792            count += 1;
793            // Safety: count is bounded by min(19, target_width) where target_width <= 20
794            if value == 0 && count >= target_width {
795                break;
796            }
797            if count >= 20 {
798                // Defensive: should never happen for i64, but prevents any overflow
799                break;
800            }
801        }
802
803        // Pad with leading zeros if needed (count is guaranteed <= 20)
804        while count < target_width {
805            digits[count] = 0;
806            count += 1;
807        }
808
809        // Add digits in reverse order
810        for i in (0..count).rev() {
811            buffer.push(char::from(b'0' + digits[i]));
812        }
813    }
814
815    /// Get the scale of this decimal
816    #[inline]
817    #[must_use]
818    pub fn scale(&self) -> i16 {
819        self.scale
820    }
821
822    /// Check if this decimal is negative
823    #[inline]
824    #[must_use]
825    pub fn is_negative(&self) -> bool {
826        self.negative && self.value != 0
827    }
828
829    /// Get the total number of digits in this decimal
830    #[inline]
831    #[must_use]
832    pub fn total_digits(&self) -> u16 {
833        if self.value == 0 {
834            return 1;
835        }
836
837        let mut count = 0;
838        let mut val = self.value.abs();
839        while val > 0 {
840            count += 1;
841            val /= 10;
842        }
843        count
844    }
845}
846
847/// Convert an integer value to a single digit (0-9)
848///
849/// Validates that the value is in the range 0-9 and converts it to a u8 digit.
850/// Returns 0 for invalid inputs (with debug assertion).
851///
852/// # Arguments
853/// * `value` - Integer value to convert (expected to be 0-9)
854///
855/// # Returns
856/// A single digit as u8, or 0 if the value is out of range
857///
858/// # Safety
859/// This function includes a debug assertion that fires if the value is out of
860/// range. In release builds, invalid values return 0.
861///
862/// # Performance
863/// Used in hot paths for packed decimal encoding where digits are extracted
864/// from numeric values.
865#[inline]
866fn digit_from_value(value: i64) -> u8 {
867    match u8::try_from(value) {
868        Ok(digit) if digit <= 9 => digit,
869        _ => {
870            debug_assert!(false, "digit out of range: {value}");
871            0
872        }
873    }
874}
875
876/// Push a single digit character to a string buffer
877///
878/// Converts an integer digit (0-9) to its ASCII character representation and
879/// appends it to the buffer.
880///
881/// # Arguments
882/// * `buffer` - String buffer to append to
883/// * `digit` - Integer digit value (0-9)
884///
885/// # Performance
886/// Inline function optimized for decimal formatting hot paths in COMP-3 decoding.
887#[inline]
888fn push_digit(buffer: &mut String, digit: i64) {
889    buffer.push(char::from(b'0' + digit_from_value(digit)));
890}
891
892/// Convert absolute scale value to u32 for power calculations
893///
894/// Takes the absolute value of a scale (i16) and converts it to u32 for use
895/// in power-of-10 calculations.
896///
897/// # Arguments
898/// * `scale` - Scale value (can be negative for integer extensions)
899///
900/// # Returns
901/// Absolute value of scale as u32
902///
903/// # Examples
904/// ```
905/// # fn scale_abs_to_u32(scale: i16) -> u32 { u32::from(scale.unsigned_abs()) }
906/// assert_eq!(scale_abs_to_u32(2), 2);
907/// assert_eq!(scale_abs_to_u32(-2), 2);
908/// assert_eq!(scale_abs_to_u32(0), 0);
909/// ```
910#[inline]
911fn scale_abs_to_u32(scale: i16) -> u32 {
912    u32::from(scale.unsigned_abs())
913}
914
915/// Decode a zoned decimal using the configured code page with detailed error context.
916///
917/// Decodes zoned decimal (PIC 9) fields where each digit is stored in a byte
918/// with a zone nibble and a digit nibble. The sign may be encoded via overpunch
919/// in the last byte's zone nibble.
920///
921/// # Arguments
922/// * `data` - Raw byte data containing the zoned decimal
923/// * `digits` - Number of digit characters (field length)
924/// * `scale` - Number of decimal places (can be negative for scaling)
925/// * `signed` - Whether the field is signed (true) or unsigned (false)
926/// * `codepage` - Character encoding (ASCII or EBCDIC variant)
927/// * `blank_when_zero` - If true, all-space fields decode as zero
928///
929/// # Returns
930/// A `SmallDecimal` containing the decoded value
931///
932/// # Policy
933/// Applies the codec default: ASCII uses `ZeroSignPolicy::Positive`; EBCDIC zeros normalize via `ZeroSignPolicy::Preferred`.
934///
935/// # Errors
936/// Returns an error if the zoned decimal data is invalid or contains bad sign zones.
937/// All errors include proper context information (`record_index`, `field_path`, `byte_offset`).
938///
939/// # Examples
940///
941/// ## ASCII Zoned Decimal
942///
943/// ```no_run
944/// use copybook_codec::numeric::{decode_zoned_decimal};
945/// use copybook_codec::options::Codepage;
946///
947/// // ASCII "123" = [0x31, 0x32, 0x33]
948/// let data = b"123";
949/// let result = decode_zoned_decimal(data, 3, 0, false, Codepage::ASCII, false)?;
950/// assert_eq!(result.to_string(), "123");
951/// # Ok::<(), copybook_core::Error>(())
952/// ```
953///
954/// ## Signed ASCII Zoned Decimal (Overpunch)
955///
956/// ```no_run
957/// use copybook_codec::numeric::{decode_zoned_decimal};
958/// use copybook_codec::options::Codepage;
959///
960/// // ASCII "-123" with overpunch: [0x31, 0x32, 0x4D] (M = 3 with negative sign)
961/// let data = [0x31, 0x32, 0x4D];
962/// let result = decode_zoned_decimal(&data, 3, 0, true, Codepage::ASCII, false)?;
963/// assert_eq!(result.to_string(), "-123");
964/// # Ok::<(), copybook_core::Error>(())
965/// ```
966///
967/// ## EBCDIC Zoned Decimal
968///
969/// ```no_run
970/// use copybook_codec::numeric::{decode_zoned_decimal};
971/// use copybook_codec::options::Codepage;
972///
973/// // EBCDIC "123" = [0xF1, 0xF2, 0xF3]
974/// let data = [0xF1, 0xF2, 0xF3];
975/// let result = decode_zoned_decimal(&data, 3, 0, false, Codepage::CP037, false)?;
976/// assert_eq!(result.to_string(), "123");
977/// # Ok::<(), copybook_core::Error>(())
978/// ```
979///
980/// ## Decimal Scale
981///
982/// ```no_run
983/// use copybook_codec::numeric::{decode_zoned_decimal};
984/// use copybook_codec::options::Codepage;
985///
986/// // "12.34" with 2 decimal places
987/// let data = b"1234";
988/// let result = decode_zoned_decimal(data, 4, 2, false, Codepage::ASCII, false)?;
989/// assert_eq!(result.to_string(), "12.34");
990/// # Ok::<(), copybook_core::Error>(())
991/// ```
992///
993/// ## BLANK WHEN ZERO
994///
995/// ```no_run
996/// use copybook_codec::numeric::{decode_zoned_decimal};
997/// use copybook_codec::options::Codepage;
998///
999/// // All spaces decode as zero when blank_when_zero is true
1000/// let data = b"   ";
1001/// let result = decode_zoned_decimal(data, 3, 0, false, Codepage::ASCII, true)?;
1002/// assert_eq!(result.to_string(), "0");
1003/// # Ok::<(), copybook_core::Error>(())
1004/// ```
1005///
1006/// # See Also
1007/// * [`decode_zoned_decimal_sign_separate`] - For SIGN SEPARATE fields
1008/// * [`decode_zoned_decimal_with_encoding`] - For encoding detection
1009/// * [`encode_zoned_decimal`] - For encoding zoned decimals
1010#[inline]
1011#[must_use = "Handle the Result or propagate the error"]
1012pub fn decode_zoned_decimal(
1013    data: &[u8],
1014    digits: u16,
1015    scale: i16,
1016    signed: bool,
1017    codepage: Codepage,
1018    blank_when_zero: bool,
1019) -> Result<SmallDecimal> {
1020    if unlikely(data.len() != usize::from(digits)) {
1021        return Err(Error::new(
1022            ErrorCode::CBKD411_ZONED_BAD_SIGN,
1023            "Zoned decimal data length mismatch".to_string(),
1024        ));
1025    }
1026
1027    // Check for BLANK WHEN ZERO (all spaces)
1028    let is_all_spaces = data.iter().all(|&b| {
1029        match codepage {
1030            Codepage::ASCII => b == b' ',
1031            _ => b == 0x40, // EBCDIC space
1032        }
1033    });
1034
1035    if is_all_spaces {
1036        if blank_when_zero {
1037            warn!("CBKD412_ZONED_BLANK_IS_ZERO: Zoned field is blank, decoding as zero");
1038            // Track this warning in RunSummary
1039            crate::lib_api::increment_warning_counter();
1040            return Ok(SmallDecimal::zero(scale));
1041        }
1042        return Err(Error::new(
1043            ErrorCode::CBKD411_ZONED_BAD_SIGN,
1044            "Zoned field contains all spaces but BLANK WHEN ZERO not specified",
1045        ));
1046    }
1047
1048    let mut value = 0i64;
1049    let mut is_negative = false;
1050    let expected_zone = match codepage {
1051        Codepage::ASCII => ASCII_DIGIT_ZONE,
1052        _ => EBCDIC_DIGIT_ZONE,
1053    };
1054
1055    for (i, &byte) in data.iter().enumerate() {
1056        if i == data.len() - 1 {
1057            let (digit, negative) = crate::zoned_overpunch::decode_overpunch_byte(byte, codepage)?;
1058
1059            if signed {
1060                is_negative = negative;
1061            } else {
1062                let zone = (byte >> 4) & 0x0F;
1063                let zone_label = match codepage {
1064                    Codepage::ASCII => "ASCII",
1065                    _ => "EBCDIC",
1066                };
1067                if zone != expected_zone {
1068                    return Err(Error::new(
1069                        ErrorCode::CBKD411_ZONED_BAD_SIGN,
1070                        format!(
1071                            "Unsigned {zone_label} zoned decimal cannot contain sign zone 0x{zone:X} in last byte"
1072                        ),
1073                    ));
1074                }
1075                if negative {
1076                    return Err(Error::new(
1077                        ErrorCode::CBKD411_ZONED_BAD_SIGN,
1078                        "Unsigned zoned decimal contains negative overpunch",
1079                    ));
1080                }
1081            }
1082
1083            value = value.saturating_mul(10).saturating_add(i64::from(digit));
1084        } else {
1085            let zone = (byte >> 4) & 0x0F;
1086            let digit = byte & 0x0F;
1087
1088            if digit > 9 {
1089                return Err(Error::new(
1090                    ErrorCode::CBKD411_ZONED_BAD_SIGN,
1091                    format!("Invalid digit nibble 0x{digit:X} at position {i}"),
1092                ));
1093            }
1094
1095            if zone != expected_zone {
1096                let zone_label = match codepage {
1097                    Codepage::ASCII => "ASCII",
1098                    _ => "EBCDIC",
1099                };
1100                return Err(Error::new(
1101                    ErrorCode::CBKD411_ZONED_BAD_SIGN,
1102                    format!(
1103                        "Invalid {zone_label} zone 0x{zone:X} at position {i}, expected 0x{expected_zone:X}"
1104                    ),
1105                ));
1106            }
1107
1108            value = value.saturating_mul(10).saturating_add(i64::from(digit));
1109        }
1110    }
1111
1112    let mut decimal = SmallDecimal::new(value, scale, is_negative);
1113    decimal.normalize(); // Normalize -0 → 0 (NORMATIVE)
1114    Ok(decimal)
1115}
1116
1117/// Decode a zoned decimal field with SIGN SEPARATE clause
1118///
1119/// SIGN SEPARATE stores the sign in a separate byte rather than overpunching
1120/// it in the zone portion of the last digit. The sign byte can be leading
1121/// (before digits) or trailing (after digits).
1122///
1123/// # Arguments
1124/// * `data` - Raw byte data (includes sign byte + digit bytes)
1125/// * `digits` - Number of digit characters (not including sign byte)
1126/// * `scale` - Decimal places (can be negative for scaling)
1127/// * `sign_separate` - SIGN SEPARATE clause information (placement)
1128/// * `codepage` - Character encoding (ASCII or EBCDIC)
1129///
1130/// # Returns
1131/// A `SmallDecimal` containing the decoded value
1132///
1133/// # Errors
1134/// Returns an error if data length is incorrect or sign byte is invalid.
1135///
1136/// # Examples
1137///
1138/// ## Leading Sign (ASCII)
1139///
1140/// ```no_run
1141/// use copybook_codec::numeric::{decode_zoned_decimal_sign_separate};
1142/// use copybook_codec::options::Codepage;
1143/// use copybook_core::SignPlacement;
1144/// use copybook_core::SignSeparateInfo;
1145///
1146/// // "+123" with leading sign: [0x2B, 0x31, 0x32, 0x33]
1147/// let sign_info = SignSeparateInfo { placement: SignPlacement::Leading };
1148/// let data = [b'+', b'1', b'2', b'3'];
1149/// let result = decode_zoned_decimal_sign_separate(&data, 3, 0, &sign_info, Codepage::ASCII)?;
1150/// assert_eq!(result.to_string(), "123");
1151/// # Ok::<(), copybook_core::Error>(())
1152/// ```
1153///
1154/// ## Trailing Sign (ASCII)
1155///
1156/// ```no_run
1157/// use copybook_codec::numeric::{decode_zoned_decimal_sign_separate};
1158/// use copybook_codec::options::Codepage;
1159/// use copybook_core::SignPlacement;
1160/// use copybook_core::SignSeparateInfo;
1161///
1162/// // "-456" with trailing sign: [0x34, 0x35, 0x36, 0x2D]
1163/// let sign_info = SignSeparateInfo { placement: SignPlacement::Trailing };
1164/// let data = [b'4', b'5', b'6', b'-'];
1165/// let result = decode_zoned_decimal_sign_separate(&data, 3, 0, &sign_info, Codepage::ASCII)?;
1166/// assert_eq!(result.to_string(), "-456");
1167/// # Ok::<(), copybook_core::Error>(())
1168/// ```
1169///
1170/// ## EBCDIC Leading Sign
1171///
1172/// ```no_run
1173/// use copybook_codec::numeric::{decode_zoned_decimal_sign_separate};
1174/// use copybook_codec::options::Codepage;
1175/// use copybook_core::SignPlacement;
1176/// use copybook_core::SignSeparateInfo;
1177///
1178/// // "+789" with leading EBCDIC sign: [0x4E, 0xF7, 0xF8, 0xF9]
1179/// let sign_info = SignSeparateInfo { placement: SignPlacement::Leading };
1180/// let data = [0x4E, 0xF7, 0xF8, 0xF9];
1181/// let result = decode_zoned_decimal_sign_separate(&data, 3, 0, &sign_info, Codepage::CP037)?;
1182/// assert_eq!(result.to_string(), "789");
1183/// # Ok::<(), copybook_core::Error>(())
1184/// ```
1185///
1186/// # See Also
1187/// * [`decode_zoned_decimal`] - For overpunch-encoded zoned decimals
1188/// * [`decode_zoned_decimal_with_encoding`] - For encoding detection
1189#[inline]
1190#[must_use = "Handle the Result or propagate the error"]
1191pub fn decode_zoned_decimal_sign_separate(
1192    data: &[u8],
1193    digits: u16,
1194    scale: i16,
1195    sign_separate: &SignSeparateInfo,
1196    codepage: Codepage,
1197) -> Result<SmallDecimal> {
1198    // SIGN SEPARATE adds 1 byte for the sign
1199    let expected_len = usize::from(digits) + 1;
1200
1201    if unlikely(data.len() != expected_len) {
1202        return Err(Error::new(
1203            ErrorCode::CBKD301_RECORD_TOO_SHORT,
1204            format!(
1205                "SIGN SEPARATE zoned decimal data length mismatch: expected {} bytes, got {}",
1206                expected_len,
1207                data.len()
1208            ),
1209        ));
1210    }
1211
1212    // Determine sign byte and digit bytes based on placement
1213    let (sign_byte, digit_bytes) = match sign_separate.placement {
1214        SignPlacement::Leading => {
1215            // Sign byte is first, digits follow
1216            if data.is_empty() {
1217                return Err(Error::new(
1218                    ErrorCode::CBKD301_RECORD_TOO_SHORT,
1219                    "SIGN SEPARATE field is empty",
1220                ));
1221            }
1222            (data[0], &data[1..])
1223        }
1224        SignPlacement::Trailing => {
1225            // Digits are first, sign byte is last
1226            if data.is_empty() {
1227                return Err(Error::new(
1228                    ErrorCode::CBKD301_RECORD_TOO_SHORT,
1229                    "SIGN SEPARATE field is empty",
1230                ));
1231            }
1232            (data[data.len() - 1], &data[..data.len() - 1])
1233        }
1234    };
1235
1236    // Decode sign byte
1237
1238    let is_negative = if codepage.is_ascii() {
1239        match sign_byte {
1240            b'-' => true,
1241
1242            b'+' | b' ' | b'0' => false, // Space or zero means positive/unsigned
1243
1244            _ => {
1245                return Err(Error::new(
1246                    ErrorCode::CBKD411_ZONED_BAD_SIGN,
1247                    format!("Invalid sign byte in SIGN SEPARATE field: 0x{sign_byte:02X} (ASCII)"),
1248                ));
1249            }
1250        }
1251    } else {
1252        // EBCDIC codepage (CP037, CP273, CP500, CP1047, CP1140)
1253
1254        match sign_byte {
1255            0x60 => true, // EBCDIC '-'
1256
1257            0x4E | 0x40 | 0xF0 => false, // Space or zero means positive/unsigned
1258
1259            _ => {
1260                return Err(Error::new(
1261                    ErrorCode::CBKD411_ZONED_BAD_SIGN,
1262                    format!("Invalid sign byte in SIGN SEPARATE field: 0x{sign_byte:02X} (EBCDIC)"),
1263                ));
1264            }
1265        }
1266    };
1267
1268    // Decode digit bytes
1269
1270    let mut value: i64 = 0;
1271
1272    for &byte in digit_bytes {
1273        let digit = if codepage.is_ascii() {
1274            if !byte.is_ascii_digit() {
1275                return Err(Error::new(
1276                    ErrorCode::CBKD301_RECORD_TOO_SHORT,
1277                    format!("Invalid digit byte in SIGN SEPARATE field: 0x{byte:02X} (ASCII)"),
1278                ));
1279            }
1280
1281            byte - b'0'
1282        } else {
1283            // EBCDIC digits are 0xF0-0xF9
1284
1285            if !(0xF0..=0xF9).contains(&byte) {
1286                return Err(Error::new(
1287                    ErrorCode::CBKD301_RECORD_TOO_SHORT,
1288                    format!("Invalid digit byte in SIGN SEPARATE field: 0x{byte:02X} (EBCDIC)"),
1289                ));
1290            }
1291
1292            byte - 0xF0
1293        };
1294
1295        value = value
1296            .checked_mul(10)
1297            .and_then(|v| v.checked_add(i64::from(digit)))
1298            .ok_or_else(|| {
1299                Error::new(
1300                    ErrorCode::CBKD410_ZONED_OVERFLOW,
1301                    format!("SIGN SEPARATE zoned decimal value overflow for {digits} digits"),
1302                )
1303            })?;
1304    }
1305
1306    let mut decimal = SmallDecimal::new(value, scale, is_negative);
1307    decimal.normalize(); // Normalize -0 → 0 (NORMATIVE)
1308    Ok(decimal)
1309}
1310
1311/// Encode a zoned decimal value with SIGN SEPARATE clause.
1312///
1313/// The SIGN SEPARATE clause places the sign character in a separate byte
1314/// (leading or trailing) rather than overpunching the last digit.
1315///
1316/// Total encoded length = digits + 1 (for the separate sign byte).
1317///
1318/// # Arguments
1319/// * `value` - String representation of the numeric value (e.g., "123", "-456.78")
1320/// * `digits` - Number of digit positions in the field
1321/// * `scale` - Number of implied decimal places
1322/// * `sign_separate` - Sign placement information (leading or trailing)
1323/// * `codepage` - Character encoding (determines sign byte encoding)
1324/// * `buffer` - Output buffer (must be at least digits + 1 bytes)
1325///
1326/// # Errors
1327/// Returns `CBKE530_SIGN_SEPARATE_ENCODE_ERROR` if the value cannot be encoded.
1328#[inline]
1329pub fn encode_zoned_decimal_sign_separate(
1330    value: &str,
1331    digits: u16,
1332    scale: i16,
1333    sign_separate: &SignSeparateInfo,
1334    codepage: Codepage,
1335    buffer: &mut [u8],
1336) -> Result<()> {
1337    let expected_len = usize::from(digits) + 1;
1338    if buffer.len() < expected_len {
1339        return Err(Error::new(
1340            ErrorCode::CBKE530_SIGN_SEPARATE_ENCODE_ERROR,
1341            format!(
1342                "SIGN SEPARATE encode buffer too small: need {expected_len} bytes, got {}",
1343                buffer.len()
1344            ),
1345        ));
1346    }
1347
1348    // Parse value string to determine sign and digit characters
1349    let trimmed = value.trim();
1350    let (is_negative, abs_str) = if let Some(rest) = trimmed.strip_prefix('-') {
1351        (true, rest)
1352    } else if let Some(rest) = trimmed.strip_prefix('+') {
1353        (false, rest)
1354    } else {
1355        (false, trimmed)
1356    };
1357
1358    // Validate input characters before scaling
1359    for ch in abs_str.chars() {
1360        if !ch.is_ascii_digit() && ch != '.' {
1361            return Err(Error::new(
1362                ErrorCode::CBKE530_SIGN_SEPARATE_ENCODE_ERROR,
1363                format!("Unexpected character '{ch}' in numeric value '{value}'"),
1364            ));
1365        }
1366    }
1367
1368    // Structural validation: reject ambiguous/empty numeric input
1369    let dot_count = abs_str.chars().filter(|&c| c == '.').count();
1370    let digit_count = abs_str.chars().filter(char::is_ascii_digit).count();
1371
1372    if digit_count == 0 {
1373        return Err(Error::new(
1374            ErrorCode::CBKE530_SIGN_SEPARATE_ENCODE_ERROR,
1375            format!("No digits found in numeric value '{value}'"),
1376        ));
1377    }
1378    if dot_count > 1 {
1379        return Err(Error::new(
1380            ErrorCode::CBKE530_SIGN_SEPARATE_ENCODE_ERROR,
1381            format!("Multiple decimal points in numeric value '{value}'"),
1382        ));
1383    }
1384    if scale <= 0 && dot_count == 1 {
1385        return Err(Error::new(
1386            ErrorCode::CBKE530_SIGN_SEPARATE_ENCODE_ERROR,
1387            format!("Unexpected decimal point for scale {scale} in value '{value}'"),
1388        ));
1389    }
1390
1391    // Build the scaled digit string
1392    let scaled = build_scaled_digit_string(abs_str, scale);
1393
1394    // Pad with leading zeros or truncate to match digit count
1395    let digits_usize = usize::from(digits);
1396    let padded = match scaled.len().cmp(&digits_usize) {
1397        std::cmp::Ordering::Less => {
1398            format!("{scaled:0>digits_usize$}")
1399        }
1400        std::cmp::Ordering::Greater => {
1401            return Err(Error::new(
1402                ErrorCode::CBKE530_SIGN_SEPARATE_ENCODE_ERROR,
1403                format!(
1404                    "SIGN SEPARATE overflow: value requires {} digits but field allows {}",
1405                    scaled.len(),
1406                    digits_usize
1407                ),
1408            ));
1409        }
1410        std::cmp::Ordering::Equal => scaled,
1411    };
1412
1413    // Determine sign byte and digit encoding based on codepage
1414    let (sign_byte, digit_base): (u8, u8) = if codepage.is_ascii() {
1415        (if is_negative { b'-' } else { b'+' }, b'0')
1416    } else {
1417        // EBCDIC codepages
1418        (if is_negative { 0x60 } else { 0x4E }, 0xF0)
1419    };
1420
1421    // Write digits to buffer
1422    let digit_offset = match sign_separate.placement {
1423        SignPlacement::Leading => {
1424            buffer[0] = sign_byte;
1425            1
1426        }
1427        SignPlacement::Trailing => 0,
1428    };
1429
1430    for (i, byte) in padded.bytes().enumerate() {
1431        let digit = byte.wrapping_sub(b'0');
1432        if digit > 9 {
1433            return Err(Error::new(
1434                ErrorCode::CBKE530_SIGN_SEPARATE_ENCODE_ERROR,
1435                format!("Invalid digit byte 0x{byte:02X} in value"),
1436            ));
1437        }
1438        buffer[digit_offset + i] = digit_base + digit;
1439    }
1440
1441    if matches!(sign_separate.placement, SignPlacement::Trailing) {
1442        buffer[digits_usize] = sign_byte;
1443    }
1444
1445    Ok(())
1446}
1447
1448/// Build a digit string scaled to the given number of decimal places.
1449///
1450/// Splits the absolute value string at the decimal point (if present),
1451/// pads or truncates the fractional part to `scale` digits, and concatenates.
1452fn build_scaled_digit_string(abs_str: &str, scale: i16) -> String {
1453    // Extract only digit characters (ignoring other formatting)
1454    let digit_str: String = abs_str.chars().filter(char::is_ascii_digit).collect();
1455
1456    if scale <= 0 {
1457        return digit_str;
1458    }
1459
1460    let scale_usize = usize::try_from(scale).unwrap_or(0);
1461    let (integer_part, fractional_part) = if let Some(pos) = abs_str.find('.') {
1462        (&abs_str[..pos], &abs_str[pos + 1..])
1463    } else {
1464        (abs_str, "")
1465    };
1466
1467    let int_digits: String = integer_part.chars().filter(char::is_ascii_digit).collect();
1468    let frac_digits: String = fractional_part
1469        .chars()
1470        .filter(char::is_ascii_digit)
1471        .collect();
1472
1473    // Pad or truncate fractional part to match scale
1474    let padded_frac = if frac_digits.len() >= scale_usize {
1475        frac_digits[..scale_usize].to_string()
1476    } else {
1477        format!("{frac_digits:0<scale_usize$}")
1478    };
1479    format!("{int_digits}{padded_frac}")
1480}
1481
1482/// Decode zoned decimal field with encoding detection and preservation
1483///
1484/// Returns both the decoded decimal and encoding information for preservation.
1485/// When `preserve_encoding` is true, analyzes the input data to detect
1486/// whether it uses ASCII or EBCDIC encoding, and whether mixed encodings
1487/// are present within the field.
1488///
1489/// # Arguments
1490/// * `data` - Raw byte data containing the zoned decimal
1491/// * `digits` - Number of digit characters (field length)
1492/// * `scale` - Number of decimal places (can be negative for scaling)
1493/// * `signed` - Whether the field is signed (true) or unsigned (false)
1494/// * `codepage` - Character encoding (ASCII or EBCDIC variant)
1495/// * `blank_when_zero` - If true, all-space fields decode as zero
1496/// * `preserve_encoding` - If true, detect and return encoding information
1497///
1498/// # Returns
1499/// A tuple of (`SmallDecimal`, `Option<ZonedEncodingInfo>`) containing:
1500/// - The decoded decimal value
1501/// - Encoding information (if `preserve_encoding` was true)
1502///
1503/// # Policy
1504/// Mirrors [`decode_zoned_decimal`], defaulting to preferred-zero handling for EBCDIC unless a preserved format dictates otherwise.
1505///
1506/// # Errors
1507/// Returns an error if the zoned decimal data is invalid or contains bad sign zones.
1508/// All errors include proper context information (`record_index`, `field_path`, `byte_offset`).
1509///
1510/// # Examples
1511///
1512/// ## Basic Decoding Without Preservation
1513///
1514/// ```no_run
1515/// use copybook_codec::numeric::{decode_zoned_decimal_with_encoding};
1516/// use copybook_codec::options::Codepage;
1517///
1518/// let data = b"123";
1519/// let (decimal, encoding_info) = decode_zoned_decimal_with_encoding(
1520///     data, 3, 0, false, Codepage::ASCII, false, false
1521/// )?;
1522/// assert_eq!(decimal.to_string(), "123");
1523/// assert!(encoding_info.is_none());
1524/// # Ok::<(), copybook_core::Error>(())
1525/// ```
1526///
1527/// ## Encoding Detection
1528///
1529/// ```no_run
1530/// use copybook_codec::numeric::{decode_zoned_decimal_with_encoding};
1531/// use copybook_codec::options::Codepage;
1532/// use copybook_codec::options::ZonedEncodingFormat;
1533///
1534/// let data = b"123";
1535/// let (decimal, encoding_info) = decode_zoned_decimal_with_encoding(
1536///     data, 3, 0, false, Codepage::ASCII, false, true
1537/// )?;
1538/// assert_eq!(decimal.to_string(), "123");
1539/// let info = encoding_info.unwrap();
1540/// assert_eq!(info.detected_format, ZonedEncodingFormat::Ascii);
1541/// assert!(!info.has_mixed_encoding);
1542/// # Ok::<(), copybook_core::Error>(())
1543/// ```
1544///
1545/// # See Also
1546/// * [`decode_zoned_decimal`] - For basic zoned decimal decoding
1547/// * [`ZonedEncodingInfo`] - For encoding detection results
1548#[inline]
1549#[must_use = "Handle the Result or propagate the error"]
1550pub fn decode_zoned_decimal_with_encoding(
1551    data: &[u8],
1552    digits: u16,
1553    scale: i16,
1554    signed: bool,
1555    codepage: Codepage,
1556    blank_when_zero: bool,
1557    preserve_encoding: bool,
1558) -> Result<(SmallDecimal, Option<ZonedEncodingInfo>)> {
1559    if data.len() != usize::from(digits) {
1560        return Err(Error::new(
1561            ErrorCode::CBKD411_ZONED_BAD_SIGN,
1562            format!(
1563                "Zoned decimal data length {} doesn't match digits {}",
1564                data.len(),
1565                digits
1566            ),
1567        ));
1568    }
1569
1570    // Check for BLANK WHEN ZERO (all spaces)
1571    let is_all_spaces = data.iter().all(|&b| {
1572        match codepage {
1573            Codepage::ASCII => b == b' ',
1574            _ => b == 0x40, // EBCDIC space
1575        }
1576    });
1577
1578    if is_all_spaces {
1579        if blank_when_zero {
1580            warn!("CBKD412_ZONED_BLANK_IS_ZERO: Zoned field is blank, decoding as zero");
1581            crate::lib_api::increment_warning_counter();
1582            return Ok((SmallDecimal::zero(scale), None));
1583        }
1584        return Err(Error::new(
1585            ErrorCode::CBKD411_ZONED_BAD_SIGN,
1586            "Zoned field contains all spaces but BLANK WHEN ZERO not specified",
1587        ));
1588    }
1589
1590    // Detect encoding if preservation is enabled
1591    let encoding_info = if preserve_encoding {
1592        Some(ZonedEncodingInfo::detect_from_data(data)?)
1593    } else {
1594        None
1595    };
1596
1597    // Check for mixed encoding error
1598    if let Some(ref info) = encoding_info
1599        && info.has_mixed_encoding
1600    {
1601        return Err(Error::new(
1602            ErrorCode::CBKD414_ZONED_MIXED_ENCODING,
1603            "Mixed ASCII/EBCDIC encoding detected within zoned decimal field",
1604        ));
1605    }
1606
1607    let (value, is_negative) =
1608        zoned_decode_digits_with_encoding(data, signed, codepage, preserve_encoding)?;
1609
1610    let mut decimal = SmallDecimal::new(value, scale, is_negative);
1611    decimal.normalize(); // Normalize -0 → 0 (NORMATIVE)
1612    Ok((decimal, encoding_info))
1613}
1614
1615/// Internal helper to decode zoned decimal digits with encoding detection
1616///
1617/// This function handles the core logic of iterating through zoned decimal bytes,
1618/// accumulating the numeric value, and optionally detecting/validating the encoding.
1619///
1620/// # Arguments
1621/// * `data` - Raw byte data containing the zoned decimal
1622/// * `signed` - Whether the field is signed
1623/// * `codepage` - Character encoding (ASCII or EBCDIC variant)
1624/// * `preserve_encoding` - If true, validate consistent encoding throughout the field
1625///
1626/// # Returns
1627/// A tuple of (`accumulated_value`, `is_negative`)
1628///
1629/// # Errors
1630/// Returns an error if an invalid digit or zone nibble is encountered.
1631#[inline]
1632fn zoned_decode_digits_with_encoding(
1633    data: &[u8],
1634    signed: bool,
1635    codepage: Codepage,
1636    preserve_encoding: bool,
1637) -> Result<(i64, bool)> {
1638    let mut value = 0i64;
1639    let mut is_negative = false;
1640
1641    for (index, &byte) in data.iter().enumerate() {
1642        let zone = (byte >> 4) & 0x0F;
1643
1644        if index == data.len() - 1 {
1645            let (digit, negative) = crate::zoned_overpunch::decode_overpunch_byte(byte, codepage)?;
1646
1647            if signed {
1648                is_negative = negative;
1649            } else {
1650                let zone_valid = if preserve_encoding {
1651                    matches!(zone, 0x3 | 0xF)
1652                } else {
1653                    match codepage {
1654                        Codepage::ASCII => zone == 0x3,
1655                        _ => zone == 0xF,
1656                    }
1657                };
1658
1659                if !zone_valid {
1660                    let message = if preserve_encoding {
1661                        format!(
1662                            "Invalid zone 0x{zone:X} in unsigned zoned decimal, expected 0x3 (ASCII) or 0xF (EBCDIC)"
1663                        )
1664                    } else {
1665                        let zone_label = zoned_zone_label(codepage);
1666                        format!(
1667                            "Unsigned {zone_label} zoned decimal cannot contain sign zone 0x{zone:X} in last byte"
1668                        )
1669                    };
1670                    let code = if preserve_encoding {
1671                        ErrorCode::CBKD413_ZONED_INVALID_ENCODING
1672                    } else {
1673                        ErrorCode::CBKD411_ZONED_BAD_SIGN
1674                    };
1675                    return Err(Error::new(code, message));
1676                }
1677
1678                if negative {
1679                    return Err(Error::new(
1680                        ErrorCode::CBKD411_ZONED_BAD_SIGN,
1681                        "Unsigned zoned decimal contains negative overpunch",
1682                    ));
1683                }
1684            }
1685
1686            value = value.saturating_mul(10).saturating_add(i64::from(digit));
1687        } else {
1688            let digit = byte & 0x0F;
1689            if digit > 9 {
1690                return Err(Error::new(
1691                    ErrorCode::CBKD411_ZONED_BAD_SIGN,
1692                    format!("Invalid digit nibble 0x{digit:X} at position {index}"),
1693                ));
1694            }
1695
1696            if preserve_encoding {
1697                match zone {
1698                    0x3 | 0xF => {}
1699                    _ => {
1700                        return Err(Error::new(
1701                            ErrorCode::CBKD413_ZONED_INVALID_ENCODING,
1702                            format!(
1703                                "Invalid zone 0x{zone:X} at position {index}, expected 0x3 (ASCII) or 0xF (EBCDIC)"
1704                            ),
1705                        ));
1706                    }
1707                }
1708            } else {
1709                match codepage {
1710                    Codepage::ASCII => {
1711                        if zone != 0x3 {
1712                            return Err(Error::new(
1713                                ErrorCode::CBKD411_ZONED_BAD_SIGN,
1714                                format!(
1715                                    "Invalid ASCII zone 0x{zone:X} at position {index}, expected 0x3"
1716                                ),
1717                            ));
1718                        }
1719                    }
1720                    _ => {
1721                        if zone != 0xF {
1722                            return Err(Error::new(
1723                                ErrorCode::CBKD411_ZONED_BAD_SIGN,
1724                                format!(
1725                                    "Invalid EBCDIC zone 0x{zone:X} at position {index}, expected 0xF"
1726                                ),
1727                            ));
1728                        }
1729                    }
1730                }
1731            }
1732
1733            value = value.saturating_mul(10).saturating_add(i64::from(digit));
1734        }
1735    }
1736
1737    Ok((value, is_negative))
1738}
1739
1740/// Decode packed decimal (COMP-3) field with comprehensive error context
1741///
1742/// Decodes COMP-3 packed decimal format where each byte contains two decimal
1743/// digits (nibbles), with the last nibble containing the sign. This function
1744/// uses optimized fast paths for common enterprise data patterns.
1745///
1746/// # Arguments
1747/// * `data` - Raw byte data containing the packed decimal
1748/// * `digits` - Number of decimal digits in the field (1-18 supported)
1749/// * `scale` - Number of decimal places (can be negative for scaling)
1750/// * `signed` - Whether the field is signed (true) or unsigned (false)
1751///
1752/// # Returns
1753/// A `SmallDecimal` containing the decoded value
1754///
1755/// # Errors
1756/// Returns an error if the packed decimal data contains invalid nibbles.
1757/// All errors include proper context information (`record_index`, `field_path`, `byte_offset`).
1758///
1759/// # Performance
1760/// This function uses specialized fast paths for common cases:
1761/// - 1-5 byte fields: Direct decoding with minimal validation
1762/// - Empty data: Immediate zero return
1763/// - Digits > 18: Error (maximum supported precision)
1764///
1765/// # Examples
1766///
1767/// ## Basic Positive Value
1768///
1769/// ```no_run
1770/// use copybook_codec::numeric::{decode_packed_decimal};
1771///
1772/// // "123" as COMP-3: [0x12, 0x3C] (12 positive, 3C = positive sign)
1773/// let data = [0x12, 0x3C];
1774/// let result = decode_packed_decimal(&data, 3, 0, true)?;
1775/// assert_eq!(result.to_string(), "123");
1776/// # Ok::<(), copybook_core::Error>(())
1777/// ```
1778///
1779/// ## Negative Value
1780///
1781/// ```no_run
1782/// use copybook_codec::numeric::{decode_packed_decimal};
1783///
1784/// // "-456" as COMP-3: [0x04, 0x56, 0xD] (456 negative)
1785/// let data = [0x04, 0x56, 0xD];
1786/// let result = decode_packed_decimal(&data, 3, 0, true)?;
1787/// assert_eq!(result.to_string(), "-456");
1788/// # Ok::<(), copybook_core::Error>(())
1789/// ```
1790///
1791/// ## Decimal Scale
1792///
1793/// ```no_run
1794/// use copybook_codec::numeric::{decode_packed_decimal};
1795///
1796/// // "12.34" with 2 decimal places: [0x12, 0x34, 0xC]
1797/// let data = [0x12, 0x34, 0xC];
1798/// let result = decode_packed_decimal(&data, 4, 2, true)?;
1799/// assert_eq!(result.to_string(), "12.34");
1800/// # Ok::<(), copybook_core::Error>(())
1801/// ```
1802///
1803/// ## Unsigned Field
1804///
1805/// ```no_run
1806/// use copybook_codec::numeric::{decode_packed_decimal};
1807///
1808/// // Unsigned "789": [0x07, 0x89, 0xF] (F = unsigned sign)
1809/// let data = [0x07, 0x89, 0xF];
1810/// let result = decode_packed_decimal(&data, 3, 0, false)?;
1811/// assert_eq!(result.to_string(), "789");
1812/// # Ok::<(), copybook_core::Error>(())
1813/// ```
1814///
1815/// ## Zero Value
1816///
1817/// ```no_run
1818/// use copybook_codec::numeric::{decode_packed_decimal};
1819///
1820/// // Zero: [0x00, 0x0C]
1821/// let data = [0x00, 0x0C];
1822/// let result = decode_packed_decimal(&data, 2, 0, true)?;
1823/// assert_eq!(result.to_string(), "0");
1824/// # Ok::<(), copybook_core::Error>(())
1825/// ```
1826///
1827/// # See Also
1828/// * [`encode_packed_decimal`] - For encoding packed decimals
1829/// * [`decode_packed_decimal_with_scratch`] - For zero-allocation decoding
1830/// * [`decode_packed_decimal_to_string_with_scratch`] - For direct string output
1831#[inline]
1832#[must_use = "Handle the Result or propagate the error"]
1833pub fn decode_packed_decimal(
1834    data: &[u8],
1835    digits: u16,
1836    scale: i16,
1837    signed: bool,
1838) -> Result<SmallDecimal> {
1839    // CRITICAL PERFORMANCE OPTIMIZATION: Ultra-fast path with minimal safety overhead
1840    let expected_bytes = usize::from((digits + 1).div_ceil(2));
1841    // PERFORMANCE CRITICAL: Single branch validation optimized for happy path
1842    if likely(data.len() == expected_bytes && !data.is_empty() && digits <= 18) {
1843        // ULTRA-FAST PATH: Most common enterprise cases with minimal validation
1844        return decode_packed_decimal_fast_path(data, digits, scale, signed);
1845    }
1846
1847    // FALLBACK PATH: Full validation for edge cases
1848    if data.len() != expected_bytes {
1849        return Err(Error::new(
1850            ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1851            "Packed decimal data length mismatch".to_string(),
1852        ));
1853    }
1854
1855    if data.is_empty() {
1856        return Ok(SmallDecimal::zero(scale));
1857    }
1858
1859    if digits > 18 {
1860        return Err(Error::new(
1861            ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1862            format!(
1863                "COMP-3 field with {digits} digits exceeds maximum supported precision (18 digits max for current implementation)"
1864            ),
1865        ));
1866    }
1867
1868    // Delegate to ultra-fast path
1869    decode_packed_decimal_fast_path(data, digits, scale, signed)
1870}
1871
1872/// Ultra-optimized COMP-3 decoder for hot path performance
1873///
1874/// This function is highly optimized for the 95% case of enterprise COBOL processing
1875/// where COMP-3 fields are 1-5 bytes and well-formed. It selects the appropriate
1876/// specialized decoder based on the data length.
1877#[inline]
1878fn decode_packed_decimal_fast_path(
1879    data: &[u8],
1880    digits: u16,
1881    scale: i16,
1882    signed: bool,
1883) -> Result<SmallDecimal> {
1884    match data.len() {
1885        1 => decode_packed_fast_len1(data[0], digits, scale, signed),
1886        2 => decode_packed_fast_len2(data, digits, scale, signed),
1887        3 => decode_packed_fast_len3(data, scale, signed),
1888        _ => decode_packed_fast_general(data, digits, scale, signed),
1889    }
1890}
1891
1892/// Specialized COMP-3 decoder for 1-byte fields (1 digit)
1893///
1894/// # Arguments
1895/// * `byte` - The single byte of packed decimal data
1896/// * `digits` - Number of digits (should be 1)
1897/// * `scale` - Decimal scale
1898/// * `signed` - Whether the field is signed
1899#[inline]
1900fn decode_packed_fast_len1(
1901    byte: u8,
1902    digits: u16,
1903    scale: i16,
1904    signed: bool,
1905) -> Result<SmallDecimal> {
1906    let high_nibble = (byte >> 4) & 0x0F;
1907    let low_nibble = byte & 0x0F;
1908    let mut value = 0i64;
1909
1910    if !digits.is_multiple_of(2) {
1911        if unlikely(high_nibble > 9) {
1912            return Err(Error::new(
1913                ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1914                "Invalid digit nibble in packed decimal".to_string(),
1915            ));
1916        }
1917        value = i64::from(high_nibble);
1918    }
1919
1920    if signed {
1921        let is_negative = match low_nibble {
1922            0xA | 0xC | 0xE | 0xF => false,
1923            0xB | 0xD => true,
1924            _ => {
1925                return Err(Error::new(
1926                    ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1927                    "Invalid sign nibble in packed decimal".to_string(),
1928                ));
1929            }
1930        };
1931        return Ok(create_normalized_decimal(value, scale, is_negative));
1932    }
1933
1934    if unlikely(low_nibble != 0xF) {
1935        return Err(Error::new(
1936            ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1937            "Invalid unsigned sign nibble, expected 0xF".to_string(),
1938        ));
1939    }
1940
1941    Ok(create_normalized_decimal(value, scale, false))
1942}
1943
1944/// Specialized COMP-3 decoder for 2-byte fields (2-3 digits)
1945///
1946/// # Arguments
1947/// * `data` - The 2 bytes of packed decimal data
1948/// * `digits` - Number of digits (2 or 3)
1949/// * `scale` - Decimal scale
1950/// * `signed` - Whether the field is signed
1951#[inline]
1952fn decode_packed_fast_len2(
1953    data: &[u8],
1954    digits: u16,
1955    scale: i16,
1956    signed: bool,
1957) -> Result<SmallDecimal> {
1958    let byte0 = data[0];
1959    let byte1 = data[1];
1960
1961    let d1 = (byte0 >> 4) & 0x0F;
1962    let d2 = byte0 & 0x0F;
1963    let d3 = (byte1 >> 4) & 0x0F;
1964    let sign_nibble = byte1 & 0x0F;
1965
1966    let value = if digits == 2 {
1967        if unlikely(d1 != 0) {
1968            return Err(Error::new(
1969                ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1970                format!("Expected padding nibble 0 for 2-digit field, got 0x{d1:X}"),
1971            ));
1972        }
1973
1974        if unlikely(d2 > 9 || d3 > 9) {
1975            return Err(Error::new(
1976                ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1977                "Invalid digit in 2-digit COMP-3 field".to_string(),
1978            ));
1979        }
1980
1981        i64::from(d2) * 10 + i64::from(d3)
1982    } else {
1983        if unlikely(d1 > 9 || d2 > 9 || d3 > 9) {
1984            return Err(Error::new(
1985                ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1986                "Invalid digit in 3-digit COMP-3 field".to_string(),
1987            ));
1988        }
1989
1990        i64::from(d1) * 100 + i64::from(d2) * 10 + i64::from(d3)
1991    };
1992
1993    let is_negative = if signed {
1994        match sign_nibble {
1995            0xA | 0xC | 0xE | 0xF => false,
1996            0xB | 0xD => true,
1997            _ => {
1998                return Err(Error::new(
1999                    ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2000                    "Invalid sign nibble in packed decimal".to_string(),
2001                ));
2002            }
2003        }
2004    } else {
2005        if unlikely(sign_nibble != 0xF) {
2006            return Err(Error::new(
2007                ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2008                "Invalid unsigned sign nibble, expected 0xF".to_string(),
2009            ));
2010        }
2011        false
2012    };
2013
2014    Ok(create_normalized_decimal(value, scale, is_negative))
2015}
2016
2017/// Specialized COMP-3 decoder for 3-byte fields (4-5 digits)
2018///
2019/// # Arguments
2020/// * `data` - The 3 bytes of packed decimal data
2021/// * `scale` - Decimal scale
2022/// * `signed` - Whether the field is signed
2023#[inline]
2024fn decode_packed_fast_len3(data: &[u8], scale: i16, signed: bool) -> Result<SmallDecimal> {
2025    let byte0 = data[0];
2026    let byte1 = data[1];
2027    let byte2 = data[2];
2028
2029    let d1 = (byte0 >> 4) & 0x0F;
2030    let d2 = byte0 & 0x0F;
2031    let d3 = (byte1 >> 4) & 0x0F;
2032    let d4 = byte1 & 0x0F;
2033    let d5 = (byte2 >> 4) & 0x0F;
2034    let sign_nibble = byte2 & 0x0F;
2035
2036    if unlikely(d1 > 9 || d2 > 9 || d3 > 9 || d4 > 9 || d5 > 9) {
2037        return Err(Error::new(
2038            ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2039            "Invalid digit in 3-byte COMP-3 field".to_string(),
2040        ));
2041    }
2042
2043    let value = i64::from(d1) * 10000
2044        + i64::from(d2) * 1000
2045        + i64::from(d3) * 100
2046        + i64::from(d4) * 10
2047        + i64::from(d5);
2048
2049    let is_negative = if signed {
2050        match sign_nibble {
2051            0xA | 0xC | 0xE | 0xF => false,
2052            0xB | 0xD => true,
2053            _ => {
2054                return Err(Error::new(
2055                    ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2056                    "Invalid sign nibble in packed decimal".to_string(),
2057                ));
2058            }
2059        }
2060    } else {
2061        if unlikely(sign_nibble != 0xF) {
2062            return Err(Error::new(
2063                ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2064                "Invalid unsigned sign nibble, expected 0xF".to_string(),
2065            ));
2066        }
2067        false
2068    };
2069
2070    Ok(create_normalized_decimal(value, scale, is_negative))
2071}
2072
2073/// General-purpose COMP-3 decoder for fields longer than 3 bytes
2074///
2075/// Handles multi-byte packed decimal decoding with support for padding
2076/// nibbles and variable digit counts.
2077///
2078/// # Arguments
2079/// * `data` - The packed decimal data bytes
2080/// * `digits` - Number of decimal digits in the field
2081/// * `scale` - Decimal scale
2082/// * `signed` - Whether the field is signed
2083#[inline]
2084fn decode_packed_fast_general(
2085    data: &[u8],
2086    digits: u16,
2087    scale: i16,
2088    signed: bool,
2089) -> Result<SmallDecimal> {
2090    let total_nibbles = digits + 1;
2091    let has_padding = (total_nibbles & 1) == 1;
2092    let digit_count = usize::from(digits);
2093
2094    let Some((last_byte, prefix_bytes)) = data.split_last() else {
2095        return Err(Error::new(
2096            ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2097            "Packed decimal data is empty".to_string(),
2098        ));
2099    };
2100    let mut value = 0i64;
2101    let mut digit_pos = 0;
2102
2103    for &byte in prefix_bytes {
2104        let high_nibble = (byte >> 4) & 0x0F;
2105        let low_nibble = byte & 0x0F;
2106
2107        if likely(!(digit_pos == 0 && has_padding)) {
2108            if unlikely(high_nibble > 9) {
2109                return Err(Error::new(
2110                    ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2111                    "Invalid digit nibble".to_string(),
2112                ));
2113            }
2114            value = value * 10 + i64::from(high_nibble);
2115            digit_pos += 1;
2116        }
2117
2118        if unlikely(low_nibble > 9) {
2119            return Err(Error::new(
2120                ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2121                "Invalid digit nibble".to_string(),
2122            ));
2123        }
2124        value = value * 10 + i64::from(low_nibble);
2125        digit_pos += 1;
2126    }
2127
2128    let last_high = (*last_byte >> 4) & 0x0F;
2129    let sign_nibble = *last_byte & 0x0F;
2130
2131    if likely(digit_pos < digit_count) {
2132        if unlikely(last_high > 9) {
2133            return Err(Error::new(
2134                ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2135                "Invalid digit nibble".to_string(),
2136            ));
2137        }
2138        value = value * 10 + i64::from(last_high);
2139    }
2140
2141    let is_negative = if signed {
2142        match sign_nibble {
2143            0xA | 0xC | 0xE | 0xF => false,
2144            0xB | 0xD => true,
2145            _ => {
2146                return Err(Error::new(
2147                    ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2148                    "Invalid sign nibble".to_string(),
2149                ));
2150            }
2151        }
2152    } else {
2153        if unlikely(sign_nibble != 0xF) {
2154            return Err(Error::new(
2155                ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2156                "Invalid unsigned sign nibble".to_string(),
2157            ));
2158        }
2159        false
2160    };
2161
2162    Ok(create_normalized_decimal(value, scale, is_negative))
2163}
2164
2165/// Decode binary integer field (COMP-4, COMP-5, BINARY)
2166///
2167/// Decodes big-endian binary integer fields. Supports 16-bit, 32-bit, and 64-bit
2168/// widths. All binary integers use big-endian byte order as per COBOL specification.
2169///
2170/// # Arguments
2171/// * `data` - Raw byte data containing the binary integer
2172/// * `bits` - Bit width of the field (16, 32, or 64)
2173/// * `signed` - Whether the field is signed (true) or unsigned (false)
2174///
2175/// # Returns
2176/// The decoded integer value as `i64`
2177///
2178/// # Errors
2179/// Returns an error if the binary data is invalid or the field size is unsupported.
2180///
2181/// # Examples
2182///
2183/// ## 16-bit Signed Integer
2184///
2185/// ```no_run
2186/// use copybook_codec::numeric::{decode_binary_int};
2187///
2188/// // 16-bit signed: -12345 = [0xCF, 0xC7] (big-endian)
2189/// let data = [0xCF, 0xC7];
2190/// let result = decode_binary_int(&data, 16, true)?;
2191/// assert_eq!(result, -12345);
2192/// # Ok::<(), copybook_core::Error>(())
2193/// ```
2194///
2195/// ## 16-bit Unsigned Integer
2196///
2197/// ```no_run
2198/// use copybook_codec::numeric::{decode_binary_int};
2199///
2200/// // 16-bit unsigned: 54321 = [0xD4, 0x31]
2201/// let data = [0xD4, 0x31];
2202/// let result = decode_binary_int(&data, 16, false)?;
2203/// assert_eq!(result, 54321);
2204/// # Ok::<(), copybook_core::Error>(())
2205/// ```
2206///
2207/// ## 32-bit Signed Integer
2208///
2209/// ```no_run
2210/// use copybook_codec::numeric::{decode_binary_int};
2211///
2212/// // 32-bit signed: -987654321 = [0xC5, 0x7D, 0x3C, 0x21]
2213/// let data = [0xC5, 0x7D, 0x3C, 0x21];
2214/// let result = decode_binary_int(&data, 32, true)?;
2215/// assert_eq!(result, -987654321);
2216/// # Ok::<(), copybook_core::Error>(())
2217/// ```
2218///
2219/// ## 64-bit Signed Integer
2220///
2221/// ```no_run
2222/// use copybook_codec::numeric::{decode_binary_int};
2223///
2224/// // 64-bit signed: 9223372036854775807 = [0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07]
2225/// let data = [0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07];
2226/// let result = decode_binary_int(&data, 64, true)?;
2227/// assert_eq!(result, 9223372036854775807);
2228/// # Ok::<(), copybook_core::Error>(())
2229/// ```
2230///
2231/// # See Also
2232/// * [`encode_binary_int`] - For encoding binary integers
2233/// * [`get_binary_width_from_digits`] - For mapping digit count to width
2234/// * [`validate_explicit_binary_width`] - For validating explicit BINARY(n) widths
2235#[inline]
2236#[must_use = "Handle the Result or propagate the error"]
2237pub fn decode_binary_int(data: &[u8], bits: u16, signed: bool) -> Result<i64> {
2238    let expected_bytes = usize::from(bits / 8);
2239    if data.len() != expected_bytes {
2240        return Err(Error::new(
2241            ErrorCode::CBKD401_COMP3_INVALID_NIBBLE, // Reusing error code for binary validation
2242            format!(
2243                "Binary data length {} doesn't match expected {} bytes for {} bits",
2244                data.len(),
2245                expected_bytes,
2246                bits
2247            ),
2248        ));
2249    }
2250
2251    match bits {
2252        16 => {
2253            if data.len() != 2 {
2254                return Err(Error::new(
2255                    ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2256                    "16-bit binary field requires exactly 2 bytes",
2257                ));
2258            }
2259            let value = u16::from_be_bytes([data[0], data[1]]);
2260            if signed {
2261                Ok(i64::from(i16::from_be_bytes([data[0], data[1]])))
2262            } else {
2263                Ok(i64::from(value))
2264            }
2265        }
2266        32 => {
2267            if data.len() != 4 {
2268                return Err(Error::new(
2269                    ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2270                    "32-bit binary field requires exactly 4 bytes",
2271                ));
2272            }
2273            let value = u32::from_be_bytes([data[0], data[1], data[2], data[3]]);
2274            if signed {
2275                Ok(i64::from(i32::from_be_bytes([
2276                    data[0], data[1], data[2], data[3],
2277                ])))
2278            } else {
2279                Ok(i64::from(value))
2280            }
2281        }
2282        64 => {
2283            if data.len() != 8 {
2284                return Err(Error::new(
2285                    ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2286                    "64-bit binary field requires exactly 8 bytes",
2287                ));
2288            }
2289            let bytes: [u8; 8] = data.try_into().map_err(|_| {
2290                Error::new(
2291                    ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2292                    "Failed to convert data to 8-byte array",
2293                )
2294            })?;
2295            if signed {
2296                Ok(i64::from_be_bytes(bytes))
2297            } else {
2298                // For unsigned 64-bit, we need to be careful about overflow
2299                let value = u64::from_be_bytes(bytes);
2300                let max_i64 = u64::try_from(i64::MAX).unwrap_or(u64::MAX);
2301                if value > max_i64 {
2302                    return Err(Error::new(
2303                        ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2304                        format!("Unsigned 64-bit value {value} exceeds i64::MAX"),
2305                    ));
2306                }
2307                i64::try_from(value).map_err(|_| {
2308                    Error::new(
2309                        ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2310                        format!("Unsigned 64-bit value {value} exceeds i64::MAX"),
2311                    )
2312                })
2313            }
2314        }
2315        _ => Err(Error::new(
2316            ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
2317            format!("Unsupported binary field width: {bits} bits"),
2318        )),
2319    }
2320}
2321
2322/// Encode a zoned decimal using the configured code page defaults.
2323///
2324/// Encodes decimal values to zoned decimal format (PIC 9) where each digit is stored
2325/// in a byte with a zone nibble and a digit nibble. For signed fields, the
2326/// last byte uses overpunch encoding for the sign.
2327///
2328/// # Arguments
2329/// * `value` - String representation of the decimal value to encode
2330/// * `digits` - Number of digit characters (field length)
2331/// * `scale` - Number of decimal places (can be negative for scaling)
2332/// * `signed` - Whether the field is signed (true) or unsigned (false)
2333/// * `codepage` - Character encoding (ASCII or EBCDIC variant)
2334///
2335/// # Returns
2336/// A vector of bytes containing the encoded zoned decimal
2337///
2338/// # Policy
2339/// Applies `ZeroSignPolicy::Positive` for ASCII and `ZeroSignPolicy::Preferred` for EBCDIC when no overrides are provided.
2340///
2341/// # Errors
2342/// Returns an error if the value cannot be encoded as a zoned decimal with the specified parameters.
2343///
2344/// # Examples
2345///
2346/// ## Basic ASCII Encoding
2347///
2348/// ```no_run
2349/// use copybook_codec::numeric::{encode_zoned_decimal};
2350/// use copybook_codec::options::Codepage;
2351///
2352/// // Encode "123" as ASCII zoned decimal
2353/// let encoded = encode_zoned_decimal("123", 3, 0, false, Codepage::ASCII)?;
2354/// assert_eq!(encoded, b"123"); // [0x31, 0x32, 0x33]
2355/// # Ok::<(), copybook_core::Error>(())
2356/// ```
2357///
2358/// ## Signed ASCII Encoding (Overpunch)
2359///
2360/// ```no_run
2361/// use copybook_codec::numeric::{encode_zoned_decimal};
2362/// use copybook_codec::options::Codepage;
2363///
2364/// // Encode "-456" with overpunch sign
2365/// let encoded = encode_zoned_decimal("-456", 3, 0, true, Codepage::ASCII)?;
2366/// // Last byte 0x4D = 'M' = digit 3 with negative sign
2367/// assert_eq!(encoded, [0x34, 0x35, 0x4D]);
2368/// # Ok::<(), copybook_core::Error>(())
2369/// ```
2370///
2371/// ## EBCDIC Encoding
2372///
2373/// ```no_run
2374/// use copybook_codec::numeric::{encode_zoned_decimal};
2375/// use copybook_codec::options::Codepage;
2376///
2377/// // Encode "789" as EBCDIC zoned decimal
2378/// let encoded = encode_zoned_decimal("789", 3, 0, false, Codepage::CP037)?;
2379/// assert_eq!(encoded, [0xF7, 0xF8, 0xF9]);
2380/// # Ok::<(), copybook_core::Error>(())
2381/// ```
2382///
2383/// ## Decimal Scale
2384///
2385/// ```no_run
2386/// use copybook_codec::numeric::{encode_zoned_decimal};
2387/// use copybook_codec::options::Codepage;
2388///
2389/// // Encode "12.34" with 2 decimal places
2390/// let encoded = encode_zoned_decimal("12.34", 4, 2, false, Codepage::ASCII)?;
2391/// assert_eq!(encoded, b"1234"); // [0x31, 0x32, 0x33, 0x34]
2392/// # Ok::<(), copybook_core::Error>(())
2393/// ```
2394///
2395/// # See Also
2396/// * [`encode_zoned_decimal_with_format`] - For encoding with explicit format
2397/// * [`encode_zoned_decimal_with_format_and_policy`] - For encoding with format and policy
2398/// * [`encode_zoned_decimal_with_bwz`] - For encoding with BLANK WHEN ZERO support
2399/// * [`decode_zoned_decimal`] - For decoding zoned decimals
2400#[inline]
2401#[must_use = "Handle the Result or propagate the error"]
2402pub fn encode_zoned_decimal(
2403    value: &str,
2404    digits: u16,
2405    scale: i16,
2406    signed: bool,
2407    codepage: Codepage,
2408) -> Result<Vec<u8>> {
2409    let zero_policy = if codepage.is_ascii() {
2410        ZeroSignPolicy::Positive
2411    } else {
2412        ZeroSignPolicy::Preferred
2413    };
2414
2415    encode_zoned_decimal_with_format_and_policy(
2416        value,
2417        digits,
2418        scale,
2419        signed,
2420        codepage,
2421        None,
2422        zero_policy,
2423    )
2424}
2425
2426/// Encode a zoned decimal using an explicit encoding override when supplied.
2427///
2428/// Encodes zoned decimal values with an explicit encoding format (ASCII or EBCDIC).
2429/// When `encoding_override` is provided, it takes precedence over the codepage default.
2430/// When `Auto` is specified, the codepage default is used.
2431///
2432/// # Arguments
2433/// * `value` - String representation of the decimal value to encode
2434/// * `digits` - Number of digit characters (field length)
2435/// * `scale` - Number of decimal places (can be negative for scaling)
2436/// * `signed` - Whether the field is signed (true) or unsigned (false)
2437/// * `codepage` - Character encoding (ASCII or EBCDIC variant)
2438/// * `encoding_override` - Optional explicit encoding format (ASCII/EBCDIC/Auto)
2439///
2440/// # Returns
2441/// A vector of bytes containing the encoded zoned decimal
2442///
2443/// # Policy
2444/// Resolves `ZeroSignPolicy` from `encoding_override` first; when unset or `Auto`, falls back to the code page defaults.
2445///
2446/// # Errors
2447/// Returns an error if the value cannot be encoded as a zoned decimal with the specified parameters.
2448///
2449/// # Examples
2450///
2451/// ## ASCII Encoding (Explicit)
2452///
2453/// ```no_run
2454/// use copybook_codec::numeric::{encode_zoned_decimal_with_format};
2455/// use copybook_codec::options::Codepage;
2456/// use copybook_codec::options::ZonedEncodingFormat;
2457///
2458/// // Encode "123" with explicit ASCII encoding
2459/// let encoded = encode_zoned_decimal_with_format(
2460///     "123", 3, 0, false, Codepage::ASCII, Some(ZonedEncodingFormat::Ascii)
2461/// )?;
2462/// assert_eq!(encoded, b"123");
2463/// # Ok::<(), copybook_core::Error>(())
2464/// ```
2465///
2466/// ## EBCDIC Encoding (Explicit)
2467///
2468/// ```no_run
2469/// use copybook_codec::numeric::{encode_zoned_decimal_with_format};
2470/// use copybook_codec::options::Codepage;
2471/// use copybook_codec::options::ZonedEncodingFormat;
2472///
2473/// // Encode "789" with explicit EBCDIC encoding
2474/// let encoded = encode_zoned_decimal_with_format(
2475///     "789", 3, 0, false, Codepage::CP037, Some(ZonedEncodingFormat::Ebcdic)
2476/// )?;
2477/// assert_eq!(encoded, [0xF7, 0xF8, 0xF9]);
2478/// # Ok::<(), copybook_core::Error>(())
2479/// ```
2480///
2481/// ## Auto Encoding (Codepage Default)
2482///
2483/// ```no_run
2484/// use copybook_codec::numeric::{encode_zoned_decimal_with_format};
2485/// use copybook_codec::options::Codepage;
2486/// use copybook_codec::options::ZonedEncodingFormat;
2487///
2488/// // Encode "456" with Auto encoding (uses EBCDIC default for CP037)
2489/// let encoded = encode_zoned_decimal_with_format(
2490///     "456", 3, 0, false, Codepage::CP037, Some(ZonedEncodingFormat::Auto)
2491/// )?;
2492/// assert_eq!(encoded, [0xF4, 0xF5, 0xF6]);
2493/// # Ok::<(), copybook_core::Error>(())
2494/// ```
2495///
2496/// # See Also
2497/// * [`encode_zoned_decimal`] - For encoding with codepage defaults
2498/// * [`encode_zoned_decimal_with_format_and_policy`] - For encoding with format and policy
2499#[inline]
2500#[must_use = "Handle the Result or propagate the error"]
2501pub fn encode_zoned_decimal_with_format(
2502    value: &str,
2503    digits: u16,
2504    scale: i16,
2505    signed: bool,
2506    codepage: Codepage,
2507    encoding_override: Option<ZonedEncodingFormat>,
2508) -> Result<Vec<u8>> {
2509    let zero_policy = match encoding_override {
2510        Some(ZonedEncodingFormat::Ascii) => ZeroSignPolicy::Positive,
2511        Some(ZonedEncodingFormat::Ebcdic) => ZeroSignPolicy::Preferred,
2512        Some(ZonedEncodingFormat::Auto) | None => {
2513            if codepage.is_ascii() {
2514                ZeroSignPolicy::Positive
2515            } else {
2516                ZeroSignPolicy::Preferred
2517            }
2518        }
2519    };
2520
2521    encode_zoned_decimal_with_format_and_policy(
2522        value,
2523        digits,
2524        scale,
2525        signed,
2526        codepage,
2527        encoding_override,
2528        zero_policy,
2529    )
2530}
2531
2532/// Encode a zoned decimal using a caller-resolved format and zero-sign policy.
2533///
2534/// This is the lowest-level zoned decimal encoder. The caller supplies both the
2535/// encoding format override (ASCII vs EBCDIC) and the zero-sign policy, which
2536/// together govern how the sign nibble of the last byte is produced.  Higher-level
2537/// wrappers such as [`encode_zoned_decimal`] and [`encode_zoned_decimal_with_format`]
2538/// resolve these parameters from codec defaults and then delegate here.
2539///
2540/// # Arguments
2541/// * `value` - String representation of the decimal value to encode (e.g. `"123"`, `"-45.67"`)
2542/// * `digits` - Number of digit positions in the COBOL field (PIC digit count)
2543/// * `scale` - Number of implied decimal places (can be negative for scaling)
2544/// * `signed` - Whether the field carries a sign (PIC S9 vs PIC 9)
2545/// * `codepage` - Target character encoding (ASCII or EBCDIC variant)
2546/// * `encoding_override` - Explicit format override; `None` falls back to codepage default
2547/// * `zero_policy` - How the sign nibble is encoded for zero values
2548///
2549/// # Returns
2550/// A vector of bytes containing the encoded zoned decimal in the target encoding.
2551///
2552/// # Policy
2553/// Callers provide the resolved policy in precedence order:
2554/// override → preserved metadata → preferred for the target code page.
2555///
2556/// # Errors
2557/// * `CBKE510_NUMERIC_OVERFLOW` - if the value is too large for the digit count
2558/// * `CBKE501_JSON_TYPE_MISMATCH` - if the input contains non-digit characters
2559///
2560/// # See Also
2561/// * [`encode_zoned_decimal`] - Convenience wrapper that resolves policy from the codepage
2562/// * [`encode_zoned_decimal_with_format`] - Accepts a format override without an explicit policy
2563/// * [`encode_zoned_decimal_with_bwz`] - Adds BLANK WHEN ZERO support
2564#[inline]
2565#[must_use = "Handle the Result or propagate the error"]
2566pub fn encode_zoned_decimal_with_format_and_policy(
2567    value: &str,
2568    digits: u16,
2569    scale: i16,
2570    signed: bool,
2571    codepage: Codepage,
2572    encoding_override: Option<ZonedEncodingFormat>,
2573    zero_policy: ZeroSignPolicy,
2574) -> Result<Vec<u8>> {
2575    // Parse the input value with scale validation (NORMATIVE)
2576    let decimal = SmallDecimal::from_str(value, scale)?;
2577
2578    // Convert to string representation of digits
2579    let abs_value = decimal.value.abs();
2580    let width = usize::from(digits);
2581    let digit_str = format!("{abs_value:0width$}");
2582
2583    if digit_str.len() > width {
2584        return Err(Error::new(
2585            ErrorCode::CBKE510_NUMERIC_OVERFLOW,
2586            format!("Value too large for {digits} digits"),
2587        ));
2588    }
2589
2590    // Determine the encoding format to use
2591    // Precedence: explicit override > codepage default
2592    let mut target_format = encoding_override.unwrap_or(match codepage {
2593        Codepage::ASCII => ZonedEncodingFormat::Ascii,
2594        _ => ZonedEncodingFormat::Ebcdic,
2595    });
2596    if target_format == ZonedEncodingFormat::Auto {
2597        target_format = if codepage.is_ascii() {
2598            ZonedEncodingFormat::Ascii
2599        } else {
2600            ZonedEncodingFormat::Ebcdic
2601        };
2602    }
2603
2604    let mut result = Vec::with_capacity(width);
2605    let digit_bytes = digit_str.as_bytes();
2606
2607    // Encode each digit
2608    for (i, &ascii_digit) in digit_bytes.iter().enumerate() {
2609        let digit = ascii_digit - b'0';
2610        if digit > 9 {
2611            return Err(Error::new(
2612                ErrorCode::CBKE501_JSON_TYPE_MISMATCH,
2613                format!("Invalid digit character: {}", ascii_digit as char),
2614            ));
2615        }
2616
2617        if i == digit_bytes.len() - 1 && signed {
2618            if target_format == ZonedEncodingFormat::Ascii {
2619                let overpunch_byte = encode_overpunch_byte(
2620                    digit,
2621                    decimal.negative,
2622                    Codepage::ASCII,
2623                    ZeroSignPolicy::Positive,
2624                )?;
2625                result.push(overpunch_byte);
2626            } else {
2627                let encode_codepage = if codepage == Codepage::ASCII {
2628                    Codepage::CP037
2629                } else {
2630                    codepage
2631                };
2632                let overpunch_byte =
2633                    encode_overpunch_byte(digit, decimal.negative, encode_codepage, zero_policy)?;
2634                result.push(overpunch_byte);
2635            }
2636        } else {
2637            let zone = match target_format {
2638                ZonedEncodingFormat::Ascii => ASCII_DIGIT_ZONE,
2639                _ => EBCDIC_DIGIT_ZONE,
2640            };
2641            result.push((zone << 4) | digit);
2642        }
2643    }
2644
2645    Ok(result)
2646}
2647
2648/// Encode packed decimal (COMP-3) field
2649///
2650/// Encodes decimal values to COMP-3 packed decimal format where each byte contains
2651/// two decimal digits (nibbles), with the last nibble containing the sign.
2652/// This function is optimized for high-throughput enterprise data processing.
2653///
2654/// # Arguments
2655/// * `value` - String representation of the decimal value to encode
2656/// * `digits` - Number of decimal digits in the field (1-18 supported)
2657/// * `scale` - Number of decimal places (can be negative for scaling)
2658/// * `signed` - Whether the field is signed (true) or unsigned (false)
2659///
2660/// # Returns
2661/// A vector of bytes containing the encoded packed decimal
2662///
2663/// # Errors
2664/// Returns an error if the value cannot be encoded as a packed decimal with the specified parameters.
2665///
2666/// # Performance
2667/// This function uses optimized digit extraction to avoid `format!()` allocation overhead.
2668///
2669/// # Examples
2670///
2671/// ## Basic Positive Value
2672///
2673/// ```no_run
2674/// use copybook_codec::numeric::{encode_packed_decimal};
2675///
2676/// // Encode "123" as COMP-3: [0x12, 0x3C] (12 positive, 3C = positive sign)
2677/// let encoded = encode_packed_decimal("123", 3, 0, true)?;
2678/// assert_eq!(encoded, [0x12, 0x3C]);
2679/// # Ok::<(), copybook_core::Error>(())
2680/// ```
2681///
2682/// ## Negative Value
2683///
2684/// ```no_run
2685/// use copybook_codec::numeric::{encode_packed_decimal};
2686///
2687/// // Encode "-456" as COMP-3: [0x04, 0x56, 0xD] (456 negative)
2688/// let encoded = encode_packed_decimal("-456", 3, 0, true)?;
2689/// assert_eq!(encoded, [0x04, 0x56, 0xD]);
2690/// # Ok::<(), copybook_core::Error>(())
2691/// ```
2692///
2693/// ## Decimal Scale
2694///
2695/// ```no_run
2696/// use copybook_codec::numeric::{encode_packed_decimal};
2697///
2698/// // Encode "12.34" with 2 decimal places: [0x12, 0x34, 0xC]
2699/// let encoded = encode_packed_decimal("12.34", 4, 2, true)?;
2700/// assert_eq!(encoded, [0x12, 0x34, 0xC]);
2701/// # Ok::<(), copybook_core::Error>(())
2702/// ```
2703///
2704/// ## Unsigned Field
2705///
2706/// ```no_run
2707/// use copybook_codec::numeric::{encode_packed_decimal};
2708///
2709/// // Unsigned "789": [0x07, 0x89, 0xF] (F = unsigned sign)
2710/// let encoded = encode_packed_decimal("789", 3, 0, false)?;
2711/// assert_eq!(encoded, [0x07, 0x89, 0xF]);
2712/// # Ok::<(), copybook_core::Error>(())
2713/// ```
2714///
2715/// ## Zero Value
2716///
2717/// ```no_run
2718/// use copybook_codec::numeric::{encode_packed_decimal};
2719///
2720/// // Zero: [0x00, 0x0C]
2721/// let encoded = encode_packed_decimal("0", 2, 0, true)?;
2722/// assert_eq!(encoded, [0x00, 0x0C]);
2723/// # Ok::<(), copybook_core::Error>(())
2724/// ```
2725///
2726/// # See Also
2727/// * [`decode_packed_decimal`] - For decoding packed decimals
2728/// * [`encode_packed_decimal_with_scratch`] - For zero-allocation encoding
2729#[inline]
2730#[must_use = "Handle the Result or propagate the error"]
2731pub fn encode_packed_decimal(
2732    value: &str,
2733    digits: u16,
2734    scale: i16,
2735    signed: bool,
2736) -> Result<Vec<u8>> {
2737    // Parse the input value with scale validation (NORMATIVE)
2738    let decimal = SmallDecimal::from_str(value, scale)?;
2739
2740    // CRITICAL PERFORMANCE OPTIMIZATION: Avoid format!() allocation
2741    // Direct integer-to-digits conversion for massive speedup
2742    let abs_value = decimal.value.abs();
2743
2744    // Fast path for zero
2745    if abs_value == 0 {
2746        let expected_bytes = usize::from((digits + 1).div_ceil(2));
2747        let mut result = vec![0u8; expected_bytes];
2748        // Set sign in last byte
2749        let sign_nibble = if signed {
2750            if decimal.negative { 0x0D } else { 0x0C }
2751        } else {
2752            0x0F
2753        };
2754        result[expected_bytes - 1] = sign_nibble;
2755        return Ok(result);
2756    }
2757
2758    // Pre-allocate digit buffer on stack for speed (up to 18 digits for i64::MAX)
2759    let mut digit_buffer: [u8; 20] = [0; 20];
2760    let mut digit_count = 0;
2761    let mut temp_value = abs_value;
2762
2763    // Extract digits in reverse order using fast division
2764    while temp_value > 0 {
2765        digit_buffer[digit_count] = digit_from_value(temp_value % 10);
2766        temp_value /= 10;
2767        digit_count += 1;
2768    }
2769
2770    // Validate digit count
2771    let digits_usize = usize::from(digits);
2772    if unlikely(digit_count > digits_usize) {
2773        return Err(Error::new(
2774            ErrorCode::CBKE510_NUMERIC_OVERFLOW,
2775            format!("Value too large for {digits} digits"),
2776        ));
2777    }
2778
2779    let expected_bytes = usize::from((digits + 1).div_ceil(2));
2780    let mut result = Vec::with_capacity(expected_bytes);
2781
2782    // CRITICAL FIX: Handle digit positioning correctly for even/odd digit counts
2783    // For packed decimal, we have:
2784    // - Total nibbles needed: digits + 1 (for sign)
2785    // - If digits is even: first nibble is padding (0), then digits, then sign
2786    // - If digits is odd: no padding, digits fill completely, then sign
2787
2788    let has_padding = digits.is_multiple_of(2); // Even digit count requires padding
2789    let total_nibbles = digits_usize + 1 + usize::from(has_padding);
2790
2791    for byte_idx in 0..expected_bytes {
2792        let mut byte_val = 0u8;
2793
2794        // Calculate which nibbles belong to this byte
2795        let nibble_offset = byte_idx * 2;
2796
2797        // High nibble
2798        let high_nibble_idx = nibble_offset;
2799        if high_nibble_idx < total_nibbles - 1 {
2800            // Not the sign nibble
2801            if has_padding && high_nibble_idx == 0 {
2802                // First nibble is padding for even digit count
2803                byte_val |= 0x00 << 4;
2804            } else {
2805                // Calculate which digit this represents
2806                let digit_idx = if has_padding {
2807                    high_nibble_idx - 1
2808                } else {
2809                    high_nibble_idx
2810                };
2811
2812                // CRITICAL FIX: Right-align digits in COMP-3 field (leading zeros, not trailing)
2813                // For field width of 'digits', actual digits should occupy the rightmost positions
2814                if digit_idx >= (digits_usize - digit_count) {
2815                    // This position should contain an actual digit
2816                    let actual_digit_idx = digit_idx - (digits_usize - digit_count);
2817                    if actual_digit_idx < digit_count {
2818                        // Digits are stored in reverse order (least significant first)
2819                        let digit_pos_from_right = digit_count - 1 - actual_digit_idx;
2820                        let digit = digit_buffer[digit_pos_from_right];
2821                        byte_val |= digit << 4;
2822                    }
2823                }
2824                // else: leading zero for large digit field (byte_val already initialized to 0)
2825            }
2826        }
2827
2828        // Low nibble
2829        let low_nibble_idx = nibble_offset + 1;
2830        if low_nibble_idx == total_nibbles - 1 {
2831            // This is the sign nibble
2832            byte_val |= if signed {
2833                if decimal.negative { 0x0D } else { 0x0C }
2834            } else {
2835                0x0F
2836            };
2837        } else if low_nibble_idx < total_nibbles - 1 {
2838            // Calculate which digit this represents
2839            let digit_idx = if has_padding {
2840                low_nibble_idx - 1
2841            } else {
2842                low_nibble_idx
2843            };
2844
2845            // CRITICAL FIX: Right-align digits in COMP-3 field (leading zeros, not trailing)
2846            // For field width of 'digits', actual digits should occupy the rightmost positions
2847            if digit_idx >= (digits_usize - digit_count) {
2848                // This position should contain an actual digit
2849                let actual_digit_idx = digit_idx - (digits_usize - digit_count);
2850                if actual_digit_idx < digit_count {
2851                    // Digits are stored in reverse order (least significant first)
2852                    let digit_pos_from_right = digit_count - 1 - actual_digit_idx;
2853                    let digit = digit_buffer[digit_pos_from_right];
2854                    byte_val |= digit;
2855                }
2856            }
2857            // else: leading zero for large digit field (byte_val already initialized to 0)
2858        }
2859
2860        result.push(byte_val);
2861    }
2862
2863    Ok(result)
2864}
2865
2866/// Encode binary integer field (COMP-4, COMP-5, BINARY)
2867///
2868/// Encodes integer values to big-endian binary format. Supports 16-bit, 32-bit, and 64-bit
2869/// widths. All binary integers use big-endian byte order as per COBOL specification.
2870///
2871/// # Arguments
2872/// * `value` - Integer value to encode
2873/// * `bits` - Bit width of field (16, 32, or 64)
2874/// * `signed` - Whether the field is signed (true) or unsigned (false)
2875///
2876/// # Returns
2877/// A vector of bytes containing the encoded binary integer
2878///
2879/// # Errors
2880/// Returns an error if the value is out of range for the specified bit width.
2881///
2882/// # Examples
2883///
2884/// ## 16-bit Signed Integer
2885///
2886/// ```no_run
2887/// use copybook_codec::numeric::{encode_binary_int};
2888///
2889/// // Encode -12345 as 16-bit signed
2890/// let encoded = encode_binary_int(-12345, 16, true)?;
2891/// assert_eq!(encoded, [0xCF, 0xC7]); // Big-endian
2892/// # Ok::<(), copybook_core::Error>(())
2893/// ```
2894///
2895/// ## 16-bit Unsigned Integer
2896///
2897/// ```no_run
2898/// use copybook_codec::numeric::{encode_binary_int};
2899///
2900/// // Encode 54321 as 16-bit unsigned
2901/// let encoded = encode_binary_int(54321, 16, false)?;
2902/// assert_eq!(encoded, [0xD4, 0x31]);
2903/// # Ok::<(), copybook_core::Error>(())
2904/// ```
2905///
2906/// ## 32-bit Signed Integer
2907///
2908/// ```no_run
2909/// use copybook_codec::numeric::{encode_binary_int};
2910///
2911/// // Encode -987654321 as 32-bit signed
2912/// let encoded = encode_binary_int(-987654321, 32, true)?;
2913/// assert_eq!(encoded, [0xC5, 0x7D, 0x3C, 0x21]);
2914/// # Ok::<(), copybook_core::Error>(())
2915/// ```
2916///
2917/// ## 64-bit Signed Integer
2918///
2919/// ```no_run
2920/// use copybook_codec::numeric::{encode_binary_int};
2921///
2922/// // Encode 9223372036854775807 as 64-bit signed
2923/// let encoded = encode_binary_int(9223372036854775807, 64, true)?;
2924/// assert_eq!(encoded, [0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07]);
2925/// # Ok::<(), copybook_core::Error>(())
2926/// ```
2927///
2928/// # See Also
2929/// * [`decode_binary_int`] - For decoding binary integers
2930/// * [`get_binary_width_from_digits`] - For mapping digit count to width
2931/// * [`validate_explicit_binary_width`] - For validating explicit BINARY(n) widths
2932#[inline]
2933#[must_use = "Handle the Result or propagate the error"]
2934pub fn encode_binary_int(value: i64, bits: u16, signed: bool) -> Result<Vec<u8>> {
2935    match bits {
2936        16 => {
2937            if signed {
2938                let int_value = i16::try_from(value).map_err(|_| {
2939                    Error::new(
2940                        ErrorCode::CBKE501_JSON_TYPE_MISMATCH,
2941                        format!("Value {value} out of range for signed 16-bit integer"),
2942                    )
2943                })?;
2944                Ok(int_value.to_be_bytes().to_vec())
2945            } else {
2946                let int_value = u16::try_from(value).map_err(|_| {
2947                    Error::new(
2948                        ErrorCode::CBKE501_JSON_TYPE_MISMATCH,
2949                        format!("Value {value} out of range for unsigned 16-bit integer"),
2950                    )
2951                })?;
2952                Ok(int_value.to_be_bytes().to_vec())
2953            }
2954        }
2955        32 => {
2956            if signed {
2957                let int_value = i32::try_from(value).map_err(|_| {
2958                    Error::new(
2959                        ErrorCode::CBKE501_JSON_TYPE_MISMATCH,
2960                        format!("Value {value} out of range for signed 32-bit integer"),
2961                    )
2962                })?;
2963                Ok(int_value.to_be_bytes().to_vec())
2964            } else {
2965                let int_value = u32::try_from(value).map_err(|_| {
2966                    Error::new(
2967                        ErrorCode::CBKE501_JSON_TYPE_MISMATCH,
2968                        format!("Value {value} out of range for unsigned 32-bit integer"),
2969                    )
2970                })?;
2971                Ok(int_value.to_be_bytes().to_vec())
2972            }
2973        }
2974        64 => {
2975            if signed {
2976                Ok(value.to_be_bytes().to_vec())
2977            } else {
2978                let int_value = u64::try_from(value).map_err(|_| {
2979                    Error::new(
2980                        ErrorCode::CBKE501_JSON_TYPE_MISMATCH,
2981                        format!("Value {value} cannot be negative for unsigned 64-bit integer"),
2982                    )
2983                })?;
2984                Ok(int_value.to_be_bytes().to_vec())
2985            }
2986        }
2987        _ => Err(Error::new(
2988            ErrorCode::CBKE501_JSON_TYPE_MISMATCH,
2989            format!("Unsupported binary field width: {bits} bits"),
2990        )),
2991    }
2992}
2993
2994/// Encode an alphanumeric (PIC X) field with right-padding to the declared length.
2995///
2996/// Converts the UTF-8 input string to the target codepage encoding and then
2997/// right-pads with space characters (ASCII `0x20` or EBCDIC `0x40`) to fill
2998/// the declared field length.
2999///
3000/// # Arguments
3001/// * `text` - UTF-8 string value to encode
3002/// * `field_len` - Declared byte length of the COBOL field
3003/// * `codepage` - Target character encoding (ASCII or EBCDIC variant)
3004///
3005/// # Returns
3006/// A vector of exactly `field_len` bytes containing the encoded and padded text.
3007///
3008/// # Errors
3009/// * `CBKE501_JSON_TYPE_MISMATCH` - if the encoded text exceeds `field_len` bytes
3010///
3011/// # Examples
3012///
3013/// ```
3014/// use copybook_codec::numeric::encode_alphanumeric;
3015/// use copybook_codec::Codepage;
3016///
3017/// // Encode a 5-character string into a 10-byte ASCII field (right-padded with spaces)
3018/// let result = encode_alphanumeric("HELLO", 10, Codepage::ASCII).unwrap();
3019/// assert_eq!(result.len(), 10);
3020/// assert_eq!(&result[..5], b"HELLO");
3021/// assert_eq!(&result[5..], b"     "); // padded with spaces
3022/// ```
3023///
3024/// # See Also
3025/// * [`crate::charset::utf8_to_ebcdic`] - Underlying character conversion
3026#[inline]
3027#[must_use = "Handle the Result or propagate the error"]
3028pub fn encode_alphanumeric(text: &str, field_len: usize, codepage: Codepage) -> Result<Vec<u8>> {
3029    // Convert UTF-8 to target encoding
3030    let encoded_bytes = crate::charset::utf8_to_ebcdic(text, codepage)?;
3031
3032    if encoded_bytes.len() > field_len {
3033        return Err(Error::new(
3034            ErrorCode::CBKE501_JSON_TYPE_MISMATCH,
3035            format!(
3036                "Text length {} exceeds field length {}",
3037                encoded_bytes.len(),
3038                field_len
3039            ),
3040        ));
3041    }
3042
3043    // Pad with spaces to field length (NORMATIVE)
3044    let mut result = encoded_bytes;
3045    let space_byte = match codepage {
3046        Codepage::ASCII => b' ',
3047        _ => 0x40, // EBCDIC space
3048    };
3049
3050    result.resize(field_len, space_byte);
3051    Ok(result)
3052}
3053
3054/// Determine whether a value should be encoded as all spaces under the
3055/// COBOL `BLANK WHEN ZERO` clause.
3056///
3057/// Returns `true` when `bwz_encode` is enabled **and** the string value
3058/// represents zero (including decimal zeros such as `"0.00"`).  Callers
3059/// use this check before encoding to decide whether to emit a
3060/// space-filled field instead of the normal numeric encoding.
3061///
3062/// # Arguments
3063/// * `value` - String representation of the numeric value to test
3064/// * `bwz_encode` - Whether the BLANK WHEN ZERO clause is active for this field
3065///
3066/// # Returns
3067/// `true` if the field should be encoded as all spaces; `false` otherwise.
3068///
3069/// # Examples
3070///
3071/// ```
3072/// use copybook_codec::numeric::should_encode_as_blank_when_zero;
3073///
3074/// assert!(should_encode_as_blank_when_zero("0", true));
3075/// assert!(should_encode_as_blank_when_zero("0.00", true));
3076/// assert!(!should_encode_as_blank_when_zero("42", true));
3077/// assert!(!should_encode_as_blank_when_zero("0", false)); // BWZ disabled
3078/// ```
3079///
3080/// # See Also
3081/// * [`encode_zoned_decimal_with_bwz`] - Uses this function to apply the BWZ policy
3082#[inline]
3083#[must_use]
3084pub fn should_encode_as_blank_when_zero(value: &str, bwz_encode: bool) -> bool {
3085    if !bwz_encode {
3086        return false;
3087    }
3088
3089    // Check if value is zero (with any scale)
3090    let trimmed = value.trim();
3091    if trimmed.is_empty() || trimmed == "0" {
3092        return true;
3093    }
3094
3095    // Check for decimal zero (0.00, 0.000, etc.)
3096    if let Some(dot_pos) = trimmed.find('.') {
3097        let integer_part = &trimmed[..dot_pos];
3098        let fractional_part = &trimmed[dot_pos + 1..];
3099
3100        if integer_part == "0" && fractional_part.chars().all(|c| c == '0') {
3101            return true;
3102        }
3103    }
3104
3105    false
3106}
3107
3108/// Encode a zoned decimal with COBOL `BLANK WHEN ZERO` support.
3109///
3110/// When `bwz_encode` is `true` and the value is zero (including decimal zeros
3111/// like `"0.00"`), the entire field is filled with space bytes (ASCII `0x20`
3112/// or EBCDIC `0x40`).  Otherwise, encoding delegates to [`encode_zoned_decimal`].
3113///
3114/// # Arguments
3115/// * `value` - String representation of the decimal value to encode
3116/// * `digits` - Number of digit positions in the COBOL field
3117/// * `scale` - Number of implied decimal places
3118/// * `signed` - Whether the field carries a sign
3119/// * `codepage` - Target character encoding
3120/// * `bwz_encode` - Whether the BLANK WHEN ZERO clause is active
3121///
3122/// # Returns
3123/// A vector of bytes containing the encoded zoned decimal, or all-space bytes
3124/// when the BWZ policy triggers.
3125///
3126/// # Errors
3127/// Returns an error if the value cannot be represented in the target zoned
3128/// decimal format (delegates error handling to [`encode_zoned_decimal`]).
3129///
3130/// # See Also
3131/// * [`should_encode_as_blank_when_zero`] - The predicate used to test zero values
3132/// * [`encode_zoned_decimal`] - Non-BWZ zoned decimal encoding
3133#[inline]
3134#[must_use = "Handle the Result or propagate the error"]
3135pub fn encode_zoned_decimal_with_bwz(
3136    value: &str,
3137    digits: u16,
3138    scale: i16,
3139    signed: bool,
3140    codepage: Codepage,
3141    bwz_encode: bool,
3142) -> Result<Vec<u8>> {
3143    // Check BWZ policy first
3144    if should_encode_as_blank_when_zero(value, bwz_encode) {
3145        let space_byte = match codepage {
3146            Codepage::ASCII => b' ',
3147            _ => 0x40, // EBCDIC space
3148        };
3149        return Ok(vec![space_byte; usize::from(digits)]);
3150    }
3151
3152    encode_zoned_decimal(value, digits, scale, signed, codepage)
3153}
3154
3155/// Map a COBOL PIC digit count to the corresponding USAGE BINARY storage width
3156/// in bits (NORMATIVE).
3157///
3158/// Follows the COBOL standard mapping:
3159/// - 1-4 digits  -> 16 bits (2 bytes, halfword)
3160/// - 5-9 digits  -> 32 bits (4 bytes, fullword)
3161/// - 10-18 digits -> 64 bits (8 bytes, doubleword)
3162///
3163/// # Arguments
3164/// * `digits` - Number of digit positions declared in the PIC clause
3165///
3166/// # Returns
3167/// The storage width in bits: 16, 32, or 64.
3168///
3169/// # See Also
3170/// * [`validate_explicit_binary_width`] - For explicit `USAGE BINARY(n)` declarations
3171/// * [`decode_binary_int`] - Decodes binary integer data at the determined width
3172#[inline]
3173#[must_use]
3174pub fn get_binary_width_from_digits(digits: u16) -> u16 {
3175    match digits {
3176        1..=4 => 16, // 2 bytes
3177        5..=9 => 32, // 4 bytes
3178        _ => 64,     // 8 bytes for larger values
3179    }
3180}
3181
3182/// Validate an explicit `USAGE BINARY(n)` byte-width declaration and return
3183/// the equivalent bit width (NORMATIVE).
3184///
3185/// Only the widths 1, 2, 4, and 8 bytes are valid.  Each is converted to
3186/// the corresponding bit count (8, 16, 32, 64) for downstream codec use.
3187///
3188/// # Arguments
3189/// * `width_bytes` - The explicit byte width from the copybook (1, 2, 4, or 8)
3190///
3191/// # Returns
3192/// The equivalent width in bits: 8, 16, 32, or 64.
3193///
3194/// # Errors
3195/// * `CBKE501_JSON_TYPE_MISMATCH` - if `width_bytes` is not 1, 2, 4, or 8
3196///
3197/// # See Also
3198/// * [`get_binary_width_from_digits`] - Infers width from PIC digit count
3199#[inline]
3200#[must_use = "Handle the Result or propagate the error"]
3201pub fn validate_explicit_binary_width(width_bytes: u8) -> Result<u16> {
3202    match width_bytes {
3203        1 => Ok(8),  // 1 byte = 8 bits
3204        2 => Ok(16), // 2 bytes = 16 bits
3205        4 => Ok(32), // 4 bytes = 32 bits
3206        8 => Ok(64), // 8 bytes = 64 bits
3207        _ => Err(Error::new(
3208            ErrorCode::CBKE501_JSON_TYPE_MISMATCH,
3209            format!("Invalid explicit binary width: {width_bytes} bytes. Must be 1, 2, 4, or 8"),
3210        )),
3211    }
3212}
3213
3214/// Get the space byte value for a given codepage
3215///
3216/// Returns the appropriate space character byte for ASCII or EBCDIC codepages.
3217///
3218/// # Arguments
3219/// * `codepage` - The target codepage
3220///
3221/// # Returns
3222/// * `0x20` (ASCII space) for ASCII codepage
3223/// * `0x40` (EBCDIC space) for EBCDIC codepages
3224///
3225/// # Examples
3226/// ```
3227/// use copybook_codec::options::Codepage;
3228/// # fn zoned_space_byte(codepage: Codepage) -> u8 {
3229/// #     match codepage {
3230/// #         Codepage::ASCII => b' ',
3231/// #         _ => 0x40,
3232/// #     }
3233/// # }
3234///
3235/// assert_eq!(zoned_space_byte(Codepage::ASCII), b' ');
3236/// assert_eq!(zoned_space_byte(Codepage::CP037), 0x40);
3237/// ```
3238#[inline]
3239const fn zoned_space_byte(codepage: Codepage) -> u8 {
3240    match codepage {
3241        Codepage::ASCII => b' ',
3242        _ => 0x40,
3243    }
3244}
3245
3246/// Get the expected zone nibble for valid digits
3247///
3248/// Returns the zone nibble value expected for digit bytes in zoned decimal
3249/// encoding for the given codepage.
3250///
3251/// # Arguments
3252/// * `codepage` - The target codepage
3253///
3254/// # Returns
3255/// * `0x3` for ASCII (digits 0x30-0x39)
3256/// * `0xF` for EBCDIC (digits 0xF0-0xF9)
3257///
3258/// # Examples
3259/// ```
3260/// use copybook_codec::options::Codepage;
3261/// # const ASCII_DIGIT_ZONE: u8 = 0x3;
3262/// # const EBCDIC_DIGIT_ZONE: u8 = 0xF;
3263/// # fn zoned_expected_zone(codepage: Codepage) -> u8 {
3264/// #     match codepage {
3265/// #         Codepage::ASCII => ASCII_DIGIT_ZONE,
3266/// #         _ => EBCDIC_DIGIT_ZONE,
3267/// #     }
3268/// # }
3269///
3270/// assert_eq!(zoned_expected_zone(Codepage::ASCII), 0x3);
3271/// assert_eq!(zoned_expected_zone(Codepage::CP037), 0xF);
3272/// ```
3273#[inline]
3274const fn zoned_expected_zone(codepage: Codepage) -> u8 {
3275    match codepage {
3276        Codepage::ASCII => ASCII_DIGIT_ZONE,
3277        _ => EBCDIC_DIGIT_ZONE,
3278    }
3279}
3280
3281/// Get a human-readable label for the encoding zone type
3282///
3283/// Returns a string label describing the encoding zone type for error messages.
3284///
3285/// # Arguments
3286/// * `codepage` - The target codepage
3287///
3288/// # Returns
3289/// * `"ASCII"` for ASCII codepage
3290/// * `"EBCDIC"` for EBCDIC codepages
3291///
3292/// # Examples
3293/// ```
3294/// use copybook_codec::options::Codepage;
3295/// # fn zoned_zone_label(codepage: Codepage) -> &'static str {
3296/// #     match codepage {
3297/// #         Codepage::ASCII => "ASCII",
3298/// #         _ => "EBCDIC",
3299/// #     }
3300/// # }
3301///
3302/// assert_eq!(zoned_zone_label(Codepage::ASCII), "ASCII");
3303/// assert_eq!(zoned_zone_label(Codepage::CP037), "EBCDIC");
3304/// ```
3305#[inline]
3306const fn zoned_zone_label(codepage: Codepage) -> &'static str {
3307    match codepage {
3308        Codepage::ASCII => "ASCII",
3309        _ => "EBCDIC",
3310    }
3311}
3312
3313/// Validate a non-final byte in a zoned decimal field
3314///
3315/// Checks that the byte contains a valid digit nibble (0-9) and the expected
3316/// zone nibble for the codepage. Non-final bytes should not contain sign information.
3317///
3318/// # Arguments
3319/// * `byte` - The byte to validate
3320/// * `index` - Position of the byte in the field (for error messages)
3321/// * `expected_zone` - Expected zone nibble value (0x3 for ASCII, 0xF for EBCDIC)
3322/// * `codepage` - Target codepage for zone validation
3323///
3324/// # Returns
3325/// The digit nibble value (0-9) extracted from the byte
3326///
3327/// # Errors
3328/// * `CBKD411_ZONED_BAD_SIGN` - Invalid digit nibble or mismatched zone
3329///
3330/// # Examples
3331/// ```text
3332/// // ASCII '5' is 0x35 (zone 0x3, digit 0x5)
3333/// let digit = zoned_validate_non_final_byte(0x35, 0, 0x3, Codepage::ASCII)?;
3334/// assert_eq!(digit, 5);
3335/// ```
3336#[inline]
3337fn zoned_validate_non_final_byte(
3338    byte: u8,
3339    index: usize,
3340    expected_zone: u8,
3341    codepage: Codepage,
3342) -> Result<u8> {
3343    let zone = (byte >> 4) & 0x0F;
3344    let digit = byte & 0x0F;
3345
3346    if digit > 9 {
3347        return Err(Error::new(
3348            ErrorCode::CBKD411_ZONED_BAD_SIGN,
3349            format!("Invalid digit nibble 0x{digit:X} at position {index}"),
3350        ));
3351    }
3352
3353    if zone != expected_zone {
3354        let zone_label = zoned_zone_label(codepage);
3355        return Err(Error::new(
3356            ErrorCode::CBKD411_ZONED_BAD_SIGN,
3357            format!(
3358                "Invalid {zone_label} zone 0x{zone:X} at position {index}, expected 0x{expected_zone:X}"
3359            ),
3360        ));
3361    }
3362
3363    Ok(digit)
3364}
3365
3366/// Process all non-final digits in a zoned decimal field
3367///
3368/// Validates each byte's zone and digit nibbles, accumulates the numeric value,
3369/// and stores digits in the scratch buffer for verification.
3370///
3371/// # Arguments
3372/// * `data` - Non-final bytes of the zoned decimal field
3373/// * `expected_zone` - Expected zone nibble (0x3 for ASCII, 0xF for EBCDIC)
3374/// * `codepage` - Target codepage
3375/// * `scratch` - Scratch buffers for digit accumulation
3376///
3377/// # Returns
3378/// Accumulated integer value from non-final digits
3379///
3380/// # Errors
3381/// * `CBKD411_ZONED_BAD_SIGN` - Invalid zone or digit nibble encountered
3382///
3383/// # Performance
3384/// Uses saturating arithmetic to prevent overflow panics while accumulating
3385/// the numeric value.
3386#[inline]
3387fn zoned_process_non_final_digits(
3388    data: &[u8],
3389    expected_zone: u8,
3390    codepage: Codepage,
3391    scratch: &mut ScratchBuffers,
3392) -> Result<i64> {
3393    let mut value = 0i64;
3394
3395    for (index, &byte) in data.iter().enumerate() {
3396        let digit = zoned_validate_non_final_byte(byte, index, expected_zone, codepage)?;
3397        scratch.digit_buffer.push(digit);
3398        value = value.saturating_mul(10).saturating_add(i64::from(digit));
3399    }
3400
3401    Ok(value)
3402}
3403
3404/// Decode the last byte of a zoned decimal field
3405///
3406/// The last byte contains both a digit and sign information encoded as an
3407/// overpunch character. Delegates to the overpunch decoder for extraction.
3408///
3409/// # Arguments
3410/// * `byte` - The final byte of the zoned decimal field
3411/// * `codepage` - Target codepage for overpunch interpretation
3412///
3413/// # Returns
3414/// Tuple of (digit, `is_negative`) extracted from the overpunch byte
3415///
3416/// # Errors
3417/// * `CBKD411_ZONED_BAD_SIGN` - Invalid overpunch encoding
3418///
3419/// # See Also
3420/// * `zoned_overpunch::decode_overpunch_byte` - Underlying overpunch decoder
3421#[inline]
3422fn zoned_decode_last_byte(byte: u8, codepage: Codepage) -> Result<(u8, bool)> {
3423    crate::zoned_overpunch::decode_overpunch_byte(byte, codepage)
3424}
3425
3426/// Ensure unsigned zoned decimal has no sign information
3427///
3428/// Validates that an unsigned zoned decimal field contains only unsigned zone
3429/// nibbles and no negative overpunch encoding.
3430///
3431/// # Arguments
3432/// * `last_byte` - The final byte of the field
3433/// * `expected_zone` - Expected unsigned zone (0x3 for ASCII, 0xF for EBCDIC)
3434/// * `codepage` - Target codepage
3435/// * `negative` - Whether overpunch decoding detected a negative sign
3436///
3437/// # Returns
3438/// Always returns `Ok(false)` for valid unsigned fields
3439///
3440/// # Errors
3441/// * `CBKD411_ZONED_BAD_SIGN` - Sign zone or negative overpunch in unsigned field
3442///
3443/// # Examples
3444/// ```text
3445/// // Valid unsigned ASCII zoned decimal ends with zone 0x3
3446/// let result = zoned_ensure_unsigned(0x35, 0x3, Codepage::ASCII, false)?;
3447/// assert_eq!(result, false);
3448/// ```
3449#[inline]
3450fn zoned_ensure_unsigned(
3451    last_byte: u8,
3452    expected_zone: u8,
3453    codepage: Codepage,
3454    negative: bool,
3455) -> Result<bool> {
3456    let zone = (last_byte >> 4) & 0x0F;
3457    if zone != expected_zone {
3458        let zone_label = zoned_zone_label(codepage);
3459        return Err(Error::new(
3460            ErrorCode::CBKD411_ZONED_BAD_SIGN,
3461            format!(
3462                "Unsigned {zone_label} zoned decimal cannot contain sign zone 0x{zone:X} in last byte"
3463            ),
3464        ));
3465    }
3466
3467    if negative {
3468        return Err(Error::new(
3469            ErrorCode::CBKD411_ZONED_BAD_SIGN,
3470            "Unsigned zoned decimal contains negative overpunch",
3471        ));
3472    }
3473
3474    Ok(false)
3475}
3476
3477/// Decode a zoned decimal using the configured code page and policy while reusing scratch buffers.
3478///
3479/// Decodes zoned decimal fields while reusing scratch buffers to avoid repeated allocations.
3480/// This is optimized for high-throughput processing where the same scratch buffers
3481/// are used across multiple decode operations.
3482///
3483/// # Arguments
3484/// * `data` - Raw byte data containing the zoned decimal
3485/// * `digits` - Number of digit characters (field length)
3486/// * `scale` - Number of decimal places (can be negative for scaling)
3487/// * `signed` - Whether the field is signed (true) or unsigned (false)
3488/// * `codepage` - Character encoding (ASCII or EBCDIC variant)
3489/// * `blank_when_zero` - If true, all-space fields decode as zero
3490/// * `scratch` - Mutable reference to scratch buffers for reuse
3491///
3492/// # Returns
3493/// A `SmallDecimal` containing of decoded value
3494///
3495/// # Policy
3496/// Defaults to *preferred zero sign* (`ZeroSignPolicy::Preferred`) for EBCDIC zeros unless
3497/// `preserve_zoned_encoding` captured an explicit format at decode.
3498///
3499/// # Errors
3500/// Returns an error if zone nibbles or the last-byte overpunch are invalid.
3501///
3502/// # Performance
3503/// This function avoids allocations by reusing scratch buffers across decode operations.
3504/// Use this for processing multiple zoned decimal fields in a loop.
3505///
3506/// # Examples
3507///
3508/// ## Basic Decoding
3509///
3510/// ```no_run
3511/// use copybook_codec::numeric::{decode_zoned_decimal_with_scratch};
3512/// use copybook_codec::memory::ScratchBuffers;
3513/// use copybook_codec::options::Codepage;
3514///
3515/// let mut scratch = ScratchBuffers::new();
3516/// let data = b"123";
3517/// let result = decode_zoned_decimal_with_scratch(data, 3, 0, false, Codepage::ASCII, false, &mut scratch)?;
3518/// assert_eq!(result.to_string(), "123");
3519/// # Ok::<(), copybook_core::Error>(())
3520/// ```
3521///
3522/// ## With BLANK WHEN ZERO
3523///
3524/// ```no_run
3525/// use copybook_codec::numeric::{decode_zoned_decimal_with_scratch};
3526/// use copybook_codec::memory::ScratchBuffers;
3527/// use copybook_codec::options::Codepage;
3528///
3529/// let mut scratch = ScratchBuffers::new();
3530/// let data = b"   "; // 3 ASCII spaces
3531/// let result = decode_zoned_decimal_with_scratch(data, 3, 0, false, Codepage::ASCII, true, &mut scratch)?;
3532/// assert_eq!(result.to_string(), "0");
3533/// # Ok::<(), copybook_core::Error>(())
3534/// ```
3535///
3536/// # See Also
3537/// * [`decode_zoned_decimal`] - For basic zoned decimal decoding
3538/// * [`ScratchBuffers`] - For scratch buffer management
3539#[inline]
3540#[must_use = "Handle the Result or propagate the error"]
3541pub fn decode_zoned_decimal_with_scratch(
3542    data: &[u8],
3543    digits: u16,
3544    scale: i16,
3545    signed: bool,
3546    codepage: Codepage,
3547    blank_when_zero: bool,
3548    scratch: &mut ScratchBuffers,
3549) -> Result<SmallDecimal> {
3550    if data.len() != usize::from(digits) {
3551        return Err(Error::new(
3552            ErrorCode::CBKD411_ZONED_BAD_SIGN,
3553            format!(
3554                "Zoned decimal data length {} doesn't match digits {}",
3555                data.len(),
3556                digits
3557            ),
3558        ));
3559    }
3560
3561    // Check for BLANK WHEN ZERO (all spaces) - optimized check
3562    let space_byte = zoned_space_byte(codepage);
3563
3564    let is_all_spaces = data.iter().all(|&b| b == space_byte);
3565    if is_all_spaces {
3566        if blank_when_zero {
3567            warn!("CBKD412_ZONED_BLANK_IS_ZERO: Zoned field is blank, decoding as zero");
3568            crate::lib_api::increment_warning_counter();
3569            return Ok(SmallDecimal::zero(scale));
3570        }
3571        return Err(Error::new(
3572            ErrorCode::CBKD411_ZONED_BAD_SIGN,
3573            "Zoned field contains all spaces but BLANK WHEN ZERO not specified",
3574        ));
3575    }
3576
3577    // Clear and prepare digit buffer for reuse
3578    scratch.digit_buffer.clear();
3579    scratch.digit_buffer.reserve(usize::from(digits));
3580
3581    let expected_zone = zoned_expected_zone(codepage);
3582    let Some((&last_byte, non_final)) = data.split_last() else {
3583        return Err(Error::new(
3584            ErrorCode::CBKD411_ZONED_BAD_SIGN,
3585            "Zoned decimal field is empty",
3586        ));
3587    };
3588    let partial_value =
3589        zoned_process_non_final_digits(non_final, expected_zone, codepage, scratch)?;
3590    let (last_digit, negative) = zoned_decode_last_byte(last_byte, codepage)?;
3591    scratch.digit_buffer.push(last_digit);
3592    let value = partial_value
3593        .saturating_mul(10)
3594        .saturating_add(i64::from(last_digit));
3595    let is_negative = if signed {
3596        negative
3597    } else {
3598        zoned_ensure_unsigned(last_byte, expected_zone, codepage, negative)?
3599    };
3600    let mut decimal = SmallDecimal::new(value, scale, is_negative);
3601    decimal.normalize();
3602
3603    debug_assert!(
3604        scratch.digit_buffer.iter().all(|&d| d <= 9),
3605        "scratch digit buffer must contain only logical digits"
3606    );
3607    Ok(decimal)
3608}
3609
3610/// Decode a single-byte packed decimal value
3611///
3612/// Handles the special case where the entire packed decimal fits in one byte.
3613/// For 1-digit fields, the high nibble contains the digit and low nibble contains
3614/// the sign. For 0-digit fields (just sign), only the low nibble is significant.
3615///
3616/// # Arguments
3617/// * `byte` - The packed decimal byte
3618/// * `digits` - Number of digits (0 or 1 for single byte)
3619/// * `scale` - Decimal scale
3620/// * `signed` - Whether the field is signed
3621///
3622/// # Returns
3623/// Decoded `SmallDecimal` value
3624///
3625/// # Errors
3626/// * `CBKD401_COMP3_INVALID_NIBBLE` - Invalid digit or sign nibble
3627///
3628/// # Format
3629/// Single-byte packed decimals:
3630/// - 1 digit: `[digit][sign]` (e.g., 0x5C = 5 positive)
3631/// - 0 digits: `[0][sign]` (just sign, high nibble must be 0)
3632///
3633/// Valid sign nibbles:
3634/// - Positive: 0xA, 0xC, 0xE, 0xF
3635/// - Negative: 0xB, 0xD
3636/// - Unsigned: 0xF only
3637#[inline]
3638fn packed_decode_single_byte(
3639    byte: u8,
3640    digits: u16,
3641    scale: i16,
3642    signed: bool,
3643) -> Result<SmallDecimal> {
3644    let high_nibble = (byte >> 4) & 0x0F;
3645    let low_nibble = byte & 0x0F;
3646    let mut value = 0i64;
3647
3648    if digits == 1 {
3649        if high_nibble > 9 {
3650            return Err(Error::new(
3651                ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
3652                format!("Invalid digit nibble 0x{high_nibble:X}"),
3653            ));
3654        }
3655        value = i64::from(high_nibble);
3656    }
3657
3658    let is_negative = if signed {
3659        match low_nibble {
3660            0xA | 0xC | 0xE | 0xF => false,
3661            0xB | 0xD => true,
3662            _ => {
3663                return Err(Error::new(
3664                    ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
3665                    format!("Invalid sign nibble 0x{low_nibble:X}"),
3666                ));
3667            }
3668        }
3669    } else {
3670        if low_nibble != 0xF {
3671            return Err(Error::new(
3672                ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
3673                format!("Invalid unsigned sign nibble 0x{low_nibble:X}, expected 0xF"),
3674            ));
3675        }
3676        false
3677    };
3678
3679    Ok(create_normalized_decimal(value, scale, is_negative))
3680}
3681
3682/// Add a digit to the accumulating packed decimal value
3683///
3684/// Multiplies the current value by 10 and adds the new digit, with overflow checking.
3685///
3686/// # Arguments
3687/// * `value` - Mutable reference to the accumulating value
3688/// * `digit` - Digit to add (0-9)
3689///
3690/// # Returns
3691/// `Ok(())` on success
3692///
3693/// # Errors
3694/// * `CBKD411_ZONED_BAD_SIGN` - Numeric overflow during accumulation
3695///
3696/// # Performance
3697/// Uses checked arithmetic to prevent panics while detecting overflow conditions.
3698#[inline]
3699fn packed_push_digit(value: &mut i64, digit: u8) -> Result<()> {
3700    *value = value
3701        .checked_mul(10)
3702        .and_then(|v| v.checked_add(i64::from(digit)))
3703        .ok_or_else(|| {
3704            Error::new(
3705                ErrorCode::CBKD411_ZONED_BAD_SIGN,
3706                "Numeric overflow during zoned decimal conversion",
3707            )
3708        })?;
3709    Ok(())
3710}
3711
3712/// Process non-final bytes of a multi-byte packed decimal
3713///
3714/// Extracts digit nibbles from all bytes before the last one, handling padding
3715/// if the digit count is odd. Accumulates the numeric value and counts digits.
3716///
3717/// # Arguments
3718/// * `bytes` - Non-final bytes of the packed decimal
3719/// * `digits` - Total number of digits in the field
3720/// * `has_padding` - Whether the first nibble is padding (odd total nibbles)
3721///
3722/// # Returns
3723/// Tuple of (`accumulated_value`, `digit_count`)
3724///
3725/// # Errors
3726/// * `CBKD401_COMP3_INVALID_NIBBLE` - Invalid digit or padding nibble
3727///
3728/// # Format
3729/// Packed decimal nibble layout:
3730/// - Even digits: `[pad=0][d1][d2][d3]...[sign]`
3731/// - Odd digits: `[d1][d2][d3]...[sign]` (no padding)
3732#[inline]
3733fn packed_process_non_last_bytes(
3734    bytes: &[u8],
3735    digits: u16,
3736    has_padding: bool,
3737) -> Result<(i64, u16)> {
3738    let mut value = 0i64;
3739    let mut digit_count: u16 = 0;
3740
3741    for (index, &byte) in bytes.iter().enumerate() {
3742        let high_nibble = (byte >> 4) & 0x0F;
3743        let low_nibble = byte & 0x0F;
3744
3745        if index == 0 && has_padding {
3746            if high_nibble != 0 {
3747                return Err(Error::new(
3748                    ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
3749                    format!("Expected padding nibble 0, got 0x{high_nibble:X}"),
3750                ));
3751            }
3752        } else {
3753            if high_nibble > 9 {
3754                return Err(Error::new(
3755                    ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
3756                    format!("Invalid digit nibble 0x{high_nibble:X}"),
3757                ));
3758            }
3759            packed_push_digit(&mut value, high_nibble)?;
3760            digit_count += 1;
3761        }
3762
3763        if digit_count >= digits {
3764            break;
3765        }
3766
3767        if low_nibble > 9 {
3768            return Err(Error::new(
3769                ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
3770                format!("Invalid digit nibble 0x{low_nibble:X}"),
3771            ));
3772        }
3773        packed_push_digit(&mut value, low_nibble)?;
3774        digit_count += 1;
3775
3776        if digit_count >= digits {
3777            break;
3778        }
3779    }
3780
3781    Ok((value, digit_count))
3782}
3783
3784/// Process the last byte of a packed decimal field
3785///
3786/// Extracts the final digit (if needed) and sign nibble from the last byte.
3787/// Creates the final normalized `SmallDecimal` value.
3788///
3789/// # Arguments
3790/// * `value` - Accumulated value from previous bytes
3791/// * `last_byte` - The final byte containing digit and sign
3792/// * `digits` - Total number of digits expected
3793/// * `digit_count` - Number of digits already processed
3794/// * `scale` - Decimal scale
3795/// * `signed` - Whether the field is signed
3796///
3797/// # Returns
3798/// Decoded and normalized `SmallDecimal`
3799///
3800/// # Errors
3801/// * `CBKD401_COMP3_INVALID_NIBBLE` - Invalid digit or sign nibble
3802///
3803/// # Format
3804/// Last byte always ends with sign nibble:
3805/// - If `digit_count` < digits: `[digit][sign]`
3806/// - If `digit_count` == digits: `[unused][sign]` (high nibble ignored)
3807#[inline]
3808fn packed_finish_last_byte(
3809    mut value: i64,
3810    last_byte: u8,
3811    digits: u16,
3812    digit_count: u16,
3813    scale: i16,
3814    signed: bool,
3815) -> Result<SmallDecimal> {
3816    let high_nibble = (last_byte >> 4) & 0x0F;
3817    let low_nibble = last_byte & 0x0F;
3818
3819    if digit_count < digits {
3820        if high_nibble > 9 {
3821            return Err(Error::new(
3822                ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
3823                format!("Invalid digit nibble 0x{high_nibble:X}"),
3824            ));
3825        }
3826        packed_push_digit(&mut value, high_nibble)?;
3827    }
3828
3829    let is_negative = if signed {
3830        match low_nibble {
3831            0xA | 0xC | 0xE | 0xF => false,
3832            0xB | 0xD => true,
3833            _ => {
3834                return Err(Error::new(
3835                    ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
3836                    format!("Invalid sign nibble 0x{low_nibble:X}"),
3837                ));
3838            }
3839        }
3840    } else {
3841        if low_nibble != 0xF {
3842            return Err(Error::new(
3843                ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
3844                format!("Invalid unsigned sign nibble 0x{low_nibble:X}, expected 0xF"),
3845            ));
3846        }
3847        false
3848    };
3849
3850    Ok(create_normalized_decimal(value, scale, is_negative))
3851}
3852
3853/// Decode a multi-byte packed decimal value
3854///
3855/// Orchestrates the decoding of packed decimals that span multiple bytes by
3856/// processing non-final bytes and the final byte separately.
3857///
3858/// # Arguments
3859/// * `data` - Complete packed decimal byte array
3860/// * `digits` - Number of digits in the field
3861/// * `scale` - Decimal scale
3862/// * `signed` - Whether the field is signed
3863///
3864/// # Returns
3865/// Decoded `SmallDecimal` value
3866///
3867/// # Errors
3868/// * `CBKD401_COMP3_INVALID_NIBBLE` - Invalid nibbles or empty input
3869///
3870/// # Algorithm
3871/// 1. Calculate if padding nibble is present (odd total nibbles)
3872/// 2. Process all non-final bytes to extract digits
3873/// 3. Process final byte to extract last digit and sign
3874/// 4. Construct normalized `SmallDecimal`
3875#[inline]
3876fn packed_decode_multi_byte(
3877    data: &[u8],
3878    digits: u16,
3879    scale: i16,
3880    signed: bool,
3881) -> Result<SmallDecimal> {
3882    let total_nibbles = digits + 1;
3883    let has_padding = (total_nibbles & 1) == 1;
3884    let Some((&last_byte, non_last)) = data.split_last() else {
3885        return Err(Error::new(
3886            ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
3887            "Packed decimal input is empty",
3888        ));
3889    };
3890    let (value, digit_count) = packed_process_non_last_bytes(non_last, digits, has_padding)?;
3891    packed_finish_last_byte(value, last_byte, digits, digit_count, scale, signed)
3892}
3893
3894/// Optimized packed decimal decoder using scratch buffers
3895/// Minimizes allocations by reusing digit buffer
3896///
3897/// # Errors
3898/// Returns an error when the packed decimal data has an invalid length or contains bad digit/sign nibbles.
3899#[inline]
3900#[must_use = "Handle the Result or propagate the error"]
3901pub fn decode_packed_decimal_with_scratch(
3902    data: &[u8],
3903    digits: u16,
3904    scale: i16,
3905    signed: bool,
3906    scratch: &mut ScratchBuffers,
3907) -> Result<SmallDecimal> {
3908    let expected_bytes = usize::from((digits + 1).div_ceil(2));
3909    if data.len() != expected_bytes {
3910        return Err(Error::new(
3911            ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
3912            format!(
3913                "Packed decimal data length {} doesn't match expected {} bytes for {} digits",
3914                data.len(),
3915                expected_bytes,
3916                digits
3917            ),
3918        ));
3919    }
3920
3921    if data.is_empty() {
3922        return Ok(SmallDecimal::zero(scale));
3923    }
3924
3925    // Use the original implementation - the "optimized" path actually hurts performance
3926    // Clear and prepare digit buffer for reuse
3927    scratch.digit_buffer.clear();
3928    scratch.digit_buffer.reserve(usize::from(digits));
3929
3930    // Optimized nibble processing - unify handling for multi-byte cases
3931    let decimal = if data.len() == 1 {
3932        packed_decode_single_byte(data[0], digits, scale, signed)?
3933    } else {
3934        packed_decode_multi_byte(data, digits, scale, signed)?
3935    };
3936
3937    debug_assert!(
3938        scratch.digit_buffer.iter().all(|&d| d <= 9),
3939        "scratch digit buffer must contain only logical digits"
3940    );
3941
3942    Ok(decimal)
3943}
3944
3945/// Decode a COBOL binary integer (USAGE BINARY / COMP) with optimized fast
3946/// paths for 16-, 32-, and 64-bit widths.
3947///
3948/// For the three common widths this function reads the big-endian bytes
3949/// directly into the native integer type, avoiding the generic loop in
3950/// [`decode_binary_int`].  Uncommon widths fall back to that generic
3951/// implementation.
3952///
3953/// # Arguments
3954/// * `data` - Raw big-endian byte data containing the binary integer
3955/// * `bits` - Expected field width in bits (16, 32, or 64 for fast path)
3956/// * `signed` - Whether the field is signed (PIC S9 COMP) or unsigned (PIC 9 COMP)
3957///
3958/// # Returns
3959/// The decoded integer value as `i64`.
3960///
3961/// # Errors
3962/// * `CBKD401_COMP3_INVALID_NIBBLE` - if an unsigned 64-bit value exceeds `i64::MAX`
3963/// * Delegates to [`decode_binary_int`] for unsupported widths, which may
3964///   return its own errors.
3965///
3966/// # See Also
3967/// * [`decode_binary_int`] - General-purpose binary integer decoder
3968/// * [`encode_binary_int`] - Binary integer encoder
3969/// * [`format_binary_int_to_string_with_scratch`] - Formats the decoded value to a string
3970#[inline]
3971#[must_use = "Handle the Result or propagate the error"]
3972pub fn decode_binary_int_fast(data: &[u8], bits: u16, signed: bool) -> Result<i64> {
3973    // Optimized paths for common binary widths
3974    match (bits, data.len()) {
3975        (16, 2) => {
3976            // 16-bit integer - most common case
3977            let bytes = [data[0], data[1]];
3978            if signed {
3979                Ok(i64::from(i16::from_be_bytes(bytes)))
3980            } else {
3981                Ok(i64::from(u16::from_be_bytes(bytes)))
3982            }
3983        }
3984        (32, 4) => {
3985            // 32-bit integer - common case
3986            let bytes = [data[0], data[1], data[2], data[3]];
3987            if signed {
3988                Ok(i64::from(i32::from_be_bytes(bytes)))
3989            } else {
3990                Ok(i64::from(u32::from_be_bytes(bytes)))
3991            }
3992        }
3993        (64, 8) => {
3994            // 64-bit integer
3995            let bytes = [
3996                data[0], data[1], data[2], data[3], data[4], data[5], data[6], data[7],
3997            ];
3998            if signed {
3999                Ok(i64::from_be_bytes(bytes))
4000            } else {
4001                let value = u64::from_be_bytes(bytes);
4002                let max_i64 = u64::try_from(i64::MAX).unwrap_or(u64::MAX);
4003                if value > max_i64 {
4004                    return Err(Error::new(
4005                        ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
4006                        format!("Unsigned 64-bit value {value} exceeds i64::MAX"),
4007                    ));
4008                }
4009                i64::try_from(value).map_err(|_| {
4010                    Error::new(
4011                        ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
4012                        format!("Unsigned 64-bit value {value} exceeds i64::MAX"),
4013                    )
4014                })
4015            }
4016        }
4017        _ => {
4018            // Fallback to general implementation
4019            decode_binary_int(data, bits, signed)
4020        }
4021    }
4022}
4023
4024/// Encode a zoned decimal while reusing caller-owned scratch buffers to avoid
4025/// per-call heap allocations on the hot path.
4026///
4027/// Converts the pre-parsed [`SmallDecimal`] to its string representation using
4028/// the scratch buffer and then delegates to [`encode_zoned_decimal`].  The
4029/// `_bwz_encode` parameter is reserved for future BLANK WHEN ZERO integration
4030/// but is currently unused.
4031///
4032/// # Arguments
4033/// * `decimal` - Pre-parsed decimal value to encode
4034/// * `digits` - Number of digit positions in the COBOL field
4035/// * `signed` - Whether the field carries a sign
4036/// * `codepage` - Target character encoding (ASCII or EBCDIC variant)
4037/// * `_bwz_encode` - Reserved for BLANK WHEN ZERO support (currently unused)
4038/// * `scratch` - Reusable scratch buffers for zero-allocation string processing
4039///
4040/// # Returns
4041/// A vector of bytes containing the encoded zoned decimal.
4042///
4043/// # Policy
4044/// Callers typically resolve policy using `zoned_encoding_override` → preserved
4045/// metadata → `preferred_zoned_encoding`, matching the documented library
4046/// behavior for zoned decimals.
4047///
4048/// # Errors
4049/// Returns an error when the decimal value cannot be represented with the
4050/// requested digit count or encoding format.
4051///
4052/// # See Also
4053/// * [`encode_zoned_decimal`] - Underlying encoder
4054/// * [`encode_packed_decimal_with_scratch`] - Scratch-based packed decimal encoder
4055#[inline]
4056#[must_use = "Handle the Result or propagate the error"]
4057pub fn encode_zoned_decimal_with_scratch(
4058    decimal: &SmallDecimal,
4059    digits: u16,
4060    signed: bool,
4061    codepage: Codepage,
4062    _bwz_encode: bool,
4063    scratch: &mut ScratchBuffers,
4064) -> Result<Vec<u8>> {
4065    // Clear and prepare buffers
4066    scratch.digit_buffer.clear();
4067    scratch.byte_buffer.clear();
4068    scratch.byte_buffer.reserve(usize::from(digits));
4069
4070    // Convert decimal to string using scratch buffer
4071    scratch.string_buffer.clear();
4072    scratch.string_buffer.push_str(&decimal.to_string());
4073
4074    // Use the standard encode function but with optimized digit processing
4075    // This is a placeholder for now - the actual optimization would involve
4076    // rewriting the encode logic to use the scratch buffers
4077    encode_zoned_decimal(
4078        &scratch.string_buffer,
4079        digits,
4080        decimal.scale,
4081        signed,
4082        codepage,
4083    )
4084}
4085
4086/// Encode a packed decimal (COMP-3) while reusing caller-owned scratch buffers
4087/// to minimize per-call allocations.
4088///
4089/// Converts the pre-parsed [`SmallDecimal`] to its string representation using
4090/// the scratch buffer and then delegates to [`encode_packed_decimal`].  Intended
4091/// for use on codec hot paths where many records are encoded sequentially with
4092/// the same [`ScratchBuffers`] instance.
4093///
4094/// # Arguments
4095/// * `decimal` - Pre-parsed decimal value to encode
4096/// * `digits` - Number of decimal digits in the field (1-18)
4097/// * `signed` - Whether the field is signed (`true`) or unsigned (`false`)
4098/// * `scratch` - Reusable scratch buffers for zero-allocation string processing
4099///
4100/// # Returns
4101/// A vector of bytes containing the encoded packed decimal (COMP-3 format).
4102///
4103/// # Errors
4104/// Returns an error when the decimal value cannot be encoded into the
4105/// requested packed representation (delegates to [`encode_packed_decimal`]).
4106///
4107/// # See Also
4108/// * [`encode_packed_decimal`] - Underlying packed decimal encoder
4109/// * [`encode_zoned_decimal_with_scratch`] - Scratch-based zoned decimal encoder
4110/// * [`decode_packed_decimal_to_string_with_scratch`] - Scratch-based decoder
4111#[inline]
4112#[must_use = "Handle the Result or propagate the error"]
4113pub fn encode_packed_decimal_with_scratch(
4114    decimal: &SmallDecimal,
4115    digits: u16,
4116    signed: bool,
4117    scratch: &mut ScratchBuffers,
4118) -> Result<Vec<u8>> {
4119    // Clear and prepare buffers
4120    scratch.digit_buffer.clear();
4121    scratch.byte_buffer.clear();
4122    let expected_bytes = usize::from((digits + 1).div_ceil(2));
4123    scratch.byte_buffer.reserve(expected_bytes);
4124
4125    // Convert decimal to string using scratch buffer
4126    scratch.string_buffer.clear();
4127    scratch.string_buffer.push_str(&decimal.to_string());
4128
4129    // Use the standard encode function but with optimized nibble processing
4130    // This is a placeholder for now - the actual optimization would involve
4131    // rewriting the encode logic to use the scratch buffers
4132    encode_packed_decimal(&scratch.string_buffer, digits, decimal.scale, signed)
4133}
4134
4135/// Decode a packed decimal (COMP-3) directly to a `String`, bypassing the
4136/// intermediate [`SmallDecimal`] allocation.
4137///
4138/// This is a critical performance optimization for COMP-3 JSON conversion.
4139/// By decoding nibbles and formatting the result in a single pass using the
4140/// caller-owned scratch buffer, it avoids the `SmallDecimal` -> `String`
4141/// allocation overhead that caused 94-96% throughput regression in COMP-3
4142/// processing benchmarks.
4143///
4144/// # Arguments
4145/// * `data` - Raw byte data containing the packed decimal (BCD with trailing sign nibble)
4146/// * `digits` - Number of decimal digits in the field (1-18)
4147/// * `scale` - Number of implied decimal places (can be negative for scaling)
4148/// * `signed` - Whether the field is signed (`true`) or unsigned (`false`)
4149/// * `scratch` - Reusable scratch buffers; the `string_buffer` is consumed via
4150///   `std::mem::take` and returned as the result string.
4151///
4152/// # Returns
4153/// The decoded value formatted as a string (e.g. `"123"`, `"-45.67"`, `"0"`).
4154///
4155/// # Errors
4156/// * `CBKD401_COMP3_INVALID_NIBBLE` - if any data nibble is > 9 or the sign
4157///   nibble is invalid
4158///
4159/// # Performance
4160/// Includes a fast path for single-digit packed decimals (1 byte) and falls
4161/// back to [`decode_packed_decimal_with_scratch`] plus
4162/// [`SmallDecimal::format_to_scratch_buffer`] for larger values.
4163///
4164/// # See Also
4165/// * [`decode_packed_decimal`] - Returns a `SmallDecimal` instead of a string
4166/// * [`decode_packed_decimal_with_scratch`] - Scratch-based decoder returning `SmallDecimal`
4167/// * [`encode_packed_decimal_with_scratch`] - Scratch-based packed decimal encoder
4168#[inline]
4169#[must_use = "Handle the Result or propagate the error"]
4170pub fn decode_packed_decimal_to_string_with_scratch(
4171    data: &[u8],
4172    digits: u16,
4173    scale: i16,
4174    signed: bool,
4175    scratch: &mut ScratchBuffers,
4176) -> Result<String> {
4177    // SIMD-friendly sign lookup table for faster branch-free sign detection
4178    // Index by nibble value: 0=invalid, 1=positive, 2=negative
4179    const SIGN_TABLE: [u8; 16] = [
4180        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0x0-0x9: invalid
4181        1, 2, 1, 2, 1, 1, // 0xA=pos, 0xB=neg, 0xC=pos, 0xD=neg, 0xE=pos, 0xF=pos
4182    ];
4183
4184    // CRITICAL OPTIMIZATION: Direct decode-to-string path to avoid SmallDecimal allocation
4185    if data.is_empty() {
4186        return Ok("0".to_string());
4187    }
4188
4189    // Fast path for common single-digit packed decimals
4190    if data.len() == 1 && digits == 1 {
4191        let byte = data[0];
4192        let high_nibble = (byte >> 4) & 0x0F;
4193        let low_nibble = byte & 0x0F;
4194
4195        let mut is_negative = false;
4196
4197        // Single digit: high nibble is unused (should be 0), low nibble is sign
4198        if high_nibble > 9 {
4199            return Err(Error::new(
4200                ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
4201                format!("Invalid digit nibble 0x{high_nibble:X}"),
4202            ));
4203        }
4204        let value = i64::from(high_nibble);
4205
4206        if signed {
4207            // SIMD-friendly branch-free sign detection
4208            let sign_code = SIGN_TABLE[usize::from(low_nibble)];
4209            if sign_code == 0 {
4210                return Err(Error::new(
4211                    ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
4212                    format!("Invalid sign nibble 0x{low_nibble:X}"),
4213                ));
4214            }
4215            is_negative = sign_code == 2;
4216        } else if low_nibble != 0xF {
4217            return Err(Error::new(
4218                ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
4219                format!("Invalid unsigned sign nibble 0x{low_nibble:X}, expected 0xF"),
4220            ));
4221        }
4222
4223        // Format directly to string without SmallDecimal
4224        scratch.string_buffer.clear();
4225        if is_negative && value != 0 {
4226            scratch.string_buffer.push('-');
4227        }
4228
4229        if scale <= 0 {
4230            // Integer format
4231            let scaled_value = if scale < 0 {
4232                value * 10_i64.pow(scale_abs_to_u32(scale))
4233            } else {
4234                value
4235            };
4236            format_integer_to_buffer(scaled_value, &mut scratch.string_buffer);
4237        } else {
4238            // Decimal format
4239            let divisor = 10_i64.pow(scale_abs_to_u32(scale));
4240            let integer_part = value / divisor;
4241            let fractional_part = value % divisor;
4242
4243            format_integer_to_buffer(integer_part, &mut scratch.string_buffer);
4244            scratch.string_buffer.push('.');
4245            format_integer_with_leading_zeros_to_buffer(
4246                fractional_part,
4247                scale_abs_to_u32(scale),
4248                &mut scratch.string_buffer,
4249            );
4250        }
4251
4252        // CRITICAL OPTIMIZATION: Move string content without cloning
4253        let result = std::mem::take(&mut scratch.string_buffer);
4254        return Ok(result);
4255    }
4256
4257    // Fall back to general case for larger packed decimals
4258    let decimal = decode_packed_decimal_with_scratch(data, digits, scale, signed, scratch)?;
4259
4260    // Now format to string using the optimized scratch buffer method
4261    decimal.format_to_scratch_buffer(scale, &mut scratch.string_buffer);
4262
4263    // CRITICAL OPTIMIZATION: Move string content without cloning
4264    let result = std::mem::take(&mut scratch.string_buffer);
4265    Ok(result)
4266}
4267
4268/// Format a binary integer into the caller-owned scratch buffer.
4269///
4270/// ## Why scratch?
4271/// Avoids hot-path allocations in codec routes that emit integers frequently
4272/// (zoned/packed/binary). This writes into `scratch` and returns that buffer,
4273/// so callers must reuse the same `ScratchBuffers` instance across a walk.
4274///
4275/// ## Contract
4276/// - No allocations on the hot path
4277/// - Returns the scratch-backed `String` (valid until next reuse/clear)
4278#[inline]
4279#[must_use = "Use the formatted string or continue mutating the scratch buffer"]
4280pub fn format_binary_int_to_string_with_scratch(
4281    value: i64,
4282    scratch: &mut ScratchBuffers,
4283) -> String {
4284    scratch.string_buffer.clear();
4285
4286    if value < 0 {
4287        scratch.string_buffer.push('-');
4288        if value == i64::MIN {
4289            // Avoid overflow when negating i64::MIN
4290            scratch.string_buffer.push_str("9223372036854775808");
4291            return std::mem::take(&mut scratch.string_buffer);
4292        }
4293        format_integer_to_buffer(-value, &mut scratch.string_buffer);
4294    } else {
4295        format_integer_to_buffer(value, &mut scratch.string_buffer);
4296    }
4297
4298    std::mem::take(&mut scratch.string_buffer)
4299}
4300
4301/// Format an integer to a string buffer with optimized performance
4302///
4303/// Provides ultra-fast integer-to-string conversion optimized for COBOL numeric
4304/// decoding hot paths. Uses manual digit extraction to avoid format macro overhead.
4305///
4306/// # Arguments
4307/// * `value` - Integer value to format
4308/// * `buffer` - String buffer to append digits to
4309///
4310/// # Performance
4311/// Critical optimization for COMP-3 and zoned decimal JSON conversion. Avoids
4312/// the overhead of Rust's standard formatting macros through manual digit extraction.
4313///
4314/// # Examples
4315/// ```text
4316/// let mut buffer = String::new();
4317/// format_integer_to_buffer(12345, &mut buffer);
4318/// assert_eq!(buffer, "12345");
4319/// ```
4320#[inline]
4321fn format_integer_to_buffer(value: i64, buffer: &mut String) {
4322    SmallDecimal::format_integer_manual(value, buffer);
4323}
4324
4325/// Format an integer with leading zeros to a string buffer
4326///
4327/// Formats an integer with exactly `width` digits, padding with leading zeros
4328/// if necessary. Optimized for decimal formatting where fractional parts must
4329/// maintain precise digit counts.
4330///
4331/// # Arguments
4332/// * `value` - Integer value to format
4333/// * `width` - Number of digits in output (with leading zeros)
4334/// * `buffer` - String buffer to append formatted digits to
4335///
4336/// # Performance
4337/// Optimized for common COBOL scales (0-4 decimal places) with specialized
4338/// fast paths. Critical for maintaining COMP-3 decimal precision.
4339///
4340/// # Examples
4341/// ```text
4342/// let mut buffer = String::new();
4343/// format_integer_with_leading_zeros_to_buffer(45, 4, &mut buffer);
4344/// assert_eq!(buffer, "0045");
4345/// ```
4346#[inline]
4347fn format_integer_with_leading_zeros_to_buffer(value: i64, width: u32, buffer: &mut String) {
4348    SmallDecimal::format_integer_with_leading_zeros(value, width, buffer);
4349}
4350
4351/// Decode a zoned decimal directly to a `String`, bypassing the intermediate
4352/// [`SmallDecimal`] allocation.
4353///
4354/// Analogous to [`decode_packed_decimal_to_string_with_scratch`] but for zoned
4355/// decimal (PIC 9 / PIC S9) fields.  Decodes via
4356/// [`decode_zoned_decimal_with_scratch`] and then formats the result into the
4357/// scratch string buffer, avoiding a separate heap allocation.
4358///
4359/// # Arguments
4360/// * `data` - Raw byte data containing the zoned decimal
4361/// * `digits` - Number of digit characters (field length)
4362/// * `scale` - Number of implied decimal places (can be negative for scaling)
4363/// * `signed` - Whether the field carries a sign (overpunch in last byte)
4364/// * `codepage` - Character encoding (ASCII or EBCDIC variant)
4365/// * `blank_when_zero` - If `true`, all-space fields decode as `"0"`
4366/// * `scratch` - Reusable scratch buffers; the `string_buffer` is consumed via
4367///   `std::mem::take` and returned as the result string.
4368///
4369/// # Returns
4370/// The decoded value formatted as a string (e.g. `"123"`, `"-45.67"`, `"0"`).
4371///
4372/// # Policy
4373/// Mirrors [`decode_zoned_decimal_with_scratch`], inheriting its default
4374/// preferred-zero handling for EBCDIC data.
4375///
4376/// # Errors
4377/// * `CBKD411_ZONED_BAD_SIGN` - if the zone nibbles or sign are invalid
4378///
4379/// # See Also
4380/// * [`decode_zoned_decimal`] - Returns a `SmallDecimal` instead of a string
4381/// * [`decode_zoned_decimal_with_scratch`] - Scratch-based decoder returning `SmallDecimal`
4382/// * [`decode_packed_decimal_to_string_with_scratch`] - Equivalent for packed decimals
4383#[inline]
4384#[must_use = "Handle the Result or propagate the error"]
4385pub fn decode_zoned_decimal_to_string_with_scratch(
4386    data: &[u8],
4387    digits: u16,
4388    scale: i16,
4389    signed: bool,
4390    codepage: Codepage,
4391    blank_when_zero: bool,
4392    scratch: &mut ScratchBuffers,
4393) -> Result<String> {
4394    // First decode to SmallDecimal using existing optimized decoder
4395    let decimal = decode_zoned_decimal_with_scratch(
4396        data,
4397        digits,
4398        scale,
4399        signed,
4400        codepage,
4401        blank_when_zero,
4402        scratch,
4403    )?;
4404
4405    // Special-case integer zoned decimals for digit padding consistency
4406    if scale == 0 && !blank_when_zero {
4407        if decimal.value == 0 {
4408            scratch.string_buffer.clear();
4409            scratch.string_buffer.push('0');
4410        } else {
4411            scratch.string_buffer.clear();
4412            if decimal.negative && decimal.value != 0 {
4413                scratch.string_buffer.push('-');
4414            }
4415
4416            let magnitude = if decimal.scale < 0 {
4417                decimal.value * 10_i64.pow(scale_abs_to_u32(decimal.scale))
4418            } else {
4419                decimal.value
4420            };
4421
4422            SmallDecimal::format_integer_with_leading_zeros(
4423                magnitude,
4424                u32::from(digits),
4425                &mut scratch.string_buffer,
4426            );
4427        }
4428
4429        return Ok(std::mem::take(&mut scratch.string_buffer));
4430    }
4431
4432    // Fallback to general fixed-scale formatting using scratch buffer
4433    decimal.format_to_scratch_buffer(scale, &mut scratch.string_buffer);
4434    Ok(std::mem::take(&mut scratch.string_buffer))
4435}
4436
4437// =============================================================================
4438// Floating-Point Codecs (COMP-1 / COMP-2)
4439// =============================================================================
4440
4441const IBM_HEX_EXPONENT_BIAS: i32 = 64;
4442const IBM_HEX_FRACTION_MIN: f64 = 1.0 / 16.0;
4443const IBM_HEX_SINGLE_FRACTION_BITS: u32 = 24;
4444const IBM_HEX_DOUBLE_FRACTION_BITS: u32 = 56;
4445
4446#[inline]
4447fn validate_float_buffer_len(data: &[u8], required: usize, usage: &str) -> Result<()> {
4448    if data.len() < required {
4449        return Err(Error::new(
4450            ErrorCode::CBKD301_RECORD_TOO_SHORT,
4451            format!("{usage} requires {required} bytes, got {}", data.len()),
4452        ));
4453    }
4454    Ok(())
4455}
4456
4457#[inline]
4458fn validate_float_encode_buffer_len(buffer: &[u8], required: usize, usage: &str) -> Result<()> {
4459    if buffer.len() < required {
4460        return Err(Error::new(
4461            ErrorCode::CBKE510_NUMERIC_OVERFLOW,
4462            format!(
4463                "{usage} requires {required} bytes, buffer has {}",
4464                buffer.len()
4465            ),
4466        ));
4467    }
4468    Ok(())
4469}
4470
4471#[inline]
4472#[allow(clippy::cast_precision_loss)]
4473fn decode_ibm_hex_to_f64(sign: bool, exponent_raw: u8, fraction_bits: u64, bits: u32) -> f64 {
4474    if exponent_raw == 0 && fraction_bits == 0 {
4475        return if sign { -0.0 } else { 0.0 };
4476    }
4477
4478    let exponent = i32::from(exponent_raw) - IBM_HEX_EXPONENT_BIAS;
4479    let divisor = 2_f64.powi(i32::try_from(bits).unwrap_or(0));
4480    let fraction = (fraction_bits as f64) / divisor;
4481    let magnitude = fraction * 16_f64.powi(exponent);
4482    if sign { -magnitude } else { magnitude }
4483}
4484
4485#[inline]
4486#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
4487fn encode_f64_to_ibm_hex_parts(value: f64, bits: u32) -> Result<(u8, u64)> {
4488    if !value.is_finite() {
4489        return Err(Error::new(
4490            ErrorCode::CBKE510_NUMERIC_OVERFLOW,
4491            "IBM hex float encoding requires finite values",
4492        ));
4493    }
4494
4495    if value == 0.0 {
4496        return Ok((0, 0));
4497    }
4498
4499    let mut exponent = IBM_HEX_EXPONENT_BIAS;
4500    let mut fraction = value.abs();
4501
4502    while fraction < IBM_HEX_FRACTION_MIN {
4503        fraction *= 16.0;
4504        exponent -= 1;
4505        if exponent <= 0 {
4506            return Ok((0, 0));
4507        }
4508    }
4509
4510    while fraction >= 1.0 {
4511        fraction /= 16.0;
4512        exponent += 1;
4513        if exponent >= 128 {
4514            return Err(Error::new(
4515                ErrorCode::CBKE510_NUMERIC_OVERFLOW,
4516                "IBM hex float exponent overflow",
4517            ));
4518        }
4519    }
4520
4521    let scale = 2_f64.powi(i32::try_from(bits).unwrap_or(0));
4522    let mut fraction_bits = (fraction * scale).round() as u64;
4523    let full_scale = 1_u64 << bits;
4524    if fraction_bits >= full_scale {
4525        // Carry from rounding: re-normalize to 0x1... and bump exponent.
4526        fraction_bits = 1_u64 << (bits - 4);
4527        exponent += 1;
4528        if exponent >= 128 {
4529            return Err(Error::new(
4530                ErrorCode::CBKE510_NUMERIC_OVERFLOW,
4531                "IBM hex float exponent overflow",
4532            ));
4533        }
4534    }
4535
4536    Ok((u8::try_from(exponent).unwrap_or(0), fraction_bits))
4537}
4538
4539/// Decode a COMP-1 field in IEEE-754 big-endian format.
4540///
4541/// # Errors
4542/// Returns `CBKD301_RECORD_TOO_SHORT` if the data slice has fewer than 4 bytes.
4543#[inline]
4544pub fn decode_float_single_ieee_be(data: &[u8]) -> Result<f32> {
4545    validate_float_buffer_len(data, 4, "COMP-1")?;
4546    let bytes: [u8; 4] = [data[0], data[1], data[2], data[3]];
4547    Ok(f32::from_be_bytes(bytes))
4548}
4549
4550/// Decode a COMP-2 field in IEEE-754 big-endian format.
4551///
4552/// # Errors
4553/// Returns `CBKD301_RECORD_TOO_SHORT` if the data slice has fewer than 8 bytes.
4554#[inline]
4555pub fn decode_float_double_ieee_be(data: &[u8]) -> Result<f64> {
4556    validate_float_buffer_len(data, 8, "COMP-2")?;
4557    let bytes: [u8; 8] = [
4558        data[0], data[1], data[2], data[3], data[4], data[5], data[6], data[7],
4559    ];
4560    Ok(f64::from_be_bytes(bytes))
4561}
4562
4563/// Decode a COMP-1 field in IBM hexadecimal floating-point format.
4564///
4565/// # Errors
4566/// Returns `CBKD301_RECORD_TOO_SHORT` if the data slice has fewer than 4 bytes.
4567#[inline]
4568pub fn decode_float_single_ibm_hex(data: &[u8]) -> Result<f32> {
4569    validate_float_buffer_len(data, 4, "COMP-1")?;
4570    let word = u32::from_be_bytes([data[0], data[1], data[2], data[3]]);
4571    let sign = (word & 0x8000_0000) != 0;
4572    let exponent_raw = ((word >> 24) & 0x7F) as u8;
4573    let fraction_bits = u64::from(word & 0x00FF_FFFF);
4574    let value = decode_ibm_hex_to_f64(
4575        sign,
4576        exponent_raw,
4577        fraction_bits,
4578        IBM_HEX_SINGLE_FRACTION_BITS,
4579    );
4580    #[allow(clippy::cast_possible_truncation)]
4581    {
4582        Ok(value as f32)
4583    }
4584}
4585
4586/// Decode a COMP-2 field in IBM hexadecimal floating-point format.
4587///
4588/// # Errors
4589/// Returns `CBKD301_RECORD_TOO_SHORT` if the data slice has fewer than 8 bytes.
4590#[inline]
4591pub fn decode_float_double_ibm_hex(data: &[u8]) -> Result<f64> {
4592    validate_float_buffer_len(data, 8, "COMP-2")?;
4593    let word = u64::from_be_bytes([
4594        data[0], data[1], data[2], data[3], data[4], data[5], data[6], data[7],
4595    ]);
4596    let sign = (word & 0x8000_0000_0000_0000) != 0;
4597    let exponent_raw = ((word >> 56) & 0x7F) as u8;
4598    let fraction_bits = word & 0x00FF_FFFF_FFFF_FFFF;
4599    Ok(decode_ibm_hex_to_f64(
4600        sign,
4601        exponent_raw,
4602        fraction_bits,
4603        IBM_HEX_DOUBLE_FRACTION_BITS,
4604    ))
4605}
4606
4607/// Decode a COMP-1 float with explicit format selection.
4608///
4609/// # Errors
4610/// Returns format-specific decode errors.
4611#[inline]
4612pub fn decode_float_single_with_format(data: &[u8], format: FloatFormat) -> Result<f32> {
4613    match format {
4614        FloatFormat::IeeeBigEndian => decode_float_single_ieee_be(data),
4615        FloatFormat::IbmHex => decode_float_single_ibm_hex(data),
4616    }
4617}
4618
4619/// Decode a COMP-2 float with explicit format selection.
4620///
4621/// # Errors
4622/// Returns format-specific decode errors.
4623#[inline]
4624pub fn decode_float_double_with_format(data: &[u8], format: FloatFormat) -> Result<f64> {
4625    match format {
4626        FloatFormat::IeeeBigEndian => decode_float_double_ieee_be(data),
4627        FloatFormat::IbmHex => decode_float_double_ibm_hex(data),
4628    }
4629}
4630
4631/// Decode a COMP-1 float with default IEEE-754 big-endian interpretation.
4632///
4633/// # Errors
4634/// Returns `CBKD301_RECORD_TOO_SHORT` if the data slice has fewer than 4 bytes.
4635#[inline]
4636pub fn decode_float_single(data: &[u8]) -> Result<f32> {
4637    decode_float_single_ieee_be(data)
4638}
4639
4640/// Decode a COMP-2 float with default IEEE-754 big-endian interpretation.
4641///
4642/// # Errors
4643/// Returns `CBKD301_RECORD_TOO_SHORT` if the data slice has fewer than 8 bytes.
4644#[inline]
4645pub fn decode_float_double(data: &[u8]) -> Result<f64> {
4646    decode_float_double_ieee_be(data)
4647}
4648
4649/// Encode a COMP-1 value in IEEE-754 big-endian format.
4650///
4651/// # Errors
4652/// Returns `CBKE510_NUMERIC_OVERFLOW` if the buffer has fewer than 4 bytes.
4653#[inline]
4654pub fn encode_float_single_ieee_be(value: f32, buffer: &mut [u8]) -> Result<()> {
4655    validate_float_encode_buffer_len(buffer, 4, "COMP-1")?;
4656    let bytes = value.to_be_bytes();
4657    buffer[..4].copy_from_slice(&bytes);
4658    Ok(())
4659}
4660
4661/// Encode a COMP-2 value in IEEE-754 big-endian format.
4662///
4663/// # Errors
4664/// Returns `CBKE510_NUMERIC_OVERFLOW` if the buffer has fewer than 8 bytes.
4665#[inline]
4666pub fn encode_float_double_ieee_be(value: f64, buffer: &mut [u8]) -> Result<()> {
4667    validate_float_encode_buffer_len(buffer, 8, "COMP-2")?;
4668    let bytes = value.to_be_bytes();
4669    buffer[..8].copy_from_slice(&bytes);
4670    Ok(())
4671}
4672
4673/// Encode a COMP-1 value in IBM hexadecimal floating-point format.
4674///
4675/// # Errors
4676/// Returns:
4677/// - `CBKE510_NUMERIC_OVERFLOW` if the buffer is too small
4678/// - `CBKE510_NUMERIC_OVERFLOW` for non-finite values or exponent overflow
4679#[inline]
4680pub fn encode_float_single_ibm_hex(value: f32, buffer: &mut [u8]) -> Result<()> {
4681    validate_float_encode_buffer_len(buffer, 4, "COMP-1")?;
4682    let sign = value.is_sign_negative();
4683    let (exponent_raw, fraction_bits) =
4684        encode_f64_to_ibm_hex_parts(f64::from(value), IBM_HEX_SINGLE_FRACTION_BITS)?;
4685    let sign_bit = if sign { 0x8000_0000 } else { 0 };
4686    let fraction_low = u32::try_from(fraction_bits & 0x00FF_FFFF).map_err(|_| {
4687        Error::new(
4688            ErrorCode::CBKE510_NUMERIC_OVERFLOW,
4689            "IBM hex fraction overflow for COMP-1",
4690        )
4691    })?;
4692    let word = sign_bit | (u32::from(exponent_raw) << 24) | fraction_low;
4693    buffer[..4].copy_from_slice(&word.to_be_bytes());
4694    Ok(())
4695}
4696
4697/// Encode a COMP-2 value in IBM hexadecimal floating-point format.
4698///
4699/// # Errors
4700/// Returns:
4701/// - `CBKE510_NUMERIC_OVERFLOW` if the buffer is too small
4702/// - `CBKE510_NUMERIC_OVERFLOW` for non-finite values or exponent overflow
4703#[inline]
4704pub fn encode_float_double_ibm_hex(value: f64, buffer: &mut [u8]) -> Result<()> {
4705    validate_float_encode_buffer_len(buffer, 8, "COMP-2")?;
4706    let sign = value.is_sign_negative();
4707    let (exponent_raw, fraction_bits) =
4708        encode_f64_to_ibm_hex_parts(value, IBM_HEX_DOUBLE_FRACTION_BITS)?;
4709    let sign_bit = if sign { 0x8000_0000_0000_0000 } else { 0 };
4710    let word = sign_bit | (u64::from(exponent_raw) << 56) | (fraction_bits & 0x00FF_FFFF_FFFF_FFFF);
4711    buffer[..8].copy_from_slice(&word.to_be_bytes());
4712    Ok(())
4713}
4714
4715/// Encode a COMP-1 float with explicit format selection.
4716///
4717/// # Errors
4718/// Returns format-specific encode errors.
4719#[inline]
4720pub fn encode_float_single_with_format(
4721    value: f32,
4722    buffer: &mut [u8],
4723    format: FloatFormat,
4724) -> Result<()> {
4725    match format {
4726        FloatFormat::IeeeBigEndian => encode_float_single_ieee_be(value, buffer),
4727        FloatFormat::IbmHex => encode_float_single_ibm_hex(value, buffer),
4728    }
4729}
4730
4731/// Encode a COMP-2 float with explicit format selection.
4732///
4733/// # Errors
4734/// Returns format-specific encode errors.
4735#[inline]
4736pub fn encode_float_double_with_format(
4737    value: f64,
4738    buffer: &mut [u8],
4739    format: FloatFormat,
4740) -> Result<()> {
4741    match format {
4742        FloatFormat::IeeeBigEndian => encode_float_double_ieee_be(value, buffer),
4743        FloatFormat::IbmHex => encode_float_double_ibm_hex(value, buffer),
4744    }
4745}
4746
4747/// Encode a COMP-1 float with default IEEE-754 big-endian format.
4748///
4749/// # Errors
4750/// Returns `CBKE510_NUMERIC_OVERFLOW` if the buffer has fewer than 4 bytes.
4751#[inline]
4752pub fn encode_float_single(value: f32, buffer: &mut [u8]) -> Result<()> {
4753    encode_float_single_ieee_be(value, buffer)
4754}
4755
4756/// Encode a COMP-2 float with default IEEE-754 big-endian format.
4757///
4758/// # Errors
4759/// Returns `CBKE510_NUMERIC_OVERFLOW` if the buffer has fewer than 8 bytes.
4760#[inline]
4761pub fn encode_float_double(value: f64, buffer: &mut [u8]) -> Result<()> {
4762    encode_float_double_ieee_be(value, buffer)
4763}
4764
4765#[cfg(test)]
4766#[allow(clippy::expect_used)]
4767#[allow(clippy::unwrap_used)]
4768#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
4769mod tests {
4770    use super::*;
4771    use crate::zoned_overpunch::{ZeroSignPolicy, encode_overpunch_byte, is_valid_overpunch};
4772    use proptest::prelude::*;
4773    use proptest::test_runner::RngSeed;
4774    use std::collections::hash_map::DefaultHasher;
4775    use std::hash::{Hash, Hasher};
4776
4777    fn proptest_case_count() -> u32 {
4778        option_env!("PROPTEST_CASES")
4779            .and_then(|s| s.parse().ok())
4780            .unwrap_or(256)
4781    }
4782
4783    fn numeric_proptest_config() -> ProptestConfig {
4784        let mut cfg = ProptestConfig {
4785            cases: proptest_case_count(),
4786            max_shrink_time: 0,
4787            ..ProptestConfig::default()
4788        };
4789
4790        if let Ok(seed_value) = std::env::var("PROPTEST_SEED")
4791            && !seed_value.is_empty()
4792        {
4793            let parsed_seed = seed_value.parse::<u64>().unwrap_or_else(|_| {
4794                let mut hasher = DefaultHasher::new();
4795                seed_value.hash(&mut hasher);
4796                hasher.finish()
4797            });
4798            cfg.rng_seed = RngSeed::Fixed(parsed_seed);
4799        }
4800
4801        cfg
4802    }
4803
4804    #[test]
4805    fn test_small_decimal_normalization() {
4806        let mut decimal = SmallDecimal::new(0, 2, true);
4807        decimal.normalize();
4808        assert!(!decimal.negative); // -0 should become 0
4809    }
4810
4811    #[test]
4812    fn test_small_decimal_formatting() {
4813        // Integer format (scale=0)
4814        let decimal = SmallDecimal::new(123, 0, false);
4815        assert_eq!(decimal.to_string(), "123");
4816
4817        // Decimal format with fixed scale
4818        let decimal = SmallDecimal::new(12345, 2, false);
4819        assert_eq!(decimal.to_string(), "123.45");
4820
4821        // Negative decimal
4822        let decimal = SmallDecimal::new(12345, 2, true);
4823        assert_eq!(decimal.to_string(), "-123.45");
4824    }
4825
4826    #[test]
4827    fn test_zero_with_scale_preserves_decimal_places() {
4828        // Zero with scale=2 must produce "0.00" (not "0")
4829        let decimal = SmallDecimal::new(0, 2, false);
4830        assert_eq!(decimal.to_string(), "0.00");
4831
4832        // Zero with scale=1
4833        let decimal = SmallDecimal::new(0, 1, false);
4834        assert_eq!(decimal.to_string(), "0.0");
4835
4836        // Zero with scale=4 and negative flag (normalizes sign away)
4837        let decimal = SmallDecimal::new(0, 4, true);
4838        assert_eq!(decimal.to_string(), "0.0000");
4839    }
4840
4841    proptest! {
4842        #![proptest_config(numeric_proptest_config())]
4843        #[test]
4844        fn prop_zoned_digit_buffer_contains_only_digits(
4845            digits_vec in prop::collection::vec(0u8..=9, 1..=12),
4846            signed in any::<bool>(),
4847            allow_negative in any::<bool>(),
4848            codepage in prop_oneof![
4849                Just(Codepage::ASCII),
4850                Just(Codepage::CP037),
4851                Just(Codepage::CP273),
4852                Just(Codepage::CP500),
4853                Just(Codepage::CP1047),
4854                Just(Codepage::CP1140),
4855            ],
4856            policy in prop_oneof![Just(ZeroSignPolicy::Positive), Just(ZeroSignPolicy::Preferred)],
4857        ) {
4858            let digit_count = u16::try_from(digits_vec.len()).expect("vector length <= 12");
4859            let mut bytes = Vec::with_capacity(digits_vec.len());
4860
4861            for digit in digits_vec.iter().take(digits_vec.len().saturating_sub(1)) {
4862                let byte = if codepage.is_ascii() {
4863                    0x30 + digit
4864                } else {
4865                    0xF0 + digit
4866                };
4867                bytes.push(byte);
4868            }
4869
4870            let is_negative = signed && allow_negative;
4871            let last_digit = *digits_vec.last().expect("vector is non-empty");
4872            let last_byte = if signed {
4873                let encoded = encode_overpunch_byte(last_digit, is_negative, codepage, policy)
4874                    .expect("valid overpunch for digit 0-9");
4875                prop_assume!(is_valid_overpunch(encoded, codepage));
4876                encoded
4877            } else if codepage.is_ascii() {
4878                0x30 + last_digit
4879            } else {
4880                0xF0 + last_digit
4881            };
4882            bytes.push(last_byte);
4883
4884            let mut scratch = ScratchBuffers::new();
4885            let _ = decode_zoned_decimal_with_scratch(
4886                &bytes,
4887                digit_count,
4888                0,
4889                signed,
4890                codepage,
4891                false,
4892                &mut scratch,
4893            ).expect("decoding constructed zoned bytes should succeed");
4894
4895            prop_assert_eq!(scratch.digit_buffer.len(), digits_vec.len());
4896            prop_assert!(scratch.digit_buffer.iter().all(|&d| d <= 9));
4897            prop_assert_eq!(&scratch.digit_buffer[..], &digits_vec[..]);
4898        }
4899    }
4900
4901    #[test]
4902    fn test_zoned_decimal_blank_when_zero() {
4903        // EBCDIC spaces (0x40)
4904        let data = vec![0x40, 0x40, 0x40];
4905        let result = decode_zoned_decimal(&data, 3, 0, false, Codepage::CP037, true).unwrap();
4906        assert_eq!(result.to_string(), "0");
4907
4908        // ASCII spaces
4909        let data = vec![b' ', b' ', b' '];
4910        let result = decode_zoned_decimal(&data, 3, 0, false, Codepage::ASCII, true).unwrap();
4911        assert_eq!(result.to_string(), "0");
4912    }
4913
4914    #[test]
4915    fn test_packed_decimal_signs() {
4916        // Positive packed decimal: 123C (123 positive)
4917        let data = vec![0x12, 0x3C];
4918        let result = decode_packed_decimal(&data, 3, 0, true).unwrap();
4919        assert_eq!(result.to_string(), "123");
4920
4921        // Negative packed decimal: 123D (123 negative)
4922        let data = vec![0x12, 0x3D];
4923        let result = decode_packed_decimal(&data, 3, 0, true).unwrap();
4924        assert_eq!(result.to_string(), "-123");
4925
4926        // Test the failing case from property tests: -11 (2 digits)
4927        // Test that round-trip encoding/decoding preserves the sign
4928        let encoded = encode_packed_decimal("-11", 2, 0, true).unwrap();
4929        let result = decode_packed_decimal(&encoded, 2, 0, true).unwrap();
4930        assert_eq!(
4931            result.to_string(),
4932            "-11",
4933            "Failed to round-trip -11 correctly"
4934        );
4935
4936        // Test that the old buggy format is now rejected
4937        let data = vec![0x11, 0xDD]; // Invalid format with sign in both nibbles
4938        let result = decode_packed_decimal(&data, 2, 0, true);
4939        assert!(
4940            result.is_err(),
4941            "Should reject invalid format with sign in both nibbles"
4942        );
4943    }
4944
4945    #[test]
4946    fn test_binary_int_big_endian() {
4947        // 16-bit big-endian: 0x0123 = 291
4948        let data = vec![0x01, 0x23];
4949        let result = decode_binary_int(&data, 16, false).unwrap();
4950        assert_eq!(result, 291);
4951
4952        // 32-bit big-endian: 0x01234567 = 19088743
4953        let data = vec![0x01, 0x23, 0x45, 0x67];
4954        let result = decode_binary_int(&data, 32, false).unwrap();
4955        assert_eq!(result, 19_088_743);
4956    }
4957
4958    #[test]
4959    fn test_alphanumeric_encoding() {
4960        // ASCII encoding with padding
4961        let result = encode_alphanumeric("HELLO", 10, Codepage::ASCII).unwrap();
4962        assert_eq!(result, b"HELLO     ");
4963
4964        // Over-length should error
4965        let result = encode_alphanumeric("HELLO WORLD", 5, Codepage::ASCII);
4966        assert!(result.is_err());
4967    }
4968
4969    #[test]
4970    fn test_bwz_policy() {
4971        // Zero values should trigger BWZ
4972        assert!(should_encode_as_blank_when_zero("0", true));
4973        assert!(should_encode_as_blank_when_zero("0.00", true));
4974        assert!(should_encode_as_blank_when_zero("0.000", true));
4975
4976        // Non-zero values should not trigger BWZ
4977        assert!(!should_encode_as_blank_when_zero("1", true));
4978        assert!(!should_encode_as_blank_when_zero("0.01", true));
4979
4980        // BWZ disabled should never trigger
4981        assert!(!should_encode_as_blank_when_zero("0", false));
4982    }
4983
4984    #[test]
4985    fn test_binary_width_mapping() {
4986        // Test digit-to-width mapping (NORMATIVE)
4987        assert_eq!(get_binary_width_from_digits(1), 16); // ≤4 → 2B
4988        assert_eq!(get_binary_width_from_digits(4), 16); // ≤4 → 2B
4989        assert_eq!(get_binary_width_from_digits(5), 32); // 5-9 → 4B
4990        assert_eq!(get_binary_width_from_digits(9), 32); // 5-9 → 4B
4991        assert_eq!(get_binary_width_from_digits(10), 64); // 10-18 → 8B
4992        assert_eq!(get_binary_width_from_digits(18), 64); // 10-18 → 8B
4993    }
4994
4995    #[test]
4996    fn test_explicit_binary_width_validation() {
4997        // Valid explicit widths
4998        assert_eq!(validate_explicit_binary_width(1).unwrap(), 8);
4999        assert_eq!(validate_explicit_binary_width(2).unwrap(), 16);
5000        assert_eq!(validate_explicit_binary_width(4).unwrap(), 32);
5001        assert_eq!(validate_explicit_binary_width(8).unwrap(), 64);
5002
5003        // Invalid explicit widths
5004        assert!(validate_explicit_binary_width(3).is_err());
5005        assert!(validate_explicit_binary_width(16).is_err());
5006    }
5007
5008    #[test]
5009    fn test_zoned_decimal_with_bwz() {
5010        // BWZ enabled with zero value should return spaces
5011        let result =
5012            encode_zoned_decimal_with_bwz("0", 3, 0, false, Codepage::ASCII, true).unwrap();
5013        assert_eq!(result, vec![b' ', b' ', b' ']);
5014
5015        // BWZ disabled with zero value should return normal encoding
5016        let result =
5017            encode_zoned_decimal_with_bwz("0", 3, 0, false, Codepage::ASCII, false).unwrap();
5018        assert_eq!(result, vec![0x30, 0x30, 0x30]); // ASCII "000"
5019
5020        // Non-zero value should return normal encoding regardless of BWZ
5021        let result =
5022            encode_zoned_decimal_with_bwz("123", 3, 0, false, Codepage::ASCII, true).unwrap();
5023        assert_eq!(result, vec![0x31, 0x32, 0x33]); // ASCII "123"
5024    }
5025
5026    #[test]
5027    fn test_error_handling_invalid_numeric_inputs() {
5028        // Test packed decimal with invalid input - should return specific CBKD error
5029        let invalid_data = vec![0xFF]; // Invalid packed decimal
5030        let result = decode_packed_decimal(&invalid_data, 2, 0, false);
5031        assert!(
5032            result.is_err(),
5033            "Invalid packed decimal should return error"
5034        );
5035
5036        let error = result.unwrap_err();
5037        assert!(
5038            error.to_string().contains("CBKD"),
5039            "Error should be CBKD code"
5040        );
5041
5042        // Test binary int with insufficient data
5043        let short_data = vec![0x01]; // Only 1 byte for 4-byte int
5044        let result = decode_binary_int(&short_data, 32, false);
5045        assert!(
5046            result.is_err(),
5047            "Insufficient binary data should return error"
5048        );
5049
5050        // Test zoned decimal with invalid characters
5051        let invalid_zoned = b"12X"; // Contains non-digit
5052        let result = decode_zoned_decimal(invalid_zoned, 3, 0, false, Codepage::ASCII, false);
5053        assert!(result.is_err(), "Invalid zoned decimal should return error");
5054
5055        // Test alphanumeric encoding with oversized input
5056        let result = encode_alphanumeric("TOOLONGFORFIELD", 5, Codepage::ASCII);
5057        assert!(
5058            result.is_err(),
5059            "Oversized alphanumeric should return error"
5060        );
5061
5062        let error = result.unwrap_err();
5063        assert!(
5064            error.to_string().contains("CBKE"),
5065            "Error should be CBKE code"
5066        );
5067    }
5068
5069    #[test]
5070    fn test_boundary_conditions_numeric_operations() {
5071        // Test maximum values for different data types
5072
5073        // Test maximum packed decimal
5074        let max_packed_bytes = vec![0x99, 0x9C]; // 999 positive (3 digits)
5075        let result = decode_packed_decimal(&max_packed_bytes, 3, 0, true);
5076        assert!(
5077            result.is_ok(),
5078            "Valid maximum packed decimal should succeed"
5079        );
5080
5081        // Test zero packed decimal
5082        let zero_packed = vec![0x00, 0x0C]; // 00 positive
5083        let result = decode_packed_decimal(&zero_packed, 2, 0, true);
5084        assert!(result.is_ok(), "Zero packed decimal should succeed");
5085
5086        // Test edge case with maximum binary values
5087        let max_u16_bytes = vec![0xFF, 0xFF];
5088        let result = decode_binary_int(&max_u16_bytes, 16, false);
5089        assert!(result.is_ok(), "Maximum unsigned 16-bit should succeed");
5090
5091        let max_signed_16_bytes = vec![0x7F, 0xFF];
5092        let result = decode_binary_int(&max_signed_16_bytes, 16, true);
5093        assert!(result.is_ok(), "Maximum signed 16-bit should succeed");
5094
5095        // Test edge case with minimum signed values
5096        let min_i16_bytes = vec![0x80, 0x00];
5097        let result = decode_binary_int(&min_i16_bytes, 16, true);
5098        assert!(result.is_ok(), "Minimum signed 16-bit should succeed");
5099    }
5100
5101    #[test]
5102    fn test_comp3_decimal_scale_fix() {
5103        // Test case for PIC S9(7)V99 COMP-3 with decimal positioning fix
5104        let input_value = "123.45";
5105        let digits = 9; // 7 integer + 2 decimal = 9 total digits
5106        let scale = 2; // 2 decimal places
5107        let signed = true;
5108
5109        // Test round-trip encoding/decoding
5110        let encoded_data = encode_packed_decimal(input_value, digits, scale, signed).unwrap();
5111        let decoded = decode_packed_decimal(&encoded_data, digits, scale, signed).unwrap();
5112
5113        assert_eq!(decoded.to_string(), "123.45", "COMP-3 round-trip failed");
5114
5115        // Test negative case
5116        let negative_value = "-999.99";
5117        let encoded_neg = encode_packed_decimal(negative_value, digits, scale, signed).unwrap();
5118        let decoded_neg = decode_packed_decimal(&encoded_neg, digits, scale, signed).unwrap();
5119
5120        assert_eq!(
5121            decoded_neg.to_string(),
5122            "-999.99",
5123            "Negative COMP-3 round-trip failed"
5124        );
5125    }
5126
5127    #[test]
5128    fn test_error_path_coverage_arithmetic_operations() {
5129        // Test SmallDecimal creation and basic operations
5130        let decimal = SmallDecimal::new(i64::MAX, 0, false);
5131        assert_eq!(decimal.value, i64::MAX);
5132        assert_eq!(decimal.scale, 0);
5133        assert!(!decimal.negative);
5134
5135        // Test boundary conditions for large values
5136        let large_decimal = SmallDecimal::new(999_999_999, 0, false);
5137        assert_eq!(large_decimal.value, 999_999_999);
5138
5139        // Test boundary conditions for scale normalization
5140        let mut small_decimal = SmallDecimal::new(1, 10, false);
5141        small_decimal.normalize(); // Should handle high scale
5142        assert!(small_decimal.scale >= 0);
5143
5144        // Test signed/unsigned conversions with boundary values
5145        let negative_decimal = SmallDecimal::new(-1, 0, true);
5146        assert!(
5147            negative_decimal.is_negative(),
5148            "Signed negative should be negative"
5149        );
5150
5151        let positive_decimal = SmallDecimal::new(1, 0, false);
5152        assert!(
5153            !positive_decimal.is_negative(),
5154            "Unsigned should not be negative"
5155        );
5156    }
5157}