//! Faithful port of the GnuCOBOL `MOVE` byte conversions for the sealed type pairs:
//! DISPLAY→DISPLAY, DISPLAY→PACKED (COMP-3 encode), PACKED→DISPLAY (COMP-3 decode).
//!
//! Citations: `cob_move` dispatch (`move.c:1446`), `store_common_region` (`move.c:147`),
//! `cob_move_display_to_display` (`move.c:372`), `cob_move_display_to_packed` (`move.c:477`),
//! `cob_move_packed_to_display` (`move.c:582`). Control flow mirrors upstream, including its
//! one-byte-past read in the packing loop (cleaned by the trailing `&= 0xf0`, faithfully
//! reproduced) — here every such read is **bounds-guarded** to a `0`, which yields the identical
//! result because that nibble is always cleaned. Evidence kind: **source_port**.
//!
//! ## What COBOL MOVE actually does (read this first)
//!
//! `MOVE source TO dest` is *not* a byte copy: it is a **category conversion**. Every COBOL data
//! item has a category derived from its PICTURE — alphanumeric (PIC X), numeric (PIC 9 / S9 with a
//! USAGE that picks a physical encoding), numeric-edited (PIC with insertion/floating symbols),
//! and group (a record with no elementary PIC of its own, treated as a raw byte run). MOVE first
//! decides which category pair it is dealing with, then applies that pair's *own* placement rule.
//! The receiver's PICTURE — not the sender's — owns the result: the value is reshaped to fit the
//! receiver, padding or truncating as the receiver demands.
//!
//! ### The two master placement rules
//!
//! * **Alphanumeric receivers are LEFT-justified, SPACE-padded.** The source string is laid down
//!   starting at the left byte; if the source is shorter, the surplus right-hand bytes are filled
//!   with ASCII space (`0x20`); if longer, the right-hand excess is dropped. `JUSTIFIED RIGHT` on
//!   the receiver flips this: the string is anchored at the right byte, padding/truncation happen
//!   on the *left*. Space is the fill — never zero — because the receiver holds text, not a number.
//!
//! * **Numeric receivers are aligned by the DECIMAL POINT, ZERO-padded, then signed.** The sender
//!   and receiver each have an implied decimal position (their `scale` = number of fractional
//!   digits). MOVE lines those two points up and copies digit-for-digit outward in both directions
//!   from the point. Integer digits the receiver cannot hold are truncated on the **left** (high
//!   order — silent high-order truncation is a defined COBOL behavior, not an error); fraction
//!   digits the receiver cannot hold are truncated on the **right** (low order); positions the
//!   receiver has but the sender does not are filled with the digit `'0'`. Fill is the character
//!   `'0'` (`0x30`) for zoned DISPLAY, or the nibble `0` for packed — never space, because the
//!   receiver holds a number. The sign is handled separately at the end (see below).
//!
//! ### Sign handling
//!
//! Numeric DISPLAY carries its sign as an *overpunch* baked into one zone half-byte of a digit (or
//! as a separate leading/trailing `+`/`-` byte). Before any digit math the sign is **stripped** so
//! the digits are clean ASCII `0`..`9`; after placement the receiver's own sign convention is
//! re-applied. If the receiver is **unsigned** (PIC 9, no `S`), the sign is simply dropped — a
//! negative value silently stores as its magnitude. Packed COMP-3 keeps the sign in its final
//! low nibble (`0xC` = positive, `0xD` = negative, `0xF` = unsigned). COMP-6 has *no* sign nibble
//! at all and is always non-negative.
//!
//! ### The `P` scaling picture and the `scale` field
//!
//! A PICTURE digit `P` is an implied position outside the stored digits (e.g. `99PPP` scales the
//! two stored digits up by 1000; `PPP99` scales them down). In this port `scale` is the count of
//! implied fractional positions: positive `scale` = that many real stored fraction digits;
//! **negative** `scale` = trailing `P`s that multiply the value but occupy no storage. The digit
//! routines therefore compute an *effective* digit count of `digits + scale` when `scale < 0`, and
//! the alphanumeric path materializes the negative-scale `P`s as literal `'0'` bytes on the right.
//!
//! ### This module's role
//!
//! These functions are the byte-level engine underneath the COBOL `MOVE` verb: one helper per
//! category pair, plus the [`cob_move`] dispatcher that classifies the pair and routes to the
//! right helper (failing **closed** on any pair not yet sealed rather than guessing). They operate
//! directly on the raw field storage slices — there is no intermediate "value" type for the
//! DISPLAY/PACKED paths; the digits *are* the representation, so correctness is a matter of exact
//! byte placement. All out-of-range buffer access is bounds-guarded so a self-inconsistent field
//! attribute fails closed instead of corrupting memory or panicking.

use crate::attr::{
    FieldAttr, COB_TYPE_ALPHANUMERIC, COB_TYPE_GROUP, COB_TYPE_NUMERIC_DISPLAY, COB_TYPE_NUMERIC_PACKED,
};
use crate::error::DecimalError;
use crate::sign;

/// Bounds-guarded read: out-of-range index yields `0` (never panics on hostile attrs).
///
/// Upstream C addresses field bytes through raw pointers and can momentarily read one byte past
/// the field (see the module header's note on the packing loop); the value of any such stray read
/// is always discarded or cleaned, so returning `0` here is behaviorally identical to the C while
/// being memory-safe. The `i64` index lets callers do pointer-style arithmetic (which can go
/// transiently negative) without casting; a negative index is treated as out of range.
#[inline]
fn rd(buf: &[u8], idx: i64) -> u8 {
    if idx >= 0 && (idx as usize) < buf.len() {
        buf[idx as usize]
    } else {
        0
    }
}

/// Bounds-guarded write: out-of-range index is dropped (the C never writes past `f->size`;
/// this only differs for self-inconsistent attrs, where it fails closed instead of corrupting).
///
/// A *well-formed* field never produces an out-of-range write index here, so on real inputs this
/// is exactly the C store; the guard only fires for a hostile/inconsistent attribute, where
/// silently dropping the write is the fail-closed choice (no buffer overflow, no panic).
#[inline]
fn wr(buf: &mut [u8], idx: i64, val: u8) {
    if idx >= 0 && (idx as usize) < buf.len() {
        buf[idx as usize] = val;
    }
}

/// Guarded write into a `fdata` region that starts at `off` within `full`.
#[inline]
fn wr_off(full: &mut [u8], off: usize, didx: i64, fsize: i64, val: u8) {
    if didx >= 0 && didx < fsize {
        let abs = off as i64 + didx;
        wr(full, abs, val);
    }
}

/// The scale-aligned copy window of `store_common_region` (`move.c:147-164`), as a pure function
/// so its index bounds can be Kani-proved as the single source of truth. Returns
/// `(dst_start, src_start, len)` for the digit-copy, or `None` when the scales do not overlap.
///
/// **Sharp invariant** (proved in [`crate::kani_harness::store_window_is_in_bounds`]): whenever
/// this returns `Some((d, s, n))`, the window stays inside both buffers for *all* integer inputs —
/// `0 <= d` and `d + n <= fsize`, and `0 <= s` and `s + n <= size`. This holds because
/// `gcf = min(hf1, hf2)` and `lcf = max(lf1, lf2)`, so `d + n = hf2 - lcf <= hf2 - lf2 = fsize`
/// and `s + n = hf1 - lcf <= hf1 - lf1 = size`. It is the exact bound that would be violated if the
/// scale arithmetic regressed.
pub(crate) fn region_window(
    size: i64,
    scale: i64,
    fsize: i64,
    dst_scale: i64,
) -> Option<(i64, i64, i64)> {
    let lf1 = -scale;
    let lf2 = -dst_scale;
    let lcf = lf1.max(lf2); // cob_max_int (move.c:155)
    let hf1 = size + lf1;
    let hf2 = fsize + lf2;
    let gcf = hf1.min(hf2); // cob_min_int (move.c:159)
    if gcf > lcf {
        Some((hf2 - gcf, hf1 - gcf, gcf - lcf))
    } else {
        None
    }
}

/// `store_common_region` (`move.c:147`): copy `size` source digit-bytes into the destination
/// field's data region via the scale-aligned [`region_window`]; `verified` selects the raw-copy
/// path (`move.c:172`) vs the digit-cleaning path (`move.c:174-187`).
fn store_common_region(
    dst_full: &mut [u8],
    dst_attr: &FieldAttr,
    data: &[u8],
    size: i64,
    scale: i64,
    verified: bool,
) {
    let off = dst_attr.data_offset();
    let fsize = dst_attr.data_size(dst_full.len()) as i64;

    // pre-set the whole target to '0' (move.c:166)
    for k in 0..fsize {
        wr_off(dst_full, off, k, fsize, b'0');
    }

    if let Some((d0, s0, n)) = region_window(size, scale, fsize, dst_attr.scale as i64) {
        for j in 0..n {
            let didx = d0 + j;
            let sidx = s0 + j;
            let sd = rd(data, sidx);
            if verified {
                wr_off(dst_full, off, didx, fsize, sd);
            } else if sd == b' ' || sd == 0 {
                // already '0' (move.c:185)
            } else {
                wr_off(dst_full, off, didx, fsize, sign::i2d(sd.wrapping_sub(b'0')));
            }
        }
    }
}

/// `cob_move_display_to_display` (`move.c:372`): strip the source sign, store the clean digits,
/// re-apply the sign to the destination. (Upstream also restores `f1`; that is invisible to the
/// destination bytes, so the port works on a copy of the source.)
fn cob_move_display_to_display(
    src_full: &[u8],
    src_attr: &FieldAttr,
    dst_full: &mut [u8],
    dst_attr: &FieldAttr,
) {
    let mut tmp = src_full.to_vec();
    let s = sign::display_get_sign_strip(&mut tmp, src_attr); // COB_GET_SIGN (move.c:374)
    let s_off = src_attr.data_offset();
    let s_size = src_attr.data_size(src_full.len()) as i64;
    store_common_region(
        dst_full,
        dst_attr,
        tmp.get(s_off..).unwrap_or(&[]), // fail closed on degenerate size < offset
        s_size,
        src_attr.scale as i64,
        false,
    );
    sign::display_put_sign(dst_full, dst_attr, s); // COB_PUT_SIGN (f2) (move.c:380)
}

/// `cob_move_display_to_packed` (`move.c:477`): pack zoned/display digits into COMP-3 nibbles,
/// then set the sign nibble. PACKED fields have no leading-separate offset, so the destination is
/// addressed via the full slice (`f2->data`, `f2->size`), exactly as upstream.
fn cob_move_display_to_packed(src_full: &[u8], src_attr: &FieldAttr, dst: &mut [u8], dst_attr: &FieldAttr) {
    let s_off = src_attr.data_offset();
    let data1 = src_full.get(s_off..).unwrap_or(&[]); // fail closed on degenerate size < offset
    let s = sign::display_get_sign_adjust_readonly(src_full, src_attr); // COB_GET_SIGN_ADJUST (move.c:480)

    let scale1 = src_attr.scale as i64;
    let scale2 = dst_attr.scale as i64;
    let tnsn = dst_attr.no_sign_nibble();

    let digits1 = if scale1 >= 0 {
        src_attr.digits as i64
    } else {
        src_attr.digits as i64 + scale1 // 99P (move.c:497)
    };
    let digits2 = if scale2 >= 0 {
        dst_attr.digits as i64
    } else {
        dst_attr.digits as i64 + scale2
    };

    let mut i: i64 = if tnsn { digits2 % 2 } else { 1 - digits2 % 2 }; // move.c:501-505

    // p = data1 + (digits1 - scale1) - (digits2 - scale2)  (move.c:511)
    let mut p: i64 = (digits1 - scale1) - (digits2 - scale2);
    while p < 0 {
        p += 1;
        i += 1; // skip not-available positions (move.c:513-515)
    }

    // zero out target (move.c:518)
    for b in dst.iter_mut() {
        *b = 0;
    }

    let i_end: i64 = dst.len() as i64;
    let p_end: i64 = digits1;
    let mut q: i64 = i / 2;

    if i % 2 == 1 {
        wr(dst, q, sign::d2i(rd(data1, p))); // *q++ = COB_D2I(*p++) (move.c:525)
        q += 1;
        p += 1;
        i += 1;
    }
    i /= 2; // i now counts target bytes (move.c:533)

    if i_end - i < (p_end - p + 1) / 2 {
        // truncating loop, bounded by target (move.c:543-550)
        while i < i_end {
            let hi = rd(data1, p) << 4;
            let lo = sign::d2i(rd(data1, p + 1));
            wr(dst, q, hi.wrapping_add(lo));
            q += 1;
            i += 1;
            p += 2;
        }
    } else {
        // consume all source digits (move.c:551-557)
        while p < p_end {
            let hi = rd(data1, p) << 4;
            let lo = sign::d2i(rd(data1, p + 1));
            wr(dst, q, hi.wrapping_add(lo));
            q += 1;
            p += 2;
        }
    }
    if p > p_end {
        // clean bottom nibble of the extra-packed digit (move.c:560)
        wr(dst, q - 1, rd(dst, q - 1) & 0xf0);
    }

    // COB_PUT_SIGN_ADJUSTED is a no-op on an ASCII host (move.c:563).
    if tnsn {
        return; // COMP-6 has no sign nibble (move.c:566)
    }
    let last = dst.len() as i64 - 1;
    let cur = rd(dst, last);
    if !dst_attr.have_sign() {
        wr(dst, last, cur | 0x0F); // move.c:574
    } else if s < 0 {
        wr(dst, last, (cur & 0xF0) | 0x0D); // move.c:576
    } else {
        wr(dst, last, (cur & 0xF0) | 0x0C); // move.c:578
    }
}

/// `cob_move_packed_to_display` (`move.c:582`): unpack COMP-3 / COMP-6 nibbles to display digits
/// (skipping leading zeros exactly as upstream), store, then set the destination sign.
fn cob_move_packed_to_display(src: &[u8], src_attr: &FieldAttr, dst_full: &mut [u8], dst_attr: &FieldAttr) {
    let mut buff = [0u8; (crate::COB_MAX_DIGITS + 1) as usize];
    let mut b: usize = 0;
    let mut di: i64 = 0;
    let d_end: i64 = src.len() as i64 - 1; // d + f1->size - 1 (move.c:587)
    let scale = src_attr.scale as i64;
    let mut digits = if scale >= 0 {
        src_attr.digits as i64
    } else {
        src_attr.digits as i64 + scale // 99P (move.c:596)
    };

    let push = |buff: &mut [u8], b: &mut usize, v: u8| {
        if *b < buff.len() {
            buff[*b] = v;
            *b += 1;
        }
    };

    if src_attr.no_sign_nibble() {
        // COMP-6 (move.c:604-635)
        let offset = digits % 2;
        if offset == 1 {
            let start = rd(src, di) & 0x0F;
            di += 1;
            if start != 0 {
                push(&mut buff, &mut b, sign::i2d(start));
            } else {
                digits -= 1;
                while di <= d_end && rd(src, di) == 0 {
                    digits -= 2;
                    di += 1;
                }
            }
        } else {
            while di <= d_end && rd(src, di) == 0 {
                digits -= 2;
                di += 1;
            }
        }
        while di <= d_end {
            push(&mut buff, &mut b, sign::i2d(rd(src, di) >> 4));
            push(&mut buff, &mut b, sign::i2d(rd(src, di) & 0x0F));
            di += 1;
        }
        store_common_region(dst_full, dst_attr, &buff[..b], digits, scale, true);
        sign::display_put_sign(dst_full, dst_attr, 0); // COB_PUT_SIGN (f2, 0) (move.c:633)
    } else {
        // PACKED-DECIMAL / COMP-3 (move.c:637-668)
        let offset = 1 - digits % 2;
        if offset == 1 {
            let start = rd(src, di) & 0x0F;
            di += 1;
            if start != 0 {
                push(&mut buff, &mut b, sign::i2d(start));
            } else {
                digits -= 1;
                while di < d_end && rd(src, di) == 0 {
                    digits -= 2;
                    di += 1;
                }
            }
        } else {
            while di < d_end && rd(src, di) == 0 {
                digits -= 2;
                di += 1;
            }
        }
        while di < d_end {
            push(&mut buff, &mut b, sign::i2d(rd(src, di) >> 4));
            push(&mut buff, &mut b, sign::i2d(rd(src, di) & 0x0F));
            di += 1;
        }
        push(&mut buff, &mut b, sign::i2d(rd(src, di) >> 4)); // last high nibble (move.c:663)
        store_common_region(dst_full, dst_attr, &buff[..b], digits, scale, true);
        let s = if (rd(src, di) & 0x0F) == 0x0D { -1 } else { 1 }; // (move.c:666)
        sign::display_put_sign(dst_full, dst_attr, s);
    }
}

/// `cob_move_alphanum_to_alphanum` (`move.c:444`): the classic string MOVE — left-justified by
/// default (truncate/space-pad on the right), or `JUSTIFIED RIGHT` (truncate/space-pad on the left).
fn cob_move_alphanum_to_alphanum(data1: &[u8], dst: &mut [u8], dst_attr: &FieldAttr) {
    let size1 = data1.len();
    let size2 = dst.len();
    if size1 >= size2 {
        if dst_attr.justified() {
            dst.copy_from_slice(&data1[size1 - size2..]);
        } else {
            dst.copy_from_slice(&data1[..size2]);
        }
    } else if dst_attr.justified() {
        let pad = size2 - size1;
        for b in &mut dst[..pad] {
            *b = b' ';
        }
        dst[pad..].copy_from_slice(data1);
    } else {
        dst[..size1].copy_from_slice(data1);
        for b in &mut dst[size1..] {
            *b = b' ';
        }
    }
}

/// `cob_move_display_to_alphanum` (`move.c:384`): numeric DISPLAY -> alphanumeric. The overpunch sign
/// is cleaned to a plain digit (`COB_GET_SIGN`), implied `P` positions become `'0'`, and the result is
/// left- or right-justified with space padding. (`f1`'s sign is restored in C; invisible to `dst`.)
fn cob_move_display_to_alphanum(src_full: &[u8], src_attr: &FieldAttr, dst: &mut [u8], dst_attr: &FieldAttr) {
    let mut tmp = src_full.to_vec();
    let _ = sign::display_get_sign_strip(&mut tmp, src_attr); // COB_GET_SIGN: de-overpunch the digits
    let off = src_attr.data_offset();
    let size1 = src_attr.data_size(src_full.len());
    let data1 = tmp.get(off..off + size1).unwrap_or(&[]);
    let size1 = data1.len();
    let mut size2 = dst.len();
    let zero_size = if (src_attr.scale as i32) < 0 { (-(src_attr.scale as i32)) as usize } else { 0 };

    if dst_attr.justified() {
        let mut zs = zero_size;
        if zs != 0 {
            zs = zs.min(size2);
            size2 -= zs;
            for b in &mut dst[size2..size2 + zs] {
                *b = b'0';
            }
        }
        if size2 != 0 {
            let diff = size2 as i64 - size1 as i64;
            let (d2, s2) = if diff > 0 {
                for b in &mut dst[..diff as usize] {
                    *b = b' ';
                }
                (diff as usize, size2 - diff as usize)
            } else {
                (0usize, size2)
            };
            dst[d2..d2 + s2].copy_from_slice(&data1[size1 - s2..size1]);
        }
    } else {
        let diff = size2 as i64 - size1 as i64;
        if diff < 0 {
            dst[..size2].copy_from_slice(&data1[..size2]);
        } else {
            dst[..size1].copy_from_slice(data1);
            let mut diff = diff as usize;
            let mut zs = 0usize;
            if zero_size != 0 {
                zs = zero_size.min(diff);
                for b in &mut dst[size1..size1 + zs] {
                    *b = b'0';
                }
                diff -= zs;
            }
            if diff != 0 {
                for b in &mut dst[size1 + zs..size1 + zs + diff] {
                    *b = b' ';
                }
            }
        }
    }
}

/// `cob_move_alphanum_to_display` (`move.c:293`): parse free-form alphanumeric text into a numeric
/// DISPLAY field — skip leading whitespace, optional `+`/`-`, count integer digits, right-align by
/// the receiver scale, copy digits (ignoring whitespace and the thousands separator), and on any
/// invalid character zero-fill the receiver. `dec_pt`/`num_sep` default to `'.'`/`','`.
fn cob_move_alphanum_to_display(data1: &[u8], dst: &mut [u8], dst_attr: &FieldAttr) {
    cob_move_alphanum_to_display_cfg(data1, dst, dst_attr, b'.', b',');
}

/// As [`cob_move_alphanum_to_display`], but with the decimal point / thousands separator supplied by the
/// module configuration (move.c reads `dec_pt`/`num_sep` from `COB_MODULE_PTR`). Under `DECIMAL-POINT IS
/// COMMA` the roles swap: `dec_pt = ','`, `num_sep = '.'`, so `MOVE "12,34"` to a numeric receiver stores
/// 12.34 rather than parsing 1234.
fn cob_move_alphanum_to_display_cfg(
    data1: &[u8],
    dst: &mut [u8],
    dst_attr: &FieldAttr,
    dec_pt: u8,
    num_sep: u8,
) {
    let off = dst_attr.data_offset();
    let fsize = dst_attr.data_size(dst.len());
    // memset(f2->data, '0', f2->size)
    for b in dst.iter_mut() {
        *b = b'0';
    }
    let e1 = data1.len();
    let mut s1 = 0usize;
    // skip whitespace
    while s1 < e1 && data1[s1].is_ascii_whitespace() {
        s1 += 1;
    }
    // sign
    let mut sign = 0i32;
    if s1 != e1 && (data1[s1] == b'+' || data1[s1] == b'-') {
        sign = if data1[s1] == b'+' { 1 } else { -1 };
        s1 += 1;
    }
    // count integer digits (before decimal point)
    let mut count = 0i64;
    {
        let mut p = s1;
        while p < e1 && data1[p] != dec_pt {
            if data1[p].is_ascii_digit() {
                count += 1;
            }
            p += 1;
        }
    }
    // destination digit window [off .. off+fsize); s2 walks it
    let mut s2 = off;
    let e2 = off + fsize;
    let size = fsize as i64 - dst_attr.scale as i64;
    if count < size {
        s2 += (size - count) as usize;
    } else {
        let mut c = count;
        while c > size {
            c -= 1;
            while s1 < e1 && !data1[s1].is_ascii_digit() {
                s1 += 1;
            }
            s1 += 1;
        }
    }
    // move
    let mut dpcount = 0i32;
    let mut error = false;
    while s1 < e1 && s2 < e2 {
        let ch = data1[s1];
        if ch.is_ascii_digit() {
            wr(dst, s2 as i64, ch);
            s2 += 1;
        } else if ch == dec_pt {
            dpcount += 1;
            if dpcount > 1 {
                error = true;
                break;
            }
        } else if !(ch.is_ascii_whitespace() || ch == num_sep) {
            error = true;
            break;
        }
        s1 += 1;
    }
    if error {
        for b in dst.iter_mut() {
            *b = b'0';
        }
        sign::display_put_sign(dst, dst_attr, 0);
        return;
    }
    sign::display_put_sign(dst, dst_attr, sign);
}

/// `cob_move_ibm (dst, src, len)` (move.c:1411): copy `len` bytes left-to-right (the IBM `MVC`
/// instruction). For non-aliasing Rust slices this is a plain copy.
pub fn cob_move_ibm(dst: &mut [u8], src: &[u8], len: usize) {
    dst[..len].copy_from_slice(&src[..len]);
}

/// A self-overlapping reference-modification MOVE -- a field onto a shifted slice of *itself*, within one
/// `buf` -- honoring the `move-ibm` dialect knob. When `move_ibm` (ibm/mvs), the IBM `MVC` byte-by-byte
/// LEFT-TO-RIGHT copy: a forward overlap (`dst_off > src_off`) PROPAGATES, since each destination byte is
/// read after earlier ones were written. Otherwise (default/mf) the snapshot (memmove) copy, as if the
/// source were captured first. Oracle (`01 A PIC X(8) VALUE "ABCDEFGH"`, `MOVE A(1:5) TO A(3:5)`):
/// default `ABABCDEH`, ibm `ABABABAH`.
pub fn cob_move_overlap(buf: &mut [u8], dst_off: usize, src_off: usize, len: usize, move_ibm: bool) {
    if move_ibm {
        for i in 0..len {
            buf[dst_off + i] = buf[src_off + i];
        }
    } else {
        buf.copy_within(src_off..src_off + len, dst_off);
    }
}

/// `cob_init_table (tbl, len, occ)` (move.c:1426): propagate the first `len`-byte element through an
/// `occ`-occurrence table by doubling copies (used by `INITIALIZE`). `tbl` is the full `len*occ` buffer
/// with the seed element in `tbl[..len]`.
pub fn cob_init_table(tbl: &mut [u8], len: usize, occ: usize) {
    if occ < 1 {
        return;
    }
    let mut m = len;
    let mut i = len;
    let mut j = 1usize;
    loop {
        j *= 2;
        let (front, rest) = tbl.split_at_mut(m);
        rest[..i].copy_from_slice(&front[..i]);
        m += i;
        i *= 2;
        if j * 2 >= occ {
            break;
        }
    }
    if j < occ {
        let copy = len * (occ - j);
        let (front, rest) = tbl.split_at_mut(m);
        rest[..copy].copy_from_slice(&front[..copy]);
    }
}

/// `cob_move_all (src, dst)` (move.c:1356): the figurative `MOVE ALL pattern` — fill `dst` by repeating
/// the source bytes. Alphanumeric/edited receivers fill directly; numeric receivers fill a display temp
/// and re-`cob_move`. `src` is the (1+ byte) pattern.
fn cob_move_all(src: &[u8], dst: &mut [u8], dst_attr: &FieldAttr) -> Result<(), DecimalError> {
    use crate::attr::{COB_TYPE_ALPHANUMERIC, COB_TYPE_ALPHANUMERIC_EDITED, COB_TYPE_NUMERIC_BINARY};
    let ft = dst_attr.field_type;
    let is_alnum = matches!(ft, COB_TYPE_ALPHANUMERIC | COB_TYPE_ALPHANUMERIC_EDITED | COB_TYPE_GROUP | crate::attr::COB_TYPE_ALPHANUMERIC_ALL);
    if is_alnum {
        if src.len() == 1 {
            for b in dst.iter_mut() {
                *b = src[0];
            }
        } else {
            for i in 0..dst.len() {
                dst[i] = src[i % src.len()];
            }
        }
        return Ok(());
    }
    let is_numeric = matches!(ft, COB_TYPE_NUMERIC_DISPLAY | COB_TYPE_NUMERIC_PACKED | COB_TYPE_NUMERIC_BINARY | 0x13..=0x1B);
    let max = crate::COB_MAX_DIGITS as usize;
    if !is_numeric {
        // numeric-edited: MOVE ALL is an alphanumeric fill into a display temp, then move
        let digcount = dst.len();
        let mut p = vec![0u8; digcount];
        fill(&mut p, src);
        let tattr = FieldAttr { field_type: COB_TYPE_ALPHANUMERIC, digits: digcount as u16, scale: 0, flags: 0 };
        return cob_move(&p, &tattr, dst, dst_attr);
    }
    let digcount = if src.len() == 1 { max } else { max };
    let mut p = vec![0u8; digcount];
    fill(&mut p, src);
    let tattr = FieldAttr { field_type: COB_TYPE_NUMERIC_DISPLAY, digits: digcount as u16, scale: 0, flags: crate::attr::COB_FLAG_HAVE_SIGN };
    cob_move(&p, &tattr, dst, dst_attr)
}

fn fill(buf: &mut [u8], src: &[u8]) {
    if src.len() == 1 {
        for b in buf.iter_mut() {
            *b = src[0];
        }
    } else {
        for i in 0..buf.len() {
            buf[i] = src[i % src.len()];
        }
    }
}

/// `indirect_move (func, src, dst, size, scale)` (move.c:1332): route `src` through a `size`/`scale`
/// signed-DISPLAY intermediate (produced by `func`) before `cob_move`-ing into `dst` — used when a
/// non-display source must be edited/stored via the display path.
#[allow(dead_code)] // wired by the edited-MOVE batch (display_to_edited / edited_to_display routing)
fn indirect_move<F>(func: F, src: &[u8], sa: &FieldAttr, dst: &mut [u8], da: &FieldAttr, size: usize, scale: i32) -> Result<(), DecimalError>
where
    F: FnOnce(&[u8], &FieldAttr, &mut [u8], &FieldAttr),
{
    let mut buff = vec![0u8; size];
    let tattr = FieldAttr { field_type: COB_TYPE_NUMERIC_DISPLAY, digits: size as u16, scale: scale as i16, flags: crate::attr::COB_FLAG_HAVE_SIGN };
    func(src, sa, &mut buff, &tattr);
    cob_move(&buff, &tattr, dst, da)
}

/// `cob_binary_mget_sint64 (f)` (move.c:196): read a BINARY field as a signed 64-bit integer (endian
/// from `COB_FLAG_BINARY_SWAP`, sign-extended for signed fields). Equals [`crate::binary::binary_decode`].
pub fn cob_binary_mget_sint64(data: &[u8], attr: &FieldAttr) -> i64 {
    crate::binary::binary_decode(data, attr) as i64
}

/// `cob_binary_mget_uint64 (f)` (move.c:231): read a BINARY field as an unsigned 64-bit integer
/// (no sign extension), honoring `COB_FLAG_BINARY_SWAP`.
pub fn cob_binary_mget_uint64(data: &[u8], attr: &FieldAttr) -> u64 {
    let n = data.len().min(8);
    let swap = attr.flags & crate::attr::COB_FLAG_BINARY_SWAP != 0;
    let mut u: u64 = 0;
    if swap {
        for &b in data.iter().take(n) {
            u = (u << 8) | b as u64;
        }
    } else {
        for &b in data.iter().take(n).rev() {
            u = (u << 8) | b as u64;
        }
    }
    u
}

/// `cob_binary_mset_sint64 (f, n)` (move.c:254): write the low `f.size` bytes of `n` into a BINARY
/// field with the field's endianness (a raw store — no PIC-digit truncation).
pub fn cob_binary_mset_sint64(out: &mut [u8], attr: &FieldAttr, n: i64) {
    let size = out.len().min(8);
    let swap = attr.flags & crate::attr::COB_FLAG_BINARY_SWAP != 0;
    if swap {
        out[..size].copy_from_slice(&n.to_be_bytes()[8 - size..]);
    } else {
        out[..size].copy_from_slice(&n.to_le_bytes()[..size]);
    }
}

/// `cob_binary_mset_uint64 (f, n)` (move.c:284): unsigned form of [`cob_binary_mset_sint64`].
pub fn cob_binary_mset_uint64(out: &mut [u8], attr: &FieldAttr, n: u64) {
    cob_binary_mset_sint64(out, attr, n as i64);
}

fn pow10_ll(n: i32) -> i64 {
    10i64.checked_pow(n.max(0) as u32).unwrap_or(i64::MAX)
}

fn bin_trunc_digits(attr: &FieldAttr) -> i32 {
    let scale = attr.scale as i32;
    if scale >= 0 {
        attr.digits as i32
    } else {
        attr.digits as i32 + scale
    }
}

/// `cob_move_binary_to_binary (f1, f2)` (move.c:709): binary -> binary, value-preserving (scale
/// ignored), with optional PIC-digit truncation on the receiver.
fn cob_move_binary_to_binary(s: &[u8], sa: &FieldAttr, d: &mut [u8], da: &FieldAttr) {
    let trunc = da.flags & crate::attr::COB_FLAG_BINARY_TRUNC != 0;
    if sa.have_sign() {
        let mut sval = cob_binary_mget_sint64(s, sa);
        let sign = sval < 0;
        if trunc {
            sval %= pow10_ll(bin_trunc_digits(da));
        }
        if da.have_sign() {
            cob_binary_mset_sint64(d, da, sval);
        } else if sign {
            cob_binary_mset_uint64(d, da, (sval as i128).unsigned_abs() as u64);
        } else {
            cob_binary_mset_uint64(d, da, sval as u64);
        }
    } else {
        let mut uval = cob_binary_mget_uint64(s, sa);
        if trunc {
            uval %= pow10_ll(bin_trunc_digits(da)) as u64;
        }
        if da.have_sign() {
            cob_binary_mset_sint64(d, da, uval as i64);
        } else {
            cob_binary_mset_uint64(d, da, uval);
        }
    }
}

/// `cob_move_display_to_binary (f1, f2)` (move.c:763): parse the zoned digits (scale-aligned to the
/// receiver) into an integer, then store it in the binary receiver. Overflow (>19 digits) routes
/// through the decimal layer.
fn cob_move_display_to_binary(s: &[u8], sa: &FieldAttr, d: &mut [u8], da: &FieldAttr) -> Result<(), DecimalError> {
    let off = sa.data_offset();
    let size1 = sa.data_size(s.len());
    let data1 = s.get(off..off + size1).unwrap_or(&[]);
    let size = (size1 as i64 - sa.scale as i64 + da.scale as i64).max(0) as usize;
    let trunc = da.flags & crate::attr::COB_FLAG_BINARY_TRUNC != 0;
    let mut target_digits: i64 = if trunc {
        let scale = da.scale as i32;
        let digits = da.digits as i32;
        let t = if scale > digits {
            digits
        } else if scale > 0 {
            digits
        } else {
            digits + scale
        };
        (t as i64).min(size as i64)
    } else {
        size as i64
    };
    let mut i = (size as i64 - target_digits).max(0) as usize;
    while i < size1 {
        if sign::d2i(data1[i]) != 0 {
            break;
        }
        target_digits -= 1;
        i += 1;
    }
    if target_digits > 19 {
        // overflow -> decimal layer
        let bytes = crate::cob_decimal::cob_decimal_setget_fld(s, sa, da, d.len(), crate::arith::Round::Truncate).map_err(|_| DecimalError::UnsupportedConversion { src_type: sa.field_type, dst_type: da.field_type })?;
        d.copy_from_slice(&bytes);
        return Ok(());
    }
    let mut tmp = s.to_vec();
    let sgn = sign::display_get_sign_strip(&mut tmp, sa); // COB_GET_SIGN_ADJUST (value re-read below)
    let data1 = tmp.get(off..off + size1).unwrap_or(&[]);
    let mut val: u64 = 0;
    while i < size {
        if i < size1 {
            val = val * 10 + sign::d2i(data1[i]) as u64;
        } else {
            val *= 10;
        }
        i += 1;
    }
    if da.have_sign() {
        let v2 = if sgn < 0 { -(val as i64) } else { val as i64 };
        cob_binary_mset_sint64(d, da, v2);
    } else {
        cob_binary_mset_uint64(d, da, val);
    }
    Ok(())
}

/// `cob_move_binary_to_display (f1, f2)` (move.c:836): decode the binary value to a digit string and
/// store it (scale-aligned) into the display receiver, with sign.
fn cob_move_binary_to_display(s: &[u8], sa: &FieldAttr, d: &mut [u8], da: &FieldAttr) {
    let mut sign = 1i32;
    let val: u64 = if sa.have_sign() {
        let v2 = cob_binary_mget_sint64(s, sa);
        if v2 < 0 {
            sign = -1;
            (v2 as i128).unsigned_abs() as u64
        } else {
            v2 as u64
        }
    } else {
        cob_binary_mget_uint64(s, sa)
    };
    let mut buff = [0u8; 32];
    let mut i = 20usize;
    let mut v = val;
    while v > 0 {
        i -= 1;
        buff[i] = sign::i2d((v % 10) as u8);
        v /= 10;
    }
    store_common_region(d, da, &buff[i..20], (20 - i) as i64, sa.scale as i64, true);
    sign::display_put_sign(d, da, sign);
}

/// `cob_move_fp_to_fp (src, dst)` (move.c:673): convert between FLOAT (f32) / DOUBLE (f64) /
/// L_DOUBLE (x87 80-bit) by widening to the largest then narrowing to the target.
fn cob_move_fp_to_fp(s: &[u8], sa: &FieldAttr, d: &mut [u8], da: &FieldAttr) {
    let dval: f64 = match sa.field_type {
        0x13 => f32::from_le_bytes(s[..4].try_into().unwrap_or([0; 4])) as f64,
        0x14 => f64::from_le_bytes(s[..8].try_into().unwrap_or([0; 8])),
        _ => crate::cob_decimal::extended80_to_f64(s),
    };
    match da.field_type {
        0x13 => d[..4].copy_from_slice(&(dval as f32).to_le_bytes()),
        0x14 => d[..8].copy_from_slice(&dval.to_le_bytes()),
        _ => {
            let e = crate::cob_decimal::f64_to_extended80(dval);
            let n = d.len().min(e.len());
            d[..n].copy_from_slice(&e[..n]);
        }
    }
}

/// `cob_move` under `DISPLAY SIGN IS EBCDIC` (the module `ebcdic_sign` flag, set by `-fsign=EBCDIC` /
/// a mainframe dialect). GnuCOBOL's `cob_real_get_sign`/`cob_real_put_sign` (common.c:3712/3763) branch
/// on that flag and read/write an EBCDIC overpunch on the (otherwise ASCII) sign byte. Rather than
/// thread the flag through the whole move dispatch, this wrapper converts the *sign byte* at the two
/// boundaries: the EBCDIC-overpunched source sign -> ASCII before the (unchanged) ASCII `cob_move`, then
/// the ASCII destination sign -> EBCDIC after. The result is byte-identical to libcob's threaded path
/// (e.g. PIC S9(3) := -12 stores `01K`, := +12 stores `01B`), and the default ASCII `cob_move` and every
/// sealed move sweep are untouched. Only the overpunched (non-separate) signed DISPLAY sign is affected.
pub fn cob_move_ebcdic_sign(
    src: &[u8],
    src_attr: &FieldAttr,
    dst: &mut [u8],
    dst_attr: &FieldAttr,
) -> Result<(), DecimalError> {
    use crate::common_sign::{cob_real_get_sign, cob_real_put_sign, SignFlags};
    let overpunched = |a: &FieldAttr| {
        a.field_type == COB_TYPE_NUMERIC_DISPLAY && a.have_sign() && !a.sign_separate()
    };
    let flags = |a: &FieldAttr, ebcdic: bool| SignFlags {
        have_sign: a.have_sign(),
        sign_separate: a.sign_separate(),
        sign_leading: a.flags & crate::attr::COB_FLAG_SIGN_LEADING != 0,
        ebcdic_sign: ebcdic,
    };
    // Source: re-encode the EBCDIC overpunch sign byte to ASCII so the ASCII pipeline reads it.
    let mut src_buf = src.to_vec();
    if overpunched(src_attr) {
        let s = cob_real_get_sign(COB_TYPE_NUMERIC_DISPLAY as u8, &mut src_buf, flags(src_attr, true), false);
        cob_real_put_sign(COB_TYPE_NUMERIC_DISPLAY as u8, &mut src_buf, flags(src_attr, false), s.signum());
    }
    cob_move(&src_buf, src_attr, dst, dst_attr)?;
    // Destination: re-encode the ASCII overpunch sign byte to EBCDIC.
    if overpunched(dst_attr) {
        let s = cob_real_get_sign(COB_TYPE_NUMERIC_DISPLAY as u8, dst, flags(dst_attr, false), false);
        cob_real_put_sign(COB_TYPE_NUMERIC_DISPLAY as u8, dst, flags(dst_attr, true), s.signum());
    }
    Ok(())
}

/// `cob_move` (`move.c:1446`), restricted to the sealed elementary type pairs. Any other pair
/// **fails closed** with [`DecimalError::UnsupportedConversion`] rather than guessing.
///
/// `src` / `dst` are the full field byte storages; the field "size" is the slice length (as
/// `cob_field.size` in upstream). `dst` is zeroed/overwritten in place.
pub fn cob_move(
    src: &[u8],
    src_attr: &FieldAttr,
    dst: &mut [u8],
    dst_attr: &FieldAttr,
) -> Result<(), DecimalError> {
    if dst.is_empty() {
        return Ok(()); // dst->size == 0 (move.c:1455)
    }
    // Figurative `ALL` source (move.c:1469): repeat the pattern across the receiver.
    if src_attr.field_type == crate::attr::COB_TYPE_ALPHANUMERIC_ALL {
        return cob_move_all(src, dst, dst_attr);
    }
    // Non-elementary move: a GROUP on either side is a raw alphanumeric copy (move.c:1473).
    if src_attr.field_type == COB_TYPE_GROUP || dst_attr.field_type == COB_TYPE_GROUP {
        cob_move_alphanum_to_alphanum(src, dst, dst_attr);
        return Ok(());
    }
    use crate::attr::COB_TYPE_NUMERIC_BINARY;
    let src_bin = src_attr.field_type == COB_TYPE_NUMERIC_BINARY;
    let dst_bin = dst_attr.field_type == COB_TYPE_NUMERIC_BINARY;

    // Binary MOVE leaves (move.c): the named per-pair routines. DISPLAY<->BINARY and BINARY<->BINARY
    // are direct; BINARY<->PACKED (and other numeric pairs) route through the decimal layer, as the
    // C dispatch does via cob_decimal_setget_fld.
    if src_bin && dst_bin {
        cob_move_binary_to_binary(src, src_attr, dst, dst_attr);
        return Ok(());
    }
    if dst_bin {
        if src_attr.field_type == COB_TYPE_NUMERIC_DISPLAY {
            return cob_move_display_to_binary(src, src_attr, dst, dst_attr);
        }
        let bytes = crate::cob_decimal::cob_decimal_setget_fld(src, src_attr, dst_attr, dst.len(), crate::arith::Round::Truncate)
            .map_err(|_| DecimalError::UnsupportedConversion { src_type: src_attr.field_type, dst_type: dst_attr.field_type })?;
        dst.copy_from_slice(&bytes);
        return Ok(());
    }
    if src_bin {
        if dst_attr.field_type == COB_TYPE_NUMERIC_DISPLAY {
            cob_move_binary_to_display(src, src_attr, dst, dst_attr);
            return Ok(());
        }
        let bytes = crate::cob_decimal::cob_decimal_setget_fld(src, src_attr, dst_attr, dst.len(), crate::arith::Round::Truncate)
            .map_err(|_| DecimalError::UnsupportedConversion { src_type: src_attr.field_type, dst_type: dst_attr.field_type })?;
        dst.copy_from_slice(&bytes);
        return Ok(());
    }

    // Floating-point leaves (move.c): float<->float widen/narrow; float<->decimal via the decimal layer.
    let is_fp = |a: &FieldAttr| matches!(a.field_type, 0x13 | 0x14 | 0x15);
    if is_fp(src_attr) && is_fp(dst_attr) {
        cob_move_fp_to_fp(src, src_attr, dst, dst_attr);
        return Ok(());
    }
    if is_fp(dst_attr) {
        // encode: src -> decimal -> the float receiver (no recursion through cob_move)
        let d = crate::cob_decimal::cob_decimal_set_field(src, src_attr);
        let v = crate::cob_decimal::cob_decimal_get_double(&d);
        match dst_attr.field_type {
            0x13 => dst[..4].copy_from_slice(&(v as f32).to_le_bytes()),
            0x14 => dst[..8].copy_from_slice(&v.to_le_bytes()),
            _ => {
                let e = crate::cob_decimal::f64_to_extended80(v);
                let n = dst.len().min(10);
                dst[..n].copy_from_slice(&e[..n]);
            }
        }
        return Ok(());
    }
    if is_fp(src_attr) {
        let bytes = crate::cob_decimal::cob_decimal_setget_fld(src, src_attr, dst_attr, dst.len(), crate::arith::Round::Truncate)
            .map_err(|_| DecimalError::UnsupportedConversion { src_type: src_attr.field_type, dst_type: dst_attr.field_type })?;
        dst.copy_from_slice(&bytes);
        return Ok(());
    }

    match (src_attr.field_type, dst_attr.field_type) {
        (COB_TYPE_NUMERIC_DISPLAY, COB_TYPE_NUMERIC_DISPLAY) => {
            cob_move_display_to_display(src, src_attr, dst, dst_attr);
            Ok(())
        }
        (COB_TYPE_NUMERIC_DISPLAY, COB_TYPE_NUMERIC_PACKED) => {
            cob_move_display_to_packed(src, src_attr, dst, dst_attr);
            Ok(())
        }
        (COB_TYPE_NUMERIC_PACKED, COB_TYPE_NUMERIC_DISPLAY) => {
            cob_move_packed_to_display(src, src_attr, dst, dst_attr);
            Ok(())
        }
        // Alphanumeric leaves (move.c default src/dst dispatch).
        (COB_TYPE_NUMERIC_DISPLAY, COB_TYPE_ALPHANUMERIC) => {
            cob_move_display_to_alphanum(src, src_attr, dst, dst_attr);
            Ok(())
        }
        (COB_TYPE_ALPHANUMERIC, COB_TYPE_NUMERIC_DISPLAY) => {
            cob_move_alphanum_to_display(src, dst, dst_attr);
            Ok(())
        }
        (COB_TYPE_ALPHANUMERIC, COB_TYPE_ALPHANUMERIC) => {
            cob_move_alphanum_to_alphanum(src, dst, dst_attr);
            Ok(())
        }
        _ => Err(DecimalError::UnsupportedConversion {
            src_type: src_attr.field_type,
            dst_type: dst_attr.field_type,
        }),
    }
}

/// [`cob_move`] honoring the module's `DECIMAL-POINT IS COMMA` setting. Only the alphanumeric ->
/// numeric-DISPLAY leaf is separator-sensitive (move.c reads `dec_pt`/`num_sep` from `COB_MODULE_PTR`
/// there); every other leaf is separator-independent, so this delegates to [`cob_move`] unless the
/// comma swap actually applies.
pub fn cob_move_cfg(
    src: &[u8],
    src_attr: &FieldAttr,
    dst: &mut [u8],
    dst_attr: &FieldAttr,
    decimal_comma: bool,
) -> Result<(), DecimalError> {
    if decimal_comma
        && src_attr.field_type == COB_TYPE_ALPHANUMERIC
        && dst_attr.field_type == COB_TYPE_NUMERIC_DISPLAY
    {
        // DECIMAL-POINT IS COMMA: ',' is the decimal point, '.' the thousands separator.
        cob_move_alphanum_to_display_cfg(src, dst, dst_attr, b',', b'.');
        return Ok(());
    }
    cob_move(src, src_attr, dst, dst_attr)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::attr::{COB_FLAG_BINARY_SWAP, COB_FLAG_HAVE_SIGN};

    #[test]
    fn cob_move_cfg_decimal_comma_alphanum_to_numeric() {
        // MOVE "12,34" (alphanumeric) -> PIC 9(2)V99 (digits=4, scale=2). Under DECIMAL-POINT IS COMMA the
        // comma is the decimal point: store 1234 as 12.34 -> "1234". With the default dot, ',' is a
        // thousands separator and there is no point, so "12,34" parses as integer 1234 truncated to the
        // 2 integer digits -> 34.00 -> "3400" (the built-cobc oracle behaviour, matched both ways).
        let src = b"12,34";
        let sattr = FieldAttr { field_type: COB_TYPE_ALPHANUMERIC, digits: 5, scale: 0, flags: 0 };
        let dattr = FieldAttr { field_type: COB_TYPE_NUMERIC_DISPLAY, digits: 4, scale: 2, flags: 0 };
        let mut comma = vec![0u8; 4];
        cob_move_cfg(src, &sattr, &mut comma, &dattr, true).unwrap();
        assert_eq!(&comma, b"1234", "comma = decimal point -> 12.34");
        let mut dot = vec![0u8; 4];
        cob_move_cfg(src, &sattr, &mut dot, &dattr, false).unwrap();
        assert_eq!(&dot, b"3400", "dot mode: comma is a separator, 1234 truncates to 34.00");
    }

    #[test]
    fn move_ibm_overlap_matches_dialect_oracle() {
        // `01 A PIC X(8) VALUE "ABCDEFGH"`, `MOVE A(1:5) TO A(3:5)` -- src=A[0..5], dst=A[2..7].
        // default/mf (snapshot): ABABCDEH; ibm/mvs (propagating MVC): ABABABAH (built-cobc oracle).
        let mut a = *b"ABCDEFGH";
        cob_move_overlap(&mut a, 2, 0, 5, crate::dialect::Dialect::DEFAULT.move_ibm);
        assert_eq!(&a, b"ABABCDEH");

        let mut a = *b"ABCDEFGH";
        cob_move_overlap(&mut a, 2, 0, 5, crate::dialect::Dialect::IBM.move_ibm);
        assert_eq!(&a, b"ABABABAH");

        // mf follows default (no move-ibm); mvs follows ibm.
        let mut a = *b"ABCDEFGH";
        cob_move_overlap(&mut a, 2, 0, 5, crate::dialect::Dialect::MF.move_ibm);
        assert_eq!(&a, b"ABABCDEH");
        let mut a = *b"ABCDEFGH";
        cob_move_overlap(&mut a, 2, 0, 5, crate::dialect::Dialect::MVS.move_ibm);
        assert_eq!(&a, b"ABABABAH");
    }

    #[test]
    fn ebcdic_sign_move_vs_cobc() {
        // PIC S9(n) under DISPLAY SIGN IS EBCDIC (-fsign=EBCDIC). The built GnuCOBOL oracle encodes
        // (trailing sign byte overpunched, others plain ASCII): -12 in S9(3) = "01K", +12 = "01B".
        let s9 = |n: u16| FieldAttr {
            field_type: COB_TYPE_NUMERIC_DISPLAY,
            digits: n,
            scale: 0,
            flags: COB_FLAG_HAVE_SIGN,
        };
        // round-trip the exact EBCDIC encodings (reads + writes the overpunch)
        let mut d = vec![0u8; 3];
        cob_move_ebcdic_sign(b"01K", &s9(3), &mut d, &s9(3)).unwrap();
        assert_eq!(d, b"01K", "EBCDIC -12 round-trips");
        let mut d2 = vec![0u8; 3];
        cob_move_ebcdic_sign(b"01B", &s9(3), &mut d2, &s9(3)).unwrap();
        assert_eq!(d2, b"01B", "EBCDIC +12 round-trips");
        // value-preserving widen: EBCDIC -12 (S9(3)) -> S9(5) must be "0001K" (magnitude 12, sign -)
        let mut dw = vec![0u8; 5];
        cob_move_ebcdic_sign(b"01K", &s9(3), &mut dw, &s9(5)).unwrap();
        assert_eq!(dw, b"0001K", "EBCDIC -12 widened to S9(5)");
    }

    fn bin(size_signed: bool, swap: bool) -> FieldAttr {
        let mut flags = 0u16;
        if size_signed {
            flags |= COB_FLAG_HAVE_SIGN;
        }
        if swap {
            flags |= COB_FLAG_BINARY_SWAP;
        }
        FieldAttr { field_type: crate::attr::COB_TYPE_NUMERIC_BINARY, digits: 9, scale: 0, flags }
    }

    #[test]
    fn binary_mset_mget_uint64_roundtrip_native() {
        // Native little-endian 4-byte COMP-5: store then read back unsigned.
        let attr = bin(false, false);
        let mut buf = [0u8; 4];
        cob_binary_mset_uint64(&mut buf, &attr, 0x0102_0304);
        assert_eq!(buf, [0x04, 0x03, 0x02, 0x01]); // little-endian byte order
        assert_eq!(cob_binary_mget_uint64(&buf, &attr), 0x0102_0304);
    }

    #[test]
    fn binary_mset_uint64_swap_is_big_endian() {
        // COB_FLAG_BINARY_SWAP stores big-endian.
        let attr = bin(false, true);
        let mut buf = [0u8; 4];
        cob_binary_mset_uint64(&mut buf, &attr, 0x0102_0304);
        assert_eq!(buf, [0x01, 0x02, 0x03, 0x04]); // big-endian byte order
        assert_eq!(cob_binary_mget_uint64(&buf, &attr), 0x0102_0304);
    }

    #[test]
    fn binary_mset_sint64_signed_roundtrip() {
        // Signed store of a negative value round-trips through the signed getter.
        let attr = bin(true, false);
        let mut buf = [0u8; 8];
        cob_binary_mset_sint64(&mut buf, &attr, -12345);
        assert_eq!(cob_binary_mget_sint64(&buf, &attr), -12345);
    }

    #[test]
    fn move_ibm_copies_len_bytes() {
        // cob_move_ibm is the IBM MVC left-to-right byte copy.
        let src = *b"HELLO";
        let mut dst = [0u8; 5];
        cob_move_ibm(&mut dst, &src, 5);
        assert_eq!(&dst, b"HELLO");
        // partial length: only the first 3 bytes are copied.
        let mut dst2 = [b'.'; 5];
        cob_move_ibm(&mut dst2, &src, 3);
        assert_eq!(&dst2, b"HEL..");
    }
}



