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

fn format_server_timestamp(ts: SystemTime) -> String {
    let dt: DateTime<Local> = 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).
    #[arg(short = 'n', long = "num-enum")]
    force_numeric: bool,

    /// Force interpretation of values as strings (overrides numeric
    /// parse for ENUM).
    #[arg(short = 's', long = "string-enum", conflicts_with = "force_numeric")]
    force_string: bool,

    /// Put long string as an array of chars (long-string convention).
    #[arg(short = 'S', long = "long-string")]
    long_string: bool,

    /// Put as array. The remaining positionals are
    /// `<count> <v0> <v1> ...`.
    #[arg(short = 'a', long = "array")]
    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),
    );

    // CA-FR-3/CA-FR-4: -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);
    }

    // Determine the channel's native type. 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 (native_type, old_value, old_snap) = if args.long_mode {
        match ch.get_with_metadata(DbrClass::Time).await {
            Ok(snap) => (snap.value.dbr_type(), snap.value.clone(), Some(snap)),
            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);
            }
        }
    } else {
        match ch.get_with_timeout(timeout).await {
            Ok((t, v)) => (t, v, None),
            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);
            }
        }
    };

    // CA-FR-4: 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,
    ) {
        Ok(v) => v,
        Err(e) => {
            eprintln!("{e}");
            std::process::exit(1);
        }
    };

    let result = match &parsed_value {
        WriteValue::Typed(v) => {
            if args.callback {
                ch.put_with_timeout(v, timeout).await
            } else {
                ch.put_nowait(v).await
            }
        }
        WriteValue::EnumString(s) => {
            // Always write ENUM-by-name through the DBR_STRING put so
            // the server resolves the menu string.
            if args.callback {
                ch.put_string(s).await
            } else {
                ch.put_string_nowait(s).await
            }
        }
        WriteValue::EnumStringArray(v) => {
            // ENUM waveform by name — DBR_STRING array, server resolves
            // each element.
            if args.callback {
                ch.put_string_array(v).await
            } else {
                ch.put_string_array_nowait(v).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). `echo_fallback` is the value
    // shown if the post-put read fails — just what was written.
    let echo_fallback = parsed_value.echo_fallback();
    let (new_value, new_snap) = if args.long_mode {
        match ch.get_with_metadata(DbrClass::Time).await {
            Ok(snap) => (snap.value.clone(), Some(snap)),
            Err(_) => (echo_fallback.clone(), None),
        }
    } else {
        (
            match ch.get_with_timeout(timeout).await {
                Ok((_, val)) => val,
                Err(_) => 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;
    let old_rendered = format_value(&old_value, &fmt, None, false);
    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 — either a value typed against the
/// channel's native DBR type, or a string to be sent as `DBR_STRING`
/// for server-side resolution (the ENUM-by-name path).
#[derive(Debug)]
enum WriteValue {
    Typed(epics_ca_rs::EpicsValue),
    /// A scalar ENUM value written by name. The server resolves it
    /// against the record's menu — see `CaChannel::put_string`.
    EnumString(String),
    /// 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<String>),
}

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

/// 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
}

/// `raw_from_escaped` decoded into a `String` for the DBR_STRING / ENUM-
/// by-name put paths. The common escapes (`\n`, `\t`, `\\`, …) decode to
/// ASCII; a high-byte `\xNN` that is not valid UTF-8 falls back to lossy
/// decoding (the Rust `EpicsValue::String` is UTF-8, whereas C carries a
/// raw byte buffer).
fn raw_from_escaped_string(s: &str) -> String {
    String::from_utf8_lossy(&raw_from_escaped(s)).into_owned()
}

/// 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). Non-fatal `-a` count-token mismatches warn on stderr;
/// fatal cases return `Err` with the full message for the caller to print.
fn build_write_value(
    values: &[String],
    native_type: epics_ca_rs::DbFieldType,
    force_numeric: bool,
    force_string: bool,
    long_string: bool,
    array_mode: bool,
) -> Result<WriteValue, String> {
    if array_mode {
        // C `caput -a` (caput.c:413-418): after the PV name it skips the
        // count token (`optind++`) and uses ALL remaining values — the
        // count number is informational only. `values[0]` is the count
        // token, `[1..]` the actual values.
        let tokens = &values[1..];
        if let Ok(want) = values[0].parse::<usize>() {
            if want != tokens.len() {
                eprintln!(
                    "caput-rs: warning: -a count {} differs from {} values supplied; \
                     using all {} (C-parity)",
                    want,
                    tokens.len(),
                    tokens.len()
                );
            }
        } else {
            // C does not parse the count token at all — a non-numeric
            // token is silently skipped. Mirror that, no hard error.
            eprintln!(
                "caput-rs: warning: -a count token '{}' is not a number; \
                 ignored (C-parity)",
                values[0]
            );
        }
        if tokens.is_empty() {
            return Err("caput-rs: -a requires at least one value after the count token".into());
        }
        // ENUM waveform special-casing, parallel to the scalar path:
        // unless `-n` forces numeric, route to a DBR_STRING array for
        // server-side menu resolution when `-s` is set or any element is
        // not a plain integer index (the same documented divergence as
        // the scalar path). C escapes each enum-name element (caput.c:487).
        let enum_by_name = native_type == epics_ca_rs::DbFieldType::Enum
            && !force_numeric
            && (force_string || tokens.iter().any(|t| parse_plain_integer(t).is_none()));
        if enum_by_name {
            let escaped = tokens.iter().map(|t| raw_from_escaped_string(t)).collect();
            return Ok(WriteValue::EnumStringArray(escaped));
        }
        // String-array elements are escape-decoded (C caput.c:520);
        // numeric arrays parse the raw token.
        if native_type == epics_ca_rs::DbFieldType::String {
            let escaped: Vec<String> = tokens.iter().map(|t| raw_from_escaped_string(t)).collect();
            return parse_array(native_type, &escaped)
                .map(WriteValue::Typed)
                .map_err(|e| format!("error: {e}"));
        }
        return parse_array(native_type, tokens)
            .map(WriteValue::Typed)
            .map_err(|e| format!("error: {e}"));
    }

    // 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. We don't fetch the menu;
    // we let the server classify:
    // * `-n` (force_numeric): interpret as a number.
    // * `-s` (force_string): always DBR_STRING; server resolves the menu.
    // * default: a plain integer index goes numeric; anything else is
    //   sent as DBR_STRING for server-side menu resolution (escaped, as
    //   C runs epicsStrnRawFromEscaped on the menu name, caput.c:487).
    if native_type == epics_ca_rs::DbFieldType::Enum {
        let is_plain_integer = parse_plain_integer(&joined).is_some();
        if !force_numeric && (force_string || !is_plain_integer) {
            return Ok(WriteValue::EnumString(raw_from_escaped_string(&joined)));
        }
        return epics_ca_rs::EpicsValue::parse(native_type, &joined)
            .map(WriteValue::Typed)
            .map_err(|e| format!("error: {e}"));
    }

    // (2) `-S` (charArrAsStr) on a NON-ENUM PV → NUL-terminated DBR_CHAR
    // array built from the escape-decoded bytes (caput.c:512). No
    // native-type parse on this path.
    if long_string {
        let mut bytes = raw_from_escaped(&joined);
        bytes.push(0);
        return Ok(WriteValue::Typed(epics_ca_rs::EpicsValue::CharArray(bytes)));
    }

    // (3) Native DBR_STRING is escape-decoded (caput.c:520).
    if native_type == epics_ca_rs::DbFieldType::String {
        return Ok(WriteValue::Typed(epics_ca_rs::EpicsValue::String(
            raw_from_escaped_string(&joined),
        )));
    }

    // (4) Numeric native types parse the raw token (no escaping).
    epics_ca_rs::EpicsValue::parse(native_type, &joined)
        .map(WriteValue::Typed)
        .map_err(|e| format!("error: {e}"))
}

/// Parse a plain integer index — decimal, optionally signed, no radix
/// prefix and no surrounding garbage. C `caput` treats anything that
/// is not a clean number as an ENUM menu string. Returns `Some` only
/// for a strict integer literal.
fn parse_plain_integer(s: &str) -> Option<i64> {
    s.trim().parse::<i64>().ok()
}

fn parse_array(
    native_type: epics_ca_rs::DbFieldType,
    tokens: &[String],
) -> Result<epics_ca_rs::EpicsValue, String> {
    use epics_ca_rs::DbFieldType as DT;
    use epics_ca_rs::EpicsValue;
    match native_type {
        DT::Short => {
            let mut arr = Vec::with_capacity(tokens.len());
            for t in tokens {
                arr.push(
                    EpicsValue::parse(DT::Short, t)
                        .and_then(|v| match v {
                            EpicsValue::Short(n) => Ok(n),
                            _ => Err(epics_ca_rs::CaError::InvalidValue("not short".into())),
                        })
                        .map_err(|e| e.to_string())?,
                );
            }
            Ok(EpicsValue::ShortArray(arr))
        }
        DT::Float => {
            let mut arr = Vec::with_capacity(tokens.len());
            for t in tokens {
                arr.push(t.parse::<f32>().map_err(|e| e.to_string())?);
            }
            Ok(EpicsValue::FloatArray(arr))
        }
        DT::Double => {
            let mut arr = Vec::with_capacity(tokens.len());
            for t in tokens {
                arr.push(t.parse::<f64>().map_err(|e| e.to_string())?);
            }
            Ok(EpicsValue::DoubleArray(arr))
        }
        DT::Long => {
            let mut arr = Vec::with_capacity(tokens.len());
            for t in tokens {
                arr.push(t.parse::<i32>().map_err(|e| e.to_string())?);
            }
            Ok(EpicsValue::LongArray(arr))
        }
        DT::Enum => {
            let mut arr = Vec::with_capacity(tokens.len());
            for t in tokens {
                arr.push(t.parse::<u16>().map_err(|e| e.to_string())?);
            }
            Ok(EpicsValue::EnumArray(arr))
        }
        DT::Int64 => {
            let mut arr = Vec::with_capacity(tokens.len());
            for t in tokens {
                arr.push(t.parse::<i64>().map_err(|e| e.to_string())?);
            }
            Ok(EpicsValue::Int64Array(arr))
        }
        DT::UInt64 => {
            let mut arr = Vec::with_capacity(tokens.len());
            for t in tokens {
                arr.push(t.parse::<u64>().map_err(|e| e.to_string())?);
            }
            Ok(EpicsValue::UInt64Array(arr))
        }
        DT::Char => Ok(EpicsValue::CharArray(
            tokens
                .iter()
                .map(|t| t.parse::<u8>().unwrap_or(0))
                .collect(),
        )),
        DT::String => Ok(EpicsValue::StringArray(tokens.to_vec())),
    }
}

#[cfg(test)]
mod tests {
    use super::{WriteValue, build_write_value, raw_from_escaped};
    use epics_ca_rs::{DbFieldType, EpicsValue};

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

    #[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::Typed(EpicsValue::CharArray(bytes))) => {
                assert_eq!(bytes, b"hello\0", "NUL-terminated long string bytes");
            }
            _ => panic!("expected Ok(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::Typed(EpicsValue::CharArray(_)))),
                "-S must yield 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 non-integer token
        // routes to DBR_STRING for server-side menu resolution, NOT a
        // DBR_CHAR array. Pre-fix the top-level `-S` block hijacked this.
        let r = build_write_value(
            &vals(&["not a number"]),
            DbFieldType::Enum,
            false,
            false,
            true,
            false,
        );
        match r {
            Ok(WriteValue::EnumString(s)) => assert_eq!(s, "not a number"),
            other => panic!("-S on an ENUM PV must yield EnumString, got {other:?}"),
        }
        // A plain integer index on an ENUM PV still goes numeric even with
        // `-S` set — ENUM precedence, then the default index path.
        let idx = build_write_value(&vals(&["2"]), DbFieldType::Enum, false, false, true, false);
        assert!(
            matches!(idx, Ok(WriteValue::Typed(_))),
            "integer index on ENUM PV stays numeric, got {idx:?}"
        );
    }

    #[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::Typed(EpicsValue::CharArray(bytes))) => {
                assert_eq!(bytes, vec![b'a', 0x09, b'b', 0x0A, 0x00]);
            }
            other => panic!("expected escape-decoded CharArray, got {other:?}"),
        }
    }

    #[test]
    fn native_string_scalar_decodes_c_escapes() {
        // A native DBR_STRING scalar is escape-decoded too (`caput.c:520`):
        // `\x41` → 'A', `\\` → one backslash, `\q` (unknown) → 'q'.
        let r = build_write_value(
            &vals(&["\\x41\\\\\\q"]),
            DbFieldType::String,
            false,
            false,
            false,
            false,
        );
        match r {
            Ok(WriteValue::Typed(EpicsValue::String(s))) => assert_eq!(s, "A\\q"),
            other => panic!("expected escape-decoded String, got {other:?}"),
        }
    }

    #[test]
    fn scalar_char_without_long_string_still_parses_numeric() {
        // Without `-S`, a native CHAR scalar parses the token as a number;
        // a non-numeric token is an error, exactly as before the fix.
        assert!(
            build_write_value(
                &vals(&["65"]),
                DbFieldType::Char,
                false,
                false,
                false,
                false
            )
            .is_ok()
        );
        assert!(
            build_write_value(
                &vals(&["hello"]),
                DbFieldType::Char,
                false,
                false,
                false,
                false
            )
            .is_err()
        );
    }
}
