use chrono::{DateTime, Local};
use clap::Parser;
use epics_base_rs::server::snapshot::{DbrClass, Snapshot};
use epics_base_rs::types::WallTime;
use epics_ca_rs::CaError;
use epics_ca_rs::cli::{PV_NAME_WIDTH, ValueFormat, format_value};
use epics_ca_rs::client::{CaChannel, CaClient, enum_string_readback_dbr};
use std::time::SystemTime;

fn format_server_timestamp(ts: WallTime) -> String {
    // Display only, to microseconds (`%.6f`), so converting through
    // `SystemTime` (100 ns-granular on Windows) loses nothing visible.
    let dt: DateTime<Local> = SystemTime::from(ts).into();
    dt.format("%Y-%m-%d %H:%M:%S%.6f").to_string()
}

fn sevr_to_str(sevr: u16) -> &'static str {
    match sevr {
        0 => "NO_ALARM",
        1 => "MINOR",
        2 => "MAJOR",
        3 => "INVALID",
        _ => "Illegal value",
    }
}

fn stat_to_str(stat: u16) -> &'static str {
    match stat {
        0 => "NO_ALARM",
        1 => "READ",
        2 => "WRITE",
        3 => "HIHI",
        4 => "HIGH",
        5 => "LOLO",
        6 => "LOW",
        7 => "STATE",
        8 => "COS",
        9 => "COMM",
        10 => "TIMEOUT",
        11 => "HW_LIMIT",
        12 => "CALC",
        13 => "SCAN",
        14 => "LINK",
        15 => "SOFT",
        16 => "BAD_SUB",
        17 => "UDF",
        18 => "DISABLE",
        19 => "SIMM",
        20 => "READ_ACCESS",
        21 => "WRITE_ACCESS",
        _ => "Illegal value",
    }
}

/// Print one `Old : ...` / `New : ...` line in long-mode shape:
///   `<prefix>{name-padded}<sep><ts>{sep}<value>{sep}{stat?}{sep}{sevr?}`
/// Mirrors `tool_lib.c::print_time_val_sts` — when alarm is
/// (NO_ALARM, NO_ALARM) the trailing two fields are emitted empty.
fn print_long_line(prefix: &str, name_col: &str, sep: char, snap: &Snapshot, fmt: &ValueFormat) {
    let enum_strings = snap.enums.as_ref().map(|e| e.strings.as_slice());
    // caput has no `-#` element-count flag, so req_elems is always false:
    // an array count prefix is emitted only when the array length itself
    // exceeds 1 (matches C `caget.c` / `tool_lib.c` PRN_TIME_VAL_STS gating).
    let val = format_value(&snap.value, fmt, enum_strings, false);
    let ts = format_server_timestamp(snap.timestamp);
    let stat = snap.alarm.status;
    let sevr = snap.alarm.severity;
    if stat == 0 && sevr == 0 {
        println!("{prefix}{name_col}{sep}{ts}{sep}{val}{sep}{sep}");
    } else {
        println!(
            "{prefix}{name_col}{sep}{ts}{sep}{val}{sep}{stat_str}{sep}{sevr_str}",
            stat_str = stat_to_str(stat),
            sevr_str = sevr_to_str(sevr),
        );
    }
}

const VERSION_INFO: &str = concat!(
    "\nEPICS Version epics-rs ",
    env!("CARGO_PKG_VERSION"),
    ", CA Protocol version 4.13"
);

/// Mirror of C `caput` flag set.
///
/// Note: positional grammar differs in array mode (`-a`):
///
/// * scalar (default):  `caput-rs <PV name> <value> [more values]`
/// * array (`-a`):      `caput-rs -a <PV name> <count> <v0> <v1> ...`
///
/// `value_count` is the parsed `<count>` token that prefixes the
/// values when `-a` is present.
#[derive(Parser)]
#[command(
    name = "caput-rs",
    about = "Write a value to an EPICS PV",
    disable_version_flag = true
)]
struct Args {
    #[arg(short = 'V', long, hide = true)]
    version: bool,

    /// CA timeout in seconds. Mirrors C `tool_lib.c:use_ca_timeout_env`.
    #[arg(short = 'w', long = "timeout")]
    timeout: Option<f64>,

    /// Wait for completion callback (`ca_put_callback`).
    #[arg(short = 'c', long = "callback")]
    callback: bool,

    /// CA priority (0-99). Opens the channel on the matching priority
    /// virtual circuit (libca `ca_create_channel` priority parameter).
    #[arg(short = 'p', long)]
    priority: Option<u8>,

    /// Terse output: print only the new value (no `Old :`/`New :`
    /// prefix, no PV name).
    #[arg(short = 't', long)]
    terse: bool,

    /// Long mode: post-write read prints `name timestamp value stat
    /// sevr` like `caget -a`.
    #[arg(short = 'l', long = "long")]
    long_mode: bool,

    /// Force interpretation of values as numbers (overrides ENUM
    /// auto-string-resolution). C `caput.c:298-305` makes `-n` and `-s`
    /// a mutually-exclusive pair where the LAST one given wins (each case
    /// sets its flag and clears the other), with no conflict error —
    /// `overrides_with` reproduces that last-wins semantics.
    #[arg(short = 'n', long = "num-enum", overrides_with = "force_string")]
    force_numeric: bool,

    /// Force interpretation of values as strings (overrides numeric
    /// parse for ENUM). Paired with `-n` (last one wins, caput.c:302-305).
    #[arg(short = 's', long = "string-enum", overrides_with = "force_numeric")]
    force_string: bool,

    /// Put long string as an array of chars (long-string convention).
    /// C `caput.c:306-319` makes `-S` and `-a` a mutually-exclusive pair
    /// where the LAST one given wins (each clears the other) — modelled
    /// with `overrides_with` so the two flags can never both be set.
    #[arg(short = 'S', long = "long-string", overrides_with = "array_mode")]
    long_string: bool,

    /// Put as array. The remaining positionals are
    /// `<count> <v0> <v1> ...`. Paired with `-S` (last one wins,
    /// caput.c:316-319).
    #[arg(short = 'a', long = "array", overrides_with = "long_string")]
    array_mode: bool,

    /// Alternate output field separator.
    #[arg(short = 'F', long = "field-separator", value_name = "OFS")]
    field_separator: Option<char>,

    /// Positional PV name.
    #[arg(required_unless_present_any = ["version"])]
    pv_name: Option<String>,

    /// Positional values. In `-a` mode the first element is the
    /// count, the rest are the values. Negative numeric values are
    /// allowed via `--`.
    #[arg(allow_hyphen_values = true, trailing_var_arg = true)]
    values: Vec<String>,
}

#[tokio::main]
async fn main() {
    let args = Args::parse();

    if args.version {
        println!("{VERSION_INFO}");
        return;
    }

    // -n / -s steer ENUM scalar handling below (force_numeric =
    // interpret as index; force_string = always send DBR_STRING for
    // server-side menu resolution). For non-ENUM channels they have
    // no effect — matching C `caput`, where enumAsNr / enumAsString
    // only gate the DBR_ENUM branch.

    let pv_name = args.pv_name.expect("clap enforces required");

    if args.values.is_empty() {
        eprintln!("caput-rs: missing value");
        std::process::exit(1);
    }

    let client = CaClient::new().await.expect("failed to create CA client");
    let timeout = epics_ca_rs::cli::timeout_duration(
        args.timeout
            .unwrap_or_else(epics_ca_rs::cli::env_default_timeout),
    );

    // -p selects the priority virtual circuit.
    let ch = client.create_channel_with_priority(&pv_name, args.priority.unwrap_or(0));
    if let Err(e) = ch.wait_connected(timeout).await {
        eprintln!("error: {e}");
        std::process::exit(1);
    }

    // The channel's native field type drives how the value to WRITE is
    // encoded (C `ca_field_type`, caput.c:143) — it must stay the real
    // native type even when the readback below is taken in STRING form.
    let native_type = match ch.native_field_type() {
        Ok(t) => t,
        Err(e) => {
            eprintln!("error: {e}");
            std::process::exit(1);
        }
    };
    // C caput.c:455-465: for an ENUM field, read the menu (DBR_GR_ENUM)
    // BEFORE building the write, so each value can be matched against the
    // state names (caput.c:487-494). A menu-read timeout aborts the put
    // exactly as C does (caput.c:461-465). The menu is empty for non-ENUM
    // fields, which makes `build_write_value` skip the ENUM path entirely.
    let enum_menu: Vec<epics_ca_rs::PvString> = if native_type == epics_ca_rs::DbFieldType::Enum {
        match ch.get_with_metadata(DbrClass::Gr).await {
            Ok(snap) => snap.enums.map(|e| e.strings).unwrap_or_default(),
            Err(CaError::Timeout) => {
                eprintln!("Read operation timed out: ENUM data was not read.");
                std::process::exit(1);
            }
            Err(e) => {
                eprintln!("error: {e}");
                std::process::exit(1);
            }
        }
    } else {
        Vec::new()
    };
    // C caput.c:147-152: the readback (the `Old :`/`New :` display) is
    // requested in the STRING form for an ENUM field unless `-n`, so the
    // echoed value is the state label, not the index. `-l` keeps the
    // TIME-class string so the timestamp + alarm line is still populated.
    let enum_dbr = enum_string_readback_dbr(native_type, args.long_mode, !args.force_numeric);

    // Read the pre-put value for the `Old :` display. Long mode also
    // wants the server timestamp + alarm pair captured BEFORE the put so
    // the `Old :` line reflects the actual pre-put state — the regular
    // path stays on the cheaper plain GET.
    let long_mode = args.long_mode;
    let read_display = move |ch: CaChannel| async move {
        match (long_mode, enum_dbr) {
            // Long mode + ENUM default: DBR_TIME_STRING (label + ts/alarm).
            (true, Some(rt)) => ch
                .get_with_dbr_type(rt, 0)
                .await
                .map(|s| (s.value.clone(), Some(s))),
            // Long mode, other fields: native DBR_TIME class.
            (true, None) => ch
                .get_with_metadata(DbrClass::Time)
                .await
                .map(|s| (s.value.clone(), Some(s))),
            // Plain + ENUM default: DBR_STRING (label only).
            (false, Some(rt)) => ch.get_with_dbr_type(rt, 0).await.map(|s| (s.value, None)),
            // Plain, other fields: native plain GET.
            (false, None) => ch.get_with_timeout(timeout).await.map(|(_t, v)| (v, None)),
        }
    };
    // C caput.c:532-535 gates the pre-put "Old :" read+print on
    // `if (format != terse)`. Terse mode prints only the new value, so the
    // pre-put GET must NOT be issued: C never issues it, and a PV that is slow
    // to read, read-denied before a write-side access transition, or backed by
    // an expensive/side-effecting read path must still proceed to the write.
    // The post-put read below is kept in every mode (C still calls caget()
    // after the put, caput.c:583; terse only suppresses the `New :` label).
    let (old_value, old_snap) = if args.terse {
        (None, None)
    } else {
        match read_display(ch.clone()).await {
            Ok((v, s)) => (Some(v), s),
            Err(CaError::Timeout) => {
                eprintln!("Read operation timed out: PV data was not read.");
                std::process::exit(1);
            }
            Err(e) => {
                eprintln!("error: {e}");
                std::process::exit(1);
            }
        }
    };

    // build the value to write in C's precedence order — `-S`
    // (long string) resolved before any native-type parse. See
    // `build_write_value`.
    let parsed_value = match build_write_value(
        &args.values,
        native_type,
        args.force_numeric,
        args.force_string,
        args.long_string,
        args.array_mode,
        &enum_menu,
    ) {
        Ok(v) => v,
        Err(e) => {
            eprintln!("{e}");
            std::process::exit(1);
        }
    };

    let result = match &parsed_value {
        WriteValue::Wire { dbr_type, value } => {
            // C-tool wire model: send the explicit DBR type (DBR_STRING /
            // DBR_CHAR) and let the server convert. The CLI `-w` timeout
            // owns the callback wait, matching caput.c:556-567.
            if args.callback {
                ch.put_as_dbr_with_timeout(*dbr_type, value, timeout).await
            } else {
                ch.put_as_dbr_nowait(*dbr_type, value).await
            }
        }
        WriteValue::EnumString(s) => {
            // ENUM-by-name → DBR_STRING; the server resolves the menu
            // string. Route through the same explicit-wire-type path as
            // the numeric/-S writes so the CLI `-w` timeout owns the
            // callback wait (caput.c:558-567 uses one caTimeout for every
            // dbrType), instead of put_string's EPICS_CA_PUT_TIMEOUT/30s
            // default which dropped `-w`.
            let v = epics_ca_rs::EpicsValue::String(s.clone());
            let dbr = epics_ca_rs::DbFieldType::String as u16;
            if args.callback {
                ch.put_as_dbr_with_timeout(dbr, &v, timeout).await
            } else {
                ch.put_as_dbr_nowait(dbr, &v).await
            }
        }
        WriteValue::EnumStringArray(v) => {
            // ENUM waveform by name — DBR_STRING array, server resolves
            // each element. Same single timeout owner as above.
            let arr = epics_ca_rs::EpicsValue::StringArray(v.clone());
            let dbr = epics_ca_rs::DbFieldType::String as u16;
            if args.callback {
                ch.put_as_dbr_with_timeout(dbr, &arr, timeout).await
            } else {
                ch.put_as_dbr_nowait(dbr, &arr).await
            }
        }
    };
    if let Err(e) = result {
        eprintln!("error: {e}");
        std::process::exit(1);
    }

    // Re-read for echoing to stdout (matches C caput which always reads the PV
    // back after the put, caput.c:583). Same readback type selection as the
    // `Old :` read above, so an ENUM `New :` value also echoes as the state
    // label. C returns this readback's status as caput's exit status
    // (caput.c:589): a read TIMEOUT or a total DISCONNECT must fail the command
    // (see `postput_read_fatal`), so we exit non-zero before printing rather
    // than hiding the failure behind the submitted value. Other readback errors
    // (e.g. read-access denied, which C exits 0 on) fall back to echoing the
    // submitted value and exit 0, matching C's exit code.
    let echo_fallback = parsed_value.echo_fallback();
    let (new_value, new_snap) = match read_display(ch.clone()).await {
        Ok(pair) => pair,
        Err(e) => match postput_read_fatal(&e) {
            Some(FatalReadback::Timeout) => {
                eprintln!("Read operation timed out: PV data was not read.");
                std::process::exit(1);
            }
            Some(FatalReadback::Disconnect) => {
                eprintln!("error: {e}");
                std::process::exit(1);
            }
            None => (echo_fallback.clone(), None),
        },
    };

    let mut fmt = ValueFormat::default();
    if let Some(c) = args.field_separator {
        fmt.field_separator = c;
    }
    let sep = fmt.field_separator;
    // In terse mode `old_value` is `None` (no pre-put read was issued) and the
    // rendered old value is unused; non-terse modes always carry `Some`.
    let old_rendered = old_value
        .as_ref()
        .map(|v| format_value(v, &fmt, None, false))
        .unwrap_or_default();
    let new_rendered = format_value(&new_value, &fmt, None, false);
    let is_scalar = new_value.count() == 1;
    let pad = |name: &str| -> String {
        if is_scalar && sep == ' ' {
            format!("{name:<width$}", width = PV_NAME_WIDTH)
        } else {
            name.to_string()
        }
    };

    if args.terse {
        // C `caput -t`: only the new value (no name, no Old/New).
        println!("{new_rendered}");
    } else if args.long_mode {
        // C `caput -l`: same shape as `caget -a` for both lines, using
        // the DBR_TIME snapshots captured around the put.
        let name_col = pad(&pv_name);
        match &old_snap {
            Some(s) => print_long_line("Old : ", &name_col, sep, s, &fmt),
            None => println!("Old : {name_col}{sep}*{sep}{old_rendered}{sep}{sep}"),
        }
        match &new_snap {
            Some(s) => print_long_line("New : ", &name_col, sep, s, &fmt),
            None => println!("New : {name_col}{sep}*{sep}{new_rendered}{sep}{sep}"),
        }
    } else {
        // Default: `Old : <name-padded><sep><value>` and likewise for
        // New. Mirrors C `caput.c::main` post-put echo.
        println!(
            "Old : {name}{sep}{val}",
            name = pad(&pv_name),
            val = old_rendered
        );
        println!(
            "New : {name}{sep}{val}",
            name = pad(&pv_name),
            val = new_rendered
        );
    }
}

/// What `caput-rs` will write. Like C `caput`, every value travels as an
/// explicit DBR wire type that the server converts to the native field
/// type — there is no native-binary-typed put path. Non-ENUM values go as
/// `DBR_STRING` / `DBR_CHAR`; ENUM values go as `DBR_STRING` (by name) or
/// `DBR_DOUBLE` (numeric fallback), per `caput.c:486-552`.
#[derive(Debug)]
enum WriteValue {
    /// An explicit-wire-type write: the tool picks the DBR wire type and
    /// the server converts to the native field type. C `caput` sends a
    /// non-ENUM value as `DBR_STRING` (`caput.c:540-552`) and an `-S`
    /// long string as `DBR_CHAR` (`caput.c:531-538`), never the native
    /// binary type. Routed through `CaChannel::put_as_dbr_*`.
    Wire {
        dbr_type: u16,
        value: epics_ca_rs::EpicsValue,
    },
    /// A scalar ENUM value written by name. The server resolves it
    /// against the record's menu — see `CaChannel::put_string`. Carried as
    /// a byte-preserving [`PvString`] so a non-UTF-8 escape survives.
    EnumString(epics_ca_rs::PvString),
    /// An ENUM waveform written by name — each element a `DBR_STRING`
    /// the server resolves against the record's menu. See
    /// `CaChannel::put_string_array`.
    EnumStringArray(Vec<epics_ca_rs::PvString>),
}

impl WriteValue {
    /// Value used to echo `New :` if the post-put read-back fails
    /// non-fatally (see [`postput_read_fatal`]).
    fn echo_fallback(&self) -> epics_ca_rs::EpicsValue {
        match self {
            WriteValue::Wire { value, .. } => value.clone(),
            WriteValue::EnumString(s) => epics_ca_rs::EpicsValue::String(s.clone()),
            WriteValue::EnumStringArray(v) => epics_ca_rs::EpicsValue::StringArray(v.clone()),
        }
    }
}

/// A post-put readback error that C `caput` propagates to a non-zero exit.
#[derive(Debug, PartialEq, Eq)]
enum FatalReadback {
    /// `ca_pend_io` timed out — C `caget` prints the read-timeout message and
    /// returns `ECA_TIMEOUT` (`caput.c:186-188`).
    Timeout,
    /// No channel connected for the readback — C `caget` returns `1` from its
    /// `if (!nConn) return 1` guard (`caput.c:181`).
    Disconnect,
}

/// Classify a post-put readback error by C `caput`'s exit-status contract.
///
/// C always issues `caget()` after the put and returns its result as the
/// process exit status (`caput.c:583,589`). Inside `caget()` only two
/// conditions make the status non-`ECA_NORMAL`: a `ca_pend_io` timeout
/// (`caput.c:186-188`) and "no PV connected" (`caput.c:181`). A read whose
/// `ca_array_get` fails *synchronously* — most notably read-access-denied,
/// which libca rejects client-side with `ECA_NORDACCESS` before any I/O is
/// outstanding — leaves `ca_pend_io` at `ECA_NORMAL`; `caget()` prints a
/// `*** ...` marker for that PV but still returns success, so C exits `0`
/// (`caput.c:200-206`). This classifier therefore marks ONLY `Timeout` and
/// `Disconnected`/`Shutdown` as fatal; every other readback error is
/// non-fatal (echo the submitted value, exit `0`), matching C's exit code.
/// Returns `None` for the non-fatal case.
fn postput_read_fatal(err: &CaError) -> Option<FatalReadback> {
    match err {
        CaError::Timeout => Some(FatalReadback::Timeout),
        CaError::Disconnected | CaError::Shutdown => Some(FatalReadback::Disconnect),
        _ => None,
    }
}

/// Port of EPICS `epicsStrnRawFromEscaped` (`libcom/.../epicsString.c`):
/// decode C escape sequences in `s` to their raw byte values.
///
/// `\a \b \f \n \r \t \v` → the control byte; `\\ \' \"` → the literal
/// char; `\0` → a NUL byte; `\xH`/`\xHH` → the hex byte (1-2 digits, a
/// following non-hex char is re-processed normally); any other `\c` → the
/// literal `c`. A trailing lone `\`, or a literal NUL in the input, stops
/// decoding. Note C does NOT decode multi-digit octal — only `\0`.
///
/// `caput` feeds string- and char-array-destined values through this so a
/// value like `'a\tb'` is sent with a real TAB byte, matching the C tool
/// (`caput.c:487,512,520`); the pre-fix Rust left `\n`/`\xNN` literal.
fn raw_from_escaped(s: &str) -> Vec<u8> {
    let b = s.as_bytes();
    let mut out = Vec::with_capacity(b.len());
    let mut i = 0;
    'next: while i < b.len() {
        let mut c = b[i];
        i += 1;
        loop {
            if c == 0 {
                // Literal NUL in the input stops decoding (C `if (!c)`).
                return out;
            }
            if c != b'\\' {
                out.push(c);
                continue 'next;
            }
            // Backslash: a trailing lone `\` stops decoding.
            if i >= b.len() {
                return out;
            }
            c = b[i];
            i += 1;
            match c {
                b'a' => out.push(0x07),
                b'b' => out.push(0x08),
                b'f' => out.push(0x0C),
                b'n' => out.push(b'\n'),
                b'r' => out.push(b'\r'),
                b't' => out.push(b'\t'),
                b'v' => out.push(0x0B),
                b'\\' => out.push(b'\\'),
                b'\'' => out.push(b'\''),
                b'"' => out.push(b'"'),
                b'0' => out.push(0),
                b'x' => {
                    // 1-2 hex digits; a following non-hex char is
                    // re-processed normally (C `goto input`).
                    if i >= b.len() {
                        return out;
                    }
                    c = b[i];
                    i += 1;
                    let Some(hi) = (c as char).to_digit(16) else {
                        // Not a hex digit: re-process `c` from the top.
                        continue;
                    };
                    let u = hi as u8;
                    if i >= b.len() {
                        out.push(u);
                        return out;
                    }
                    c = b[i];
                    i += 1;
                    match (c as char).to_digit(16) {
                        Some(lo) => out.push(u << 4 | lo as u8),
                        None => {
                            // One hex digit, then re-process `c` (C `goto
                            // input`); a NUL `c` is caught at the loop top
                            // and stops decoding, matching C `goto done`.
                            out.push(u);
                            continue;
                        }
                    }
                }
                other => out.push(other),
            }
            continue 'next;
        }
    }
    out
}

/// Max raw payload of a CA `DBR_STRING` element: `MAX_STRING_SIZE` (40)
/// minus the trailing NUL. C `caput` decodes each string/ENUM-name value
/// into a fixed `EpicsStr[MAX_STRING_SIZE]` buffer, so at most 39 raw
/// bytes survive (`epicsStrnRawFromEscaped` writes while `--rem > 0` from
/// `rem = 40`, then NUL-terminates — epicsString.c:55-118).
const DBR_STRING_PAYLOAD_MAX: usize = 39;

/// `raw_from_escaped` decoded into a byte-preserving [`PvString`] for the
/// DBR_STRING / ENUM-by-name put paths. The common escapes (`\n`, `\t`,
/// `\\`, …) decode to ASCII; a high-byte `\xNN` reaches the wire as its
/// literal byte — `EpicsValue::String` now carries raw bytes ([`PvString`]),
/// matching C's byte buffer with no UTF-8 lossy fixup.
///
/// The decoded byte run is truncated to [`DBR_STRING_PAYLOAD_MAX`] bytes, the
/// way C `caput` decodes into its fixed `EpicsStr` buffer and forces a
/// trailing NUL (caput.c:484-489 for ENUM names, caput.c:523-528 for
/// native DBR_STRING): an overlong CLI value is written as its 39-byte
/// prefix, not rejected. The Rust client's `validate_put_strings` /
/// libca's `nciu::stringVerify` reject `>= 40`, so the cap must happen in
/// the CLI builder to keep C-tool parity. `-S` long strings take the
/// DBR_CHAR path (`raw_from_escaped`, not this helper) and stay uncapped.
/// The cut is byte-oriented, exactly matching C's byte buffer — no UTF-8
/// char-boundary fixup, since the value is bytes, not text.
fn raw_from_escaped_string(s: &str) -> epics_ca_rs::PvString {
    let mut bytes = raw_from_escaped(s);
    bytes.truncate(DBR_STRING_PAYLOAD_MAX);
    epics_ca_rs::PvString::from_bytes(bytes)
}

/// Build the value to write, in C `caput`'s precedence order
/// (`caput.c:454-530`): the ENUM field type is handled FIRST, then `-S`
/// (charArrAsStr → DBR_CHAR) for a NON-ENUM PV, then the DBR_STRING /
/// numeric paths. `-a` (array) resets charArrAsStr in C (`caput.c:318`),
/// so the array path takes precedence over `-S`. String- and char-array-
/// destined values are escape-decoded via [`raw_from_escaped`]; numeric
/// values are parsed from the raw token (C runs `epicsStrtod` on the
/// original argv). In `-a` mode the leading count token is skipped
/// without parsing (C `caput.c:413-418`); a per-element parse failure
/// returns `Err` with the full message for the caller to print.
///
/// For an ENUM field `enum_menu` is the record's state-name list
/// (`DBR_GR_ENUM`, read up front by the caller as C does at
/// `caput.c:459`). C classifies each value against that menu — a name
/// that matches a state goes as `DBR_STRING`, otherwise it falls back to
/// a number sent as `DBR_DOUBLE` (`caput.c:486-508`). The menu is empty
/// for non-ENUM fields.
fn build_write_value(
    values: &[String],
    native_type: epics_ca_rs::DbFieldType,
    force_numeric: bool,
    force_string: bool,
    long_string: bool,
    array_mode: bool,
    enum_menu: &[epics_ca_rs::PvString],
) -> Result<WriteValue, String> {
    if array_mode {
        // C `caput -a` (caput.c:413-418): after the PV name it skips the
        // count token (`optind++`) WITHOUT parsing it, then derives the
        // real count from `argc - optind`. The token is purely
        // informational positional-compatibility syntax — C never
        // validates it against the supplied values, never errors on a
        // non-numeric token, and reaches the write path with `count == 0`
        // when no values follow. Mirror that: skip `values[0]` silently
        // and let `values[1..]` (possibly empty) flow to the write, so a
        // zero-count put is decided by the server/libca, not by CLI
        // argument parsing.
        let tokens = &values[1..];
        // ENUM waveform: classify each element against the menu exactly as
        // the scalar path does (C `caput.c:467-509` runs the same per-value
        // loop for any count). Build one consistent wire type for the whole
        // array — see `build_enum_array`.
        if native_type == epics_ca_rs::DbFieldType::Enum {
            return build_enum_array(tokens, force_numeric, force_string, enum_menu);
        }
        // Non-ENUM array: C sends every element as a DBR_STRING after
        // epicsStrnRawFromEscaped (caput.c:540-552), regardless of the
        // native numeric or string field type — the server converts each.
        let escaped: Vec<epics_ca_rs::PvString> =
            tokens.iter().map(|t| raw_from_escaped_string(t)).collect();
        return Ok(WriteValue::Wire {
            dbr_type: epics_ca_rs::DbFieldType::String as u16,
            value: epics_ca_rs::EpicsValue::StringArray(escaped),
        });
    }

    // Scalar: C `caput` joins extra positionals with single spaces.
    let joined = values.join(" ");

    // (1) ENUM field type is handled FIRST (caput.c:455), BEFORE `-S` —
    // charArrAsStr never applies to an ENUM PV. C reads the menu and
    // classifies the value against it (`classify_enum_token`): a state
    // name goes as DBR_STRING, a number as DBR_DOUBLE. Sending a numeric-
    // looking *label* (e.g. "1" where state 1 is named "1") as a native
    // index would silently mean the wrong state — the menu match prevents
    // that (caput.c:486-508).
    if native_type == epics_ca_rs::DbFieldType::Enum {
        return match classify_enum_token(&joined, force_numeric, force_string, enum_menu)? {
            EnumToken::Name(s) => Ok(WriteValue::EnumString(s)),
            EnumToken::Number(n) => Ok(WriteValue::Wire {
                dbr_type: epics_ca_rs::DbFieldType::Double as u16,
                value: epics_ca_rs::EpicsValue::Double(n),
            }),
        };
    }

    // (2) `-S` (charArrAsStr) on a NON-ENUM PV → NUL-terminated DBR_CHAR
    // array built from the escape-decoded bytes, sent with the explicit
    // DBR_CHAR wire type and count = nbytes + NUL (caput.c:531-538:
    // `dbrType = DBR_CHAR; count = epicsStrnRawFromEscaped(...) + 1`).
    // The wire type must be DBR_CHAR regardless of the channel native
    // type — sending these char bytes under the native header
    // (DBR_STRING/DBR_DOUBLE/…) is a malformed/rejected write.
    if long_string {
        let mut bytes = raw_from_escaped(&joined);
        bytes.push(0);
        return Ok(WriteValue::Wire {
            dbr_type: epics_ca_rs::DbFieldType::Char as u16,
            value: epics_ca_rs::EpicsValue::CharArray(bytes),
        });
    }

    // (3) Non-ENUM, non-`-S`: C sends the value as DBR_STRING after
    // epicsStrnRawFromEscaped (caput.c:540-552), regardless of the native
    // numeric or string field type — the server/IOC performs the
    // string->native conversion. This matches C `caput`'s wire model; the
    // programmatic native-typed write stays on `CaChannel::put`.
    Ok(WriteValue::Wire {
        dbr_type: epics_ca_rs::DbFieldType::String as u16,
        value: epics_ca_rs::EpicsValue::String(raw_from_escaped_string(&joined)),
    })
}

/// One ENUM value's classification, mirroring C `caput.c`'s per-value
/// `dbrType` decision: a menu state name is written as `DBR_STRING`
/// ([`EnumToken::Name`]); anything else is written as a `DBR_DOUBLE`
/// number ([`EnumToken::Number`]).
enum EnumToken {
    Name(epics_ca_rs::PvString),
    Number(f64),
}

/// Classify one value written to an ENUM field, mirroring C
/// `caput.c:467-509`.
///
/// * `-n` (`force_numeric`): parse the token as a number and send it as
///   `DBR_DOUBLE` (caput.c:469-482); a non-number is an error.
/// * default / `-s`: escape-decode the token and compare it byte-for-byte
///   against the menu state names (`strcmp`, caput.c:487-494). A match is
///   sent as `DBR_STRING`. A non-match falls back to a number sent as
///   `DBR_DOUBLE` (caput.c:496-508) — UNLESS `-s` (`force_string`) forbids
///   the numeric fallback, in which case the value is rejected.
///
/// This is the structural fix for the numeric-label defect: a token like
/// "1" is matched against the menu FIRST, so when state 1 is literally
/// named "1" it is written by name (DBR_STRING) and resolves to the right
/// state, instead of being sent as a native index that could mean a
/// different state.
fn classify_enum_token(
    token: &str,
    force_numeric: bool,
    force_string: bool,
    menu: &[epics_ca_rs::PvString],
) -> Result<EnumToken, String> {
    if force_numeric {
        return parse_enum_double(token)
            .map(EnumToken::Number)
            .ok_or_else(|| format!("Enum index value '{token}' is not a number."));
    }
    // C escapes the value into a fixed EpicsStr buffer before comparing
    // it to the menu names (caput.c:487-488).
    let escaped = raw_from_escaped_string(token);
    if menu
        .iter()
        .any(|name| name.as_bytes() == escaped.as_bytes())
    {
        return Ok(EnumToken::Name(escaped));
    }
    // Not a menu name: `-s` rejects it outright (caput.c:499); otherwise
    // try the escaped text as a number (caput.c:498-507).
    if force_string {
        return Err(format!("Enum string value '{escaped}' invalid."));
    }
    parse_enum_double(&String::from_utf8_lossy(escaped.as_bytes()))
        .map(EnumToken::Number)
        .ok_or_else(|| format!("Enum string value '{escaped}' invalid."))
}

/// Parse an ENUM value as a number, mirroring C `epicsStrtod`
/// (caput.c:470,498). Returns `None` when the token is not a number.
/// Stricter than `strtod` in rejecting trailing garbage (e.g. "1.5x"),
/// which is irrelevant for the clean indices ENUM values carry.
fn parse_enum_double(s: &str) -> Option<f64> {
    let t = s.trim();
    if t.is_empty() {
        return None;
    }
    t.parse::<f64>().ok()
}

/// Build an ENUM waveform write, applying [`classify_enum_token`] to each
/// element (C `caput.c:467-509` loops over `count` values).
///
/// C shares a single `dbrType` across the array — the LAST element's
/// classification wins (caput.c:489/507) — which silently zeroes any
/// name element when the final element is numeric (`dbuf` is never set
/// for a name). We pick ONE wire type for the whole array instead: all
/// numbers → `DBR_DOUBLE[]`; otherwise → `DBR_STRING[]` so every name
/// still resolves (a numeric element falls to the server's index parse),
/// rather than corrupting name elements to 0.
fn build_enum_array(
    tokens: &[String],
    force_numeric: bool,
    force_string: bool,
    menu: &[epics_ca_rs::PvString],
) -> Result<WriteValue, String> {
    let classified: Vec<EnumToken> = tokens
        .iter()
        .map(|t| classify_enum_token(t, force_numeric, force_string, menu))
        .collect::<Result<_, _>>()?;

    let mut numbers = Vec::with_capacity(classified.len());
    let mut all_number = true;
    for c in &classified {
        match c {
            EnumToken::Number(n) => numbers.push(*n),
            EnumToken::Name(_) => all_number = false,
        }
    }
    if all_number {
        return Ok(WriteValue::Wire {
            dbr_type: epics_ca_rs::DbFieldType::Double as u16,
            value: epics_ca_rs::EpicsValue::DoubleArray(numbers),
        });
    }
    // At least one menu name → DBR_STRING array. A `Number` element keeps
    // its escaped token text; the server resolves it by index parse.
    let names = tokens
        .iter()
        .zip(&classified)
        .map(|(t, c)| match c {
            EnumToken::Name(s) => s.clone(),
            EnumToken::Number(_) => raw_from_escaped_string(t),
        })
        .collect();
    Ok(WriteValue::EnumStringArray(names))
}

#[cfg(test)]
mod tests {
    use super::{
        Args, FatalReadback, WriteValue, build_write_value, postput_read_fatal, raw_from_escaped,
        raw_from_escaped_string,
    };
    use clap::Parser;
    use epics_ca_rs::{CaError, DbFieldType, EpicsValue};

    fn vals(s: &[&str]) -> Vec<String> {
        s.iter().map(|x| x.to_string()).collect()
    }

    fn menu_vals(s: &[&str]) -> Vec<epics_ca_rs::PvString> {
        s.iter().map(|x| (*x).into()).collect()
    }

    /// C `caput.c:298-319` parses `-n`/`-s` and `-S`/`-a` as two
    /// mutually-exclusive last-wins pairs via a getopt switch (each case
    /// sets its flag and clears the paired one), with NO conflict error.
    /// `overrides_with` must reproduce that for every order boundary.
    #[test]
    fn enum_and_array_flags_are_last_wins_pairs() {
        let parse = |extra: &[&str]| {
            let mut argv = vec!["caput-rs"];
            argv.extend_from_slice(extra);
            argv.extend_from_slice(&["PV", "1"]);
            Args::try_parse_from(argv).expect("flags must parse without a conflict error")
        };

        // -n / -s: last one wins, neither errors.
        let a = parse(&["-n", "-s"]);
        assert!(
            a.force_string && !a.force_numeric,
            "-n -s → string (last wins)"
        );
        let a = parse(&["-s", "-n"]);
        assert!(
            a.force_numeric && !a.force_string,
            "-s -n → numeric (last wins)"
        );
        let a = parse(&["-n"]);
        assert!(a.force_numeric && !a.force_string, "-n alone → numeric");
        let a = parse(&["-s"]);
        assert!(a.force_string && !a.force_numeric, "-s alone → string");

        // -S / -a: last one wins, never both set.
        let a = parse(&["-a", "-S"]);
        assert!(
            a.long_string && !a.array_mode,
            "-a -S → long string (last wins)"
        );
        let a = parse(&["-S", "-a"]);
        assert!(a.array_mode && !a.long_string, "-S -a → array (last wins)");
        let a = parse(&["-a"]);
        assert!(a.array_mode && !a.long_string, "-a alone → array");
        let a = parse(&["-S"]);
        assert!(a.long_string && !a.array_mode, "-S alone → long string");
    }

    /// The post-put readback
    /// status must follow C `caput`'s exit-code contract — a read TIMEOUT and a
    /// total DISCONNECT fail the command (C `caget` returns ECA_TIMEOUT /
    /// `!nConn` returns 1, caput.c:181,186-188), while a synchronously-failed
    /// read such as read-access-denied leaves `ca_pend_io` at ECA_NORMAL so
    /// C exits 0 (caput.c:200-206). Boundary cases, one per classifier arm.
    #[test]
    fn postput_read_timeout_is_fatal() {
        assert_eq!(
            postput_read_fatal(&CaError::Timeout),
            Some(FatalReadback::Timeout),
            "read timeout must fail caput like C ca_pend_io ECA_TIMEOUT"
        );
    }

    #[test]
    fn postput_read_disconnect_is_fatal() {
        // Both disconnect and shutdown map to C's `!nConn` no-connection guard.
        assert_eq!(
            postput_read_fatal(&CaError::Disconnected),
            Some(FatalReadback::Disconnect),
            "disconnected readback must fail caput like C caget !nConn"
        );
        assert_eq!(
            postput_read_fatal(&CaError::Shutdown),
            Some(FatalReadback::Disconnect),
            "client shutdown during readback must fail caput"
        );
    }

    #[test]
    fn postput_read_other_errors_are_nonfatal() {
        // Read-access-denied and other synchronous CA failures: C's caget still
        // returns ECA_NORMAL (ca_pend_io never timed out) → caput exits 0. These
        // must be non-fatal so we echo the submitted value and exit 0, NOT
        // over-correct to non-zero (the finding's prescription was wrong here).
        assert_eq!(
            postput_read_fatal(&CaError::ServerError(0x178)), // ECA_NORDACCESS
            None,
            "read-access-denied exits 0 in C, must stay non-fatal"
        );
        assert_eq!(
            postput_read_fatal(&CaError::Protocol("bad frame".into())),
            None,
            "other readback errors exit 0 in C, must stay non-fatal"
        );
    }

    #[test]
    fn raw_from_escaped_matches_epics_strn_raw_from_escaped() {
        // Per-boundary cases against the C `epicsStrnRawFromEscaped`:
        // control escapes, literal escapes, the lone `\0` octal, `\xHH`
        // hex with 1 and 2 digits, `\x` followed by a non-hex char
        // (re-processed), an unknown `\c` (literal `c`), a trailing lone
        // backslash (stops), and a literal NUL in the input (stops).
        assert_eq!(
            raw_from_escaped("\\a\\b\\f\\n\\r\\t\\v"),
            vec![0x07, 0x08, 0x0C, 0x0A, 0x0D, 0x09, 0x0B]
        );
        assert_eq!(raw_from_escaped("\\\\\\'\\\""), vec![b'\\', b'\'', b'"']);
        assert_eq!(raw_from_escaped("\\0"), vec![0]);
        assert_eq!(raw_from_escaped("\\x41"), vec![0x41]); // two hex digits
        assert_eq!(raw_from_escaped("\\xA"), vec![0x0A]); // single trailing hex
        assert_eq!(raw_from_escaped("\\xG"), vec![b'G']); // non-hex re-processed
        assert_eq!(raw_from_escaped("\\q"), vec![b'q']); // unknown escape → literal
        assert_eq!(raw_from_escaped("a\\"), vec![b'a']); // trailing lone backslash stops
        assert_eq!(raw_from_escaped("a\0b"), vec![b'a']); // literal NUL stops decoding
    }

    #[test]
    fn long_string_takes_precedence_over_char_parse() {
        // `caput -S PV hello` against a native DBF_CHAR must send the
        // bytes as a NUL-terminated DBR_CHAR array, NOT parse "hello" as a
        // numeric char. The old order parsed first and exited on the parse
        // error before `-S` was ever applied.
        let r = build_write_value(
            &vals(&["hello"]),
            DbFieldType::Char,
            false,
            false,
            true,
            false,
            &[],
        );
        match r {
            Ok(WriteValue::Wire {
                dbr_type,
                value: EpicsValue::CharArray(bytes),
            }) => {
                assert_eq!(dbr_type, DbFieldType::Char as u16, "DBR_CHAR wire type");
                assert_eq!(bytes, b"hello\0", "NUL-terminated long string bytes");
            }
            _ => panic!("expected Ok(DBR_CHAR Wire CharArray) for -S on a CHAR PV"),
        }
    }

    #[test]
    fn long_string_applies_to_every_non_enum_native_type() {
        // For a NON-ENUM PV `-S` never reaches the native-type parse, so no
        // such native type can make it fail — it always yields a
        // NUL-terminated DBR_CHAR array.
        for nt in [
            DbFieldType::Char,
            DbFieldType::Double,
            DbFieldType::Long,
            DbFieldType::String,
        ] {
            let r = build_write_value(&vals(&["not a number"]), nt, false, false, true, false, &[]);
            assert!(
                matches!(
                    r,
                    Ok(WriteValue::Wire {
                        dbr_type,
                        value: EpicsValue::CharArray(_),
                    }) if dbr_type == DbFieldType::Char as u16
                ),
                "-S must yield a DBR_CHAR Wire CharArray for non-ENUM native type {nt:?}"
            );
        }
    }

    #[test]
    fn enum_field_type_wins_over_long_string() {
        // C checks the ENUM field type FIRST (`caput.c:455`), so `-S`
        // (charArrAsStr) never applies to an ENUM PV: a value that matches
        // a menu state name routes to DBR_STRING (server resolves the
        // name), NOT a DBR_CHAR array. Pre-fix the top-level `-S` block
        // hijacked this.
        let menu = menu_vals(&["Stop", "Run", "not a number"]);
        let r = build_write_value(
            &vals(&["not a number"]),
            DbFieldType::Enum,
            false,
            false,
            true,
            false,
            &menu,
        );
        match r {
            Ok(WriteValue::EnumString(s)) => assert_eq!(s, "not a number"),
            other => panic!("-S on an ENUM PV must yield EnumString, got {other:?}"),
        }
        // An integer index that is NOT a menu state name falls back to a
        // number sent as DBR_DOUBLE (caput.c:507) even with `-S` set —
        // ENUM precedence, then the numeric fallback path.
        let idx = build_write_value(
            &vals(&["5"]),
            DbFieldType::Enum,
            false,
            false,
            true,
            false,
            &menu,
        );
        match idx {
            Ok(WriteValue::Wire {
                dbr_type,
                value: EpicsValue::Double(n),
            }) => {
                assert_eq!(dbr_type, DbFieldType::Double as u16);
                assert_eq!(n, 5.0);
            }
            other => panic!("out-of-menu index on ENUM PV → DBR_DOUBLE, got {other:?}"),
        }
    }

    #[test]
    fn enum_numeric_label_matches_menu_name_before_index() {
        // Regression: a record whose state 1 is literally named "1" must
        // be written by NAME (DBR_STRING) so the server resolves it to that
        // state — C matches the menu before the numeric fallback
        // (caput.c:487-494). Sending "1" as a native index instead could
        // mean a different state.
        let menu = menu_vals(&["0", "1", "2"]);
        match build_write_value(
            &vals(&["1"]),
            DbFieldType::Enum,
            false,
            false,
            false,
            false,
            &menu,
        ) {
            Ok(WriteValue::EnumString(s)) => assert_eq!(s, "1"),
            other => panic!("numeric-looking menu label must go by name, got {other:?}"),
        }
        // A value with no matching state name falls back to a number
        // (DBR_DOUBLE), not a native index (caput.c:496-507).
        match build_write_value(
            &vals(&["7"]),
            DbFieldType::Enum,
            false,
            false,
            false,
            false,
            &menu,
        ) {
            Ok(WriteValue::Wire {
                dbr_type,
                value: EpicsValue::Double(n),
            }) => {
                assert_eq!(dbr_type, DbFieldType::Double as u16);
                assert_eq!(n, 7.0);
            }
            other => panic!("out-of-menu value → DBR_DOUBLE fallback, got {other:?}"),
        }
    }

    #[test]
    fn enum_force_string_rejects_non_menu_value() {
        // `-s` (enumAsString) forbids the numeric fallback: a value that
        // matches no state name is an error, not a coerced index
        // (caput.c:499-503).
        let menu = menu_vals(&["Off", "On"]);
        let err = build_write_value(
            &vals(&["3"]),
            DbFieldType::Enum,
            false,
            true,
            false,
            false,
            &menu,
        );
        assert!(
            matches!(&err, Err(m) if m.contains("invalid")),
            "-s on a non-menu value must error, got {err:?}"
        );
        // A name that DOES match is accepted as DBR_STRING.
        match build_write_value(
            &vals(&["On"]),
            DbFieldType::Enum,
            false,
            true,
            false,
            false,
            &menu,
        ) {
            Ok(WriteValue::EnumString(s)) => assert_eq!(s, "On"),
            other => panic!("-s with a matching name → EnumString, got {other:?}"),
        }
    }

    #[test]
    fn enum_force_numeric_sends_dbr_double_ignoring_menu() {
        // `-n` (enumAsNr) interprets every value as a number sent as
        // DBR_DOUBLE, never matching the menu (caput.c:467-482) — even a
        // value that IS a state name.
        let menu = menu_vals(&["1", "2"]);
        match build_write_value(
            &vals(&["1"]),
            DbFieldType::Enum,
            true,
            false,
            false,
            false,
            &menu,
        ) {
            Ok(WriteValue::Wire {
                dbr_type,
                value: EpicsValue::Double(n),
            }) => {
                assert_eq!(dbr_type, DbFieldType::Double as u16);
                assert_eq!(n, 1.0);
            }
            other => panic!("-n must send DBR_DOUBLE, got {other:?}"),
        }
        // `-n` on a non-number is an error.
        let err = build_write_value(
            &vals(&["Open"]),
            DbFieldType::Enum,
            true,
            false,
            false,
            false,
            &menu,
        );
        assert!(
            matches!(&err, Err(m) if m.contains("is not a number")),
            "-n on a non-number must error, got {err:?}"
        );
    }

    #[test]
    fn enum_array_homogeneous_and_mixed_wire_types() {
        // ENUM waveform: all-name → DBR_STRING[]; all-number → DBR_DOUBLE[].
        let menu = menu_vals(&["Stop", "Run"]);
        // `-a PV 2 Stop Run`: both are state names → DBR_STRING[].
        match build_write_value(
            &vals(&["2", "Stop", "Run"]),
            DbFieldType::Enum,
            false,
            false,
            false,
            true,
            &menu,
        ) {
            Ok(WriteValue::EnumStringArray(a)) => {
                assert_eq!(a, vec!["Stop", "Run"]);
            }
            other => panic!("name array → DBR_STRING[], got {other:?}"),
        }
        // `-a PV 2 0 1`: neither matches a name → DBR_DOUBLE[] (caput.c:507).
        match build_write_value(
            &vals(&["2", "0", "1"]),
            DbFieldType::Enum,
            false,
            false,
            false,
            true,
            &menu,
        ) {
            Ok(WriteValue::Wire {
                dbr_type,
                value: EpicsValue::DoubleArray(a),
            }) => {
                assert_eq!(dbr_type, DbFieldType::Double as u16);
                assert_eq!(a, vec![0.0, 1.0]);
            }
            other => panic!("numeric array → DBR_DOUBLE[], got {other:?}"),
        }
        // Mixed `-a PV 2 Stop 1`: at least one name → DBR_STRING[] for the
        // whole array (we avoid C's last-element-wins corruption that would
        // zero the name element).
        match build_write_value(
            &vals(&["2", "Stop", "1"]),
            DbFieldType::Enum,
            false,
            false,
            false,
            true,
            &menu,
        ) {
            Ok(WriteValue::EnumStringArray(a)) => assert_eq!(a, vec!["Stop", "1"]),
            other => panic!("mixed array → DBR_STRING[], got {other:?}"),
        }
    }

    #[test]
    fn long_string_decodes_c_escapes_to_raw_bytes() {
        // `-S` feeds the value through `epicsStrnRawFromEscaped`, so
        // `a\tb\n` becomes real TAB/LF bytes (then the NUL terminator), not
        // the literal backslash sequences the pre-fix `into_bytes()` left.
        let r = build_write_value(
            &vals(&["a\\tb\\n"]),
            DbFieldType::Char,
            false,
            false,
            true,
            false,
            &[],
        );
        match r {
            Ok(WriteValue::Wire {
                dbr_type,
                value: EpicsValue::CharArray(bytes),
            }) => {
                assert_eq!(dbr_type, DbFieldType::Char as u16, "DBR_CHAR wire type");
                assert_eq!(bytes, vec![b'a', 0x09, b'b', 0x0A, 0x00]);
            }
            other => panic!("expected escape-decoded DBR_CHAR Wire CharArray, got {other:?}"),
        }
    }

    #[test]
    fn native_string_scalar_decodes_c_escapes() {
        // A non-ENUM scalar is escape-decoded and sent as DBR_STRING
        // (`caput.c:540-552`): `\x41` → 'A', `\\` → one backslash, `\q`
        // (unknown) → 'q'. The wire type is DBR_STRING regardless of the
        // native field type — the server converts.
        let r = build_write_value(
            &vals(&["\\x41\\\\\\q"]),
            DbFieldType::String,
            false,
            false,
            false,
            false,
            &[],
        );
        match r {
            Ok(WriteValue::Wire {
                dbr_type,
                value: EpicsValue::String(s),
            }) => {
                assert_eq!(dbr_type, DbFieldType::String as u16, "DBR_STRING wire type");
                assert_eq!(s, "A\\q");
            }
            other => panic!("expected escape-decoded DBR_STRING Wire, got {other:?}"),
        }
    }

    #[test]
    fn non_enum_numeric_scalar_and_array_send_dbr_string() {
        // CA-RS parity: C `caput` sends every non-ENUM, non-`-S` value as
        // DBR_STRING (`caput.c:540-552`), NOT the native numeric type; the
        // server/IOC performs the string->native conversion. A numeric
        // scalar against a DBF_DOUBLE PV must therefore yield a DBR_STRING
        // Wire carrying the original token, not a parsed EpicsValue::Double.
        match build_write_value(
            &vals(&["1.5"]),
            DbFieldType::Double,
            false,
            false,
            false,
            false,
            &[],
        ) {
            Ok(WriteValue::Wire {
                dbr_type,
                value: EpicsValue::String(s),
            }) => {
                assert_eq!(dbr_type, DbFieldType::String as u16);
                assert_eq!(s, "1.5");
            }
            other => panic!("numeric scalar must be a DBR_STRING Wire, got {other:?}"),
        }
        // A numeric array (`-a`) sends DBR_STRING[] with count == nvalues,
        // each element the raw token; no native LongArray parse.
        match build_write_value(
            &vals(&["3", "10", "20", "30"]),
            DbFieldType::Long,
            false,
            false,
            false,
            true,
            &[],
        ) {
            Ok(WriteValue::Wire {
                dbr_type,
                value: EpicsValue::StringArray(a),
            }) => {
                assert_eq!(dbr_type, DbFieldType::String as u16);
                assert_eq!(a, vec!["10", "20", "30"]);
            }
            other => panic!("numeric array must be a DBR_STRING[] Wire, got {other:?}"),
        }
    }

    #[test]
    fn array_count_token_is_skipped_without_parsing_or_error() {
        // C `caput -a` (caput.c:413-418) skips the count token without
        // parsing it: the value is taken from `argc - optind`. A count
        // that disagrees with the supplied values, a non-numeric count,
        // and a count of zero must all reach the write path silently —
        // no warning, no CLI-side rejection.
        // The non-ENUM array path now sends DBR_STRING[] (caput.c:540-552);
        // these assertions check the count-token skip via the element list.
        // `-a PV 999 1 2`: count 999 vs 2 values → use both, no error.
        match build_write_value(
            &vals(&["999", "1", "2"]),
            DbFieldType::Long,
            false,
            false,
            false,
            true,
            &[],
        ) {
            Ok(WriteValue::Wire {
                value: EpicsValue::StringArray(a),
                ..
            }) => assert_eq!(a, vec!["1", "2"]),
            other => panic!("count mismatch must use all values, got {other:?}"),
        }
        // `-a PV not-a-count 1 2`: non-numeric count token skipped.
        match build_write_value(
            &vals(&["not-a-count", "1", "2"]),
            DbFieldType::Long,
            false,
            false,
            false,
            true,
            &[],
        ) {
            Ok(WriteValue::Wire {
                value: EpicsValue::StringArray(a),
                ..
            }) => assert_eq!(a, vec!["1", "2"]),
            other => panic!("non-numeric count token must be ignored, got {other:?}"),
        }
        // `-a PV 0`: zero trailing values reaches the write path as an
        // empty array (count == 0), decided by the server — not a CLI error.
        match build_write_value(
            &vals(&["0"]),
            DbFieldType::Long,
            false,
            false,
            false,
            true,
            &[],
        ) {
            Ok(WriteValue::Wire {
                value: EpicsValue::StringArray(a),
                ..
            }) => assert!(a.is_empty()),
            other => panic!("zero-count -a must reach the write path empty, got {other:?}"),
        }
    }

    #[test]
    fn scalar_char_without_long_string_sends_dbr_string() {
        // Without `-S`, a non-ENUM CHAR scalar is sent as DBR_STRING
        // (caput.c:540-552), NOT parsed locally as a number — so a
        // non-numeric token is no longer a CLI-side error (the server
        // performs the conversion and any rejection). This is distinct
        // from `-S`, which sends a DBR_CHAR byte array.
        for tok in ["65", "hello"] {
            match build_write_value(
                &vals(&[tok]),
                DbFieldType::Char,
                false,
                false,
                false,
                false,
                &[],
            ) {
                Ok(WriteValue::Wire {
                    dbr_type,
                    value: EpicsValue::String(s),
                }) => {
                    assert_eq!(dbr_type, DbFieldType::String as u16);
                    assert_eq!(s, tok);
                }
                other => panic!("CHAR scalar without -S must be a DBR_STRING Wire, got {other:?}"),
            }
        }
    }

    #[test]
    fn overlong_dbr_string_values_truncate_to_39_bytes() {
        // C `caput` decodes each DBR_STRING / ENUM-name value into a fixed
        // EpicsStr[40] buffer and keeps 39 raw bytes (caput.c:484-489,
        // 523-528); an overlong value is TRUNCATED before libca, not
        // rejected. The Rust client rejects >= 40, so caput-rs must cap.
        let long = "a".repeat(50); // 50 ASCII bytes
        // Non-ENUM string scalar -> DBR_STRING capped to 39.
        match build_write_value(
            &vals(&[long.as_str()]),
            DbFieldType::String,
            false,
            false,
            false,
            false,
            &[],
        ) {
            Ok(WriteValue::Wire {
                value: EpicsValue::String(s),
                ..
            }) => {
                assert_eq!(s.len(), 39, "scalar string truncated to 39 bytes");
                assert_eq!(s, "a".repeat(39));
            }
            other => panic!("expected truncated DBR_STRING Wire, got {other:?}"),
        }
        // Non-ENUM string array element -> each capped to 39 (the leading
        // token is the skipped -a count).
        match build_write_value(
            &vals(&["2", long.as_str(), "short"]),
            DbFieldType::String,
            false,
            false,
            false,
            true,
            &[],
        ) {
            Ok(WriteValue::Wire {
                value: EpicsValue::StringArray(a),
                ..
            }) => {
                assert_eq!(a[0].len(), 39, "array element truncated to 39 bytes");
                assert_eq!(a[1], "short");
            }
            other => panic!("expected truncated DBR_STRING[] Wire, got {other:?}"),
        }
        // ENUM-by-name scalar (`-s`) -> EnumString capped to 39. The value
        // is escape-decoded and truncated to 39 bytes BEFORE the menu
        // compare, so the matching state name is the 39-byte form.
        let menu_label: epics_ca_rs::PvString = "a".repeat(39).into();
        match build_write_value(
            &vals(&[long.as_str()]),
            DbFieldType::Enum,
            false,
            true,
            false,
            false,
            std::slice::from_ref(&menu_label),
        ) {
            Ok(WriteValue::EnumString(s)) => {
                assert_eq!(s.len(), 39, "enum-by-name value truncated to 39 bytes")
            }
            other => panic!("expected truncated EnumString, got {other:?}"),
        }
        // `-S` long strings take the DBR_CHAR path and stay UNCAPPED — all
        // 50 bytes + NUL survive (finding: -S is not 39-byte limited).
        match build_write_value(
            &vals(&[long.as_str()]),
            DbFieldType::Char,
            false,
            false,
            true,
            false,
            &[],
        ) {
            Ok(WriteValue::Wire {
                dbr_type,
                value: EpicsValue::CharArray(b),
            }) => {
                assert_eq!(dbr_type, DbFieldType::Char as u16);
                assert_eq!(b.len(), 51, "-S keeps all 50 bytes + NUL, uncapped");
            }
            other => panic!("expected uncapped DBR_CHAR Wire, got {other:?}"),
        }
    }

    /// PVA-89 / CA Latin-1 parity: a `\xNN` escape with a high byte must
    /// reach the wire as that literal byte. C `caput` decodes into a raw
    /// `EpicsStr` byte buffer (epicsString.c:55-118) with no UTF-8 fixup;
    /// the Rust port carries the bytes in `PvString::from_bytes`, so a
    /// non-UTF-8 byte run round-trips verbatim instead of being mangled
    /// into U+FFFD replacement sequences.
    #[test]
    fn escaped_high_bytes_reach_wire_verbatim_not_lossy() {
        // `\xff` and `\x80` are not valid standalone UTF-8; a lossy
        // `from_utf8_lossy` would turn each into the 3-byte U+FFFD. The
        // byte-preserving path keeps them as single 0xFF / 0x80 bytes.
        let pv = raw_from_escaped_string("\\xff\\x80\\x41");
        assert_eq!(
            pv.as_bytes(),
            &[0xff, 0x80, 0x41],
            "high-byte escapes must survive as literal bytes, not U+FFFD"
        );
        // The full Latin-1 range (0x00 excluded — a literal NUL stops the
        // decoder) is representable byte-for-byte.
        let pv = raw_from_escaped_string("\\xc3\\x28");
        assert_eq!(pv.as_bytes(), &[0xc3, 0x28], "invalid UTF-8 pair preserved");
    }

    /// PVA-89: the byte truncation to `DBR_STRING_PAYLOAD_MAX` (39) is
    /// byte-oriented, never a UTF-8 char-boundary fixup. 40 raw high bytes
    /// must cut to exactly 39 bytes — C decodes into a fixed byte buffer
    /// (`rem = 40`, NUL-terminated), so the cut is on bytes, not chars.
    #[test]
    fn escaped_high_bytes_truncate_on_byte_boundary() {
        // 40 `\xff` escapes → 40 raw 0xFF bytes, capped to 39.
        let input: String = "\\xff".repeat(40);
        let pv = raw_from_escaped_string(&input);
        assert_eq!(pv.as_bytes().len(), 39, "byte-oriented cut at 39");
        assert!(
            pv.as_bytes().iter().all(|&b| b == 0xff),
            "every surviving byte is the literal 0xFF, no UTF-8 fixup"
        );
    }

    /// PVA-89 end-to-end: `build_write_value` for a DBR_STRING put must
    /// carry the decoded high bytes into `EpicsValue::String(PvString)`
    /// verbatim — the gateway/server sees the same bytes a C `caput` sends.
    #[test]
    fn build_write_value_dbr_string_preserves_high_bytes() {
        match build_write_value(
            &vals(&["\\xff\\x80"]),
            DbFieldType::String,
            false,
            false,
            false,
            false,
            &[],
        ) {
            Ok(WriteValue::Wire {
                dbr_type,
                value: EpicsValue::String(s),
            }) => {
                assert_eq!(dbr_type, DbFieldType::String as u16, "DBR_STRING wire type");
                assert_eq!(
                    s.as_bytes(),
                    &[0xff, 0x80],
                    "DBR_STRING put carries raw high bytes to the wire"
                );
            }
            other => panic!("expected DBR_STRING Wire with raw bytes, got {other:?}"),
        }
    }
}
