use std::sync::Arc;
use std::time::{Duration, SystemTime};

use epics_base_rs::error::{CaError, CaResult};
use epics_base_rs::server::device_support::{DeviceReadOutcome, DeviceSupport, WriteCompletion};
use epics_base_rs::server::record::{Record, ScanType};
use epics_base_rs::types::EpicsValue;

use crate::error::AsynError;
use crate::interfaces::InterfaceType;
use crate::interrupt::{InterruptFilter, InterruptSubscription};
use crate::port_handle::{AsyncCompletionHandle, PortHandle};
use crate::request::{RequestOp, RequestResult};
use crate::user::AsynUser;

/// Parsed `@asyn(portName, addr, timeout) drvInfoString` link specification.
#[derive(Debug, Clone)]
pub struct AsynLink {
    pub port_name: String,
    pub addr: i32,
    pub timeout: Duration,
    pub drv_info: String,
}

/// Strip an optional sign and detect the C `strtol(.., 0)` base of a
/// numeric link field: a leading `0x`/`0X` selects hexadecimal, a leading
/// `0` (with further digits) octal, otherwise decimal. Returns
/// `(negative, digit_str, radix)`.
///
/// Unlike C — which consumes the longest valid prefix and leaves the rest
/// in `endp` — these require the *whole* (trimmed) token to be a valid
/// number. asyn's comma/space split already isolates each numeric field,
/// so there is no trailing remainder for C's `endp` walk to pick up;
/// rejecting a partly-numeric field is closer to the link parser's intent
/// than silently binding its leading digits.
fn split_base0(tok: &str) -> Option<(bool, &str, u32)> {
    let tok = tok.trim();
    let (neg, rest) = match tok.strip_prefix('-') {
        Some(r) => (true, r),
        None => (false, tok.strip_prefix('+').unwrap_or(tok)),
    };
    if let Some(hex) = rest.strip_prefix("0x").or_else(|| rest.strip_prefix("0X")) {
        // `0x` with no following digits is not a number (C: endp stays at 'x').
        (!hex.is_empty()).then_some((neg, hex, 16))
    } else if rest.len() > 1 && rest.starts_with('0') {
        // Leading `0` + more digits → octal. C `strtol(.., 0)` does NOT
        // recognise a `0o` prefix, so `0o..` falls here and fails the
        // octal digit parse (matching C, which stops at the 'o').
        Some((neg, &rest[1..], 8))
    } else if rest.is_empty() {
        None
    } else {
        // Plain decimal, including a bare "0".
        Some((neg, rest, 10))
    }
}

/// C `strtol(s, _, 0)` for the signed `addr` link field. The `long`→`int`
/// assignment in C truncates on overflow, mirrored here by the `as i32`
/// wrap.
fn strtol_base0_i32(tok: &str) -> Option<i32> {
    let (neg, digits, radix) = split_base0(tok)?;
    let mag = i64::from_str_radix(digits, radix).ok()?;
    Some((if neg { mag.wrapping_neg() } else { mag }) as i32)
}

/// C `strtoul(s, _, 0)` for the unsigned `mask` link field. A leading `-`
/// negates modulo 2^32 (the C standard mandates strtoul negate-then-cast),
/// so `-8` yields `0xFFFFFFF8` — the bit pattern asynInt32 reinterprets as
/// signed nbits via [`Int32Mask::from_nbits`].
fn strtoul_base0_u32(tok: &str) -> Option<u32> {
    let (neg, digits, radix) = split_base0(tok)?;
    let mag = u64::from_str_radix(digits, radix).ok()?;
    Some((if neg { mag.wrapping_neg() } else { mag }) as u32)
}

/// Parse an asyn link string.
///
/// Accepted formats (comma or space delimited, matching C EPICS):
/// - `@asyn(portName) drvInfo`
/// - `@asyn(portName, addr) drvInfo`
/// - `@asyn(portName, addr, timeout) drvInfo`
/// - `@asyn(portName addr) drvInfo`
/// - `@asyn(portName addr timeout) drvInfo`
pub fn parse_asyn_link(s: &str) -> Result<AsynLink, AsynError> {
    let s = s.trim();
    let rest = s
        .strip_prefix("@asyn(")
        .ok_or_else(|| AsynError::InvalidLinkSyntax(format!("must start with @asyn(: {s}")))?;

    let paren_end = rest
        .find(')')
        .ok_or_else(|| AsynError::InvalidLinkSyntax(format!("missing closing paren: {s}")))?;

    let args_str = &rest[..paren_end];
    let drv_info = rest[paren_end + 1..].trim().to_string();

    // C EPICS pasynEpicsUtils->parseLink accepts both comma and space as delimiters.
    // Split by comma first; if only one part, try splitting by whitespace.
    let parts: Vec<&str> = if args_str.contains(',') {
        args_str.split(',').map(|p| p.trim()).collect()
    } else {
        args_str.split_whitespace().collect()
    };
    if parts.is_empty() || parts[0].is_empty() {
        return Err(AsynError::InvalidLinkSyntax("portName is required".into()));
    }

    let port_name = parts[0].to_string();
    let addr = if parts.len() > 1 {
        // C asynEpicsUtils.c:114 `strtol(pnext, &endp, 0)` — base auto.
        strtol_base0_i32(parts[1])
            .ok_or_else(|| AsynError::InvalidLinkSyntax(format!("invalid addr: {}", parts[1])))?
    } else {
        0
    };
    let timeout = if parts.len() > 2 {
        let secs: f64 = parts[2]
            .parse()
            .map_err(|_| AsynError::InvalidLinkSyntax(format!("invalid timeout: {}", parts[2])))?;
        Duration::from_secs_f64(secs)
    } else {
        Duration::from_secs(1)
    };

    Ok(AsynLink {
        port_name,
        addr,
        timeout,
        drv_info,
    })
}

/// Parsed `@asynMask(portName, addr, mask, timeout) drvInfoString` link specification.
#[derive(Debug, Clone)]
pub struct AsynMaskLink {
    pub port_name: String,
    pub addr: i32,
    pub mask: u32,
    pub timeout: Duration,
    pub drv_info: String,
}

/// Parse an asynMask link string.
///
/// Format: `@asynMask(portName, addr, mask[, timeout]) drvInfo`
pub fn parse_asyn_mask_link(s: &str) -> Result<AsynMaskLink, AsynError> {
    let s = s.trim();
    let rest = s
        .strip_prefix("@asynMask(")
        .ok_or_else(|| AsynError::InvalidLinkSyntax(format!("must start with @asynMask(: {s}")))?;

    let paren_end = rest
        .find(')')
        .ok_or_else(|| AsynError::InvalidLinkSyntax(format!("missing closing paren: {s}")))?;

    let args_str = &rest[..paren_end];
    let drv_info = rest[paren_end + 1..].trim().to_string();

    let parts: Vec<&str> = args_str.split(',').map(|p| p.trim()).collect();
    if parts.len() < 3 {
        return Err(AsynError::InvalidLinkSyntax(
            "asynMask requires at least 3 arguments: portName, addr, mask".into(),
        ));
    }

    let port_name = parts[0].to_string();
    // C asynEpicsUtils.c:186 `strtol(pnext, &endp, 0)` — base auto.
    let addr = strtol_base0_i32(parts[1])
        .ok_or_else(|| AsynError::InvalidLinkSyntax(format!("invalid addr: {}", parts[1])))?;

    // C asynEpicsUtils.c:193 `strtoul(pnext, &endp, 0)` — base auto
    // (0x hex, leading-0 octal, else decimal). The resulting 32-bit
    // pattern is reinterpreted per interface: asynInt32 reads it as a
    // signed nbits via `as i32` (a negative count like `-8` parses through
    // strtoul to 0xFFFFFFF8; see `Int32Mask::from_nbits`), asynUInt32Digital
    // as a raw bitmask.
    let mask_str = parts[2];
    let mask = strtoul_base0_u32(mask_str)
        .ok_or_else(|| AsynError::InvalidLinkSyntax(format!("invalid mask: {mask_str}")))?;

    let timeout = if parts.len() > 3 {
        let secs: f64 = parts[3]
            .parse()
            .map_err(|_| AsynError::InvalidLinkSyntax(format!("invalid timeout: {}", parts[3])))?;
        Duration::from_secs_f64(secs)
    } else {
        Duration::from_secs(1)
    };

    Ok(AsynMaskLink {
        port_name,
        addr,
        mask,
        timeout,
        drv_info,
    })
}

/// asynInt32 `@asynMask` bit-count (nbits) configuration.
///
/// For the **asynInt32** interface the third `@asynMask` argument is a
/// signed bit COUNT, not a raw bitmask (C devAsynInt32.c:232-247):
/// a negative count selects *bipolar* handling (sign-extend on read),
/// a positive count *unipolar* (plain low-bit mask). The derived `mask`
/// keeps the low `|nbits|` bits; `sign_bit` is the top of that field;
/// `device_low`/`device_high` are the raw device range used for the
/// LINEAR ESLO/EOFF slope (convertAi:444-451), and they take precedence
/// over the driver's `getBounds` (initAi:822-826).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Int32Mask {
    mask: u32,
    sign_bit: u32,
    bipolar: bool,
    device_low: i32,
    device_high: i32,
}

impl Int32Mask {
    /// Derive from the signed nbits value (the reinterpreted `@asynMask`
    /// 3rd arg). Returns `None` for `nbits == 0` — C leaves `mask = 0`,
    /// so the read/interrupt masking blocks are skipped entirely.
    fn from_nbits(nbits_signed: i32) -> Option<Self> {
        if nbits_signed == 0 {
            return None;
        }
        let bipolar = nbits_signed < 0;
        // Clamp to 32 so the shifts below stay defined (C relies on the
        // platform `int` width; asynInt32 is 32-bit).
        let nbits = nbits_signed.unsigned_abs().min(32);
        // C `~(~0 << nbits)`: the low `nbits` bits set. `<< 32` is UB in
        // C and panics in Rust, so the full-width case is taken directly.
        let mask = if nbits >= 32 {
            u32::MAX
        } else {
            !(!0u32 << nbits)
        };
        let sign_bit = 1u32 << (nbits - 1);
        let (device_low, device_high) = if bipolar {
            // C: deviceLow = ~(mask/2)+1 (= -(mask/2)); deviceHigh = mask/2.
            let half = (mask / 2) as i32;
            (-half, half)
        } else {
            // C: deviceLow = 0; deviceHigh = mask.
            (0, mask as i32)
        };
        Some(Self {
            mask,
            sign_bit,
            bipolar,
            device_low,
            device_high,
        })
    }

    /// C `processCallbackInput` / `interruptCallbackInput` mask +
    /// sign-extend (devAsynInt32.c:485-488 / 537-540): keep the low
    /// `nbits`, then for bipolar fields with the sign bit set, extend the
    /// sign into the high bits.
    fn apply(self, value: i32) -> i32 {
        let mut v = (value as u32) & self.mask;
        if self.bipolar && (v & self.sign_bit) != 0 {
            v |= !self.mask;
        }
        v as i32
    }
}

/// Adapter bridging an asyn-rs PortDriver to epics-base-rs DeviceSupport.
pub struct AsynDeviceSupport {
    handle: PortHandle,
    addr: i32,
    timeout: Duration,
    drv_info: String,
    reason: usize,
    reason_set: bool,
    iface_type: String,
    /// Typed interface (resolved from `iface_type` string at construction).
    iface: Option<InterfaceType>,
    /// Bit mask for UInt32Digital read/write. Default: 0xFFFFFFFF.
    mask: u32,
    /// asynInt32 `@asynMask` nbits config (mask + sign-extend + device
    /// bounds), derived when the interface is `asynInt32` and a non-zero
    /// bit count was given. `None` for every other case (no masking).
    int32_mask: Option<Int32Mask>,
    last_alarm_status: u16,
    last_alarm_severity: u16,
    last_ts: Option<SystemTime>,
    record_name: String,
    scan: ScanType,
    /// Maximum number of array elements for array read operations.
    /// Default: 307200 (enough for 640x480 images).
    max_array_elements: usize,
    /// Buffer cap for `asynOctet` reads, including the trailing NUL.
    /// C asyn devAsynOctet.c:1103 passes `plsi->sizv` as the read
    /// limit for lsi/lso/printf — the byte the driver leaves at
    /// position `sizv` is overwritten with `\0` (line 1124). For
    /// stringin/stringout the C path is the fixed 40-byte record
    /// field; for waveform it uses NELM*FTVL. We pick the per-record
    /// SIZV at init time when available; otherwise fall back to the
    /// stringin-grade 256-byte default.
    octet_max_size: usize,
    /// If true, read back the current driver value during init (for output records).
    initial_readback: bool,
    /// `info(asyn:READBACK, "1")` flag, asyn upstream PRs #60 / #208.
    /// When set on an output record, the adapter activates the
    /// driver-callback path even when `SCAN != "I/O Intr"` so the
    /// record reprocesses on every external value change. Wired
    /// through `io_intr_receiver` together with the existing IoIntr
    /// scan path.
    asyn_readback: bool,
    /// If true, this is a write-only device support (e.g. asynOctetWrite).
    /// read() returns no-op to avoid overwriting the record's native value type.
    write_only: bool,
    /// RAII interrupt subscription — dropping unsubscribes.
    interrupt_sub: Option<InterruptSubscription>,
    /// Per-record ring buffer of interrupt values, FIFO-ordered. The
    /// I/O Intr forwarding task pushes; `read()` pops the oldest
    /// entry. C parity: `devAsynInt32.c::ringBuffer` (DEFAULT 10,
    /// configurable via `info("asyn:FIFO")`). Overflow policy:
    /// drop-oldest + `overflows++`; the record-process wakeup is
    /// **only** sent on a fresh-entry add (not on overflow
    /// overwrite) so the dbScan queue does not flood.
    interrupt_fifo: Arc<std::sync::Mutex<InterruptFifo>>,
    /// C `devAsynInt32.c::newOutputCallbackValue` (asyn devEpics, the
    /// `devPvt` flag). Set when a driver-callback readback cycle is armed
    /// (`arm_readback_callback`, mirrors `outputCallbackCallback` setting
    /// the flag to 1 before `dbProcess`) and cleared the moment the record
    /// actually reaches its read stage (`read()` clears it, mirroring
    /// `processBo` clearing the flag). If the cycle never reaches `read()`
    /// — the PACT entry guard bailed because a put / FLNK cycle still owns
    /// the record — the flag survives and `reconcile_readback_callback`
    /// discards the stale ring entry (C `outputCallbackCallback`'s fallback
    /// `getCallbackValue`), so every output callback consumes exactly one
    /// ring entry (1 wakeup == 1 pop). Without it a readback that races the
    /// record's own put leaves the FIFO desynced and the final driver value
    /// (e.g. AD `Acquire` returning to 0) is never popped.
    output_callback_pending: bool,
    /// True once the `asynFloat64` ai path has written VAL at least once.
    /// The SMOO filter primes on the first read (C `processAi` skips
    /// smoothing while `pr->udf`, devAsynFloat64.c:599); this is the
    /// adapter-side `!udf` for that path, where the adapter is the sole
    /// VAL writer so "have I produced a value yet" is exactly C's prime
    /// signal — and it does not depend on the framework's UDF lifecycle.
    smoo_primed: bool,
    /// Enum state-field family captured at init when the record exposes
    /// ZRST/ZNAM and the driver provided an asynEnum table. `Some` arms the
    /// runtime re-propagation callback ([`Self::property_post_receiver`]);
    /// `None` means no enum table (mirrors C's
    /// `findInterface(asynEnumType) && maxEnums>0` gate).
    enum_shape: Option<EnumRecordShape>,
    /// The driver enum table applied at init — the diff seed for the
    /// runtime callback so a value-only change (enum index moves, choices
    /// unchanged) fires no DBE_PROPERTY post (C's asynEnum callback only
    /// fires on `doCallbacksEnum`, never on an int32 value callback).
    enum_choices: Option<Arc<[crate::param::EnumEntry]>>,
    /// RAII subscription for the runtime enum-table interrupt — dropping
    /// unsubscribes. Distinct from `interrupt_sub` (the value I/O Intr
    /// subscription); C registers the asynEnum callback separately.
    enum_interrupt_sub: Option<InterruptSubscription>,
}

/// Cached interrupt value with metadata for alarm/timestamp propagation.
struct CachedInterrupt {
    value: crate::param::ParamValue,
    timestamp: SystemTime,
    /// Driver-reported alarm (EPICS alarm status/severity) captured at
    /// callback time. C devAsynInt32.c:561-563 stores rp->alarmStatus /
    /// rp->alarmSeverity in each ring-buffer element so processXxx can
    /// recGblSetSevr them (devAsynInt32.c:843-847). Mirrors the polled
    /// read path's `RequestResult` alarm carrier.
    alarm_status: u16,
    alarm_severity: u16,
}

/// Per-record ring buffer for I/O Intr callbacks. Mirrors C
/// `devAsynInt32.c::ringBuffer` + `ringSize` + `ringBufferOverflows`.
struct InterruptFifo {
    entries: std::collections::VecDeque<CachedInterrupt>,
    /// Maximum entries (`asyn:FIFO` info-tag, default
    /// `DEFAULT_RING_BUFFER_SIZE`).
    ring_size: usize,
    /// Running count of drop-oldest overwrites since the last
    /// successful pop. Logged + reset by `pop_callback_value`.
    overflows: u64,
}

/// C `DEFAULT_RING_BUFFER_SIZE` at `devAsynInt32.c:63`.
const DEFAULT_RING_BUFFER_SIZE: usize = 10;

impl InterruptFifo {
    fn new() -> Self {
        Self {
            entries: std::collections::VecDeque::with_capacity(DEFAULT_RING_BUFFER_SIZE),
            ring_size: DEFAULT_RING_BUFFER_SIZE,
            overflows: 0,
        }
    }

    /// Producer-side: push a fresh interrupt entry, dropping the
    /// oldest if full. Returns `true` if this push corresponds to a
    /// *new* entry (and therefore the caller should schedule a record
    /// process); `false` on overflow (no scanIoRequest in C parity).
    fn push_with_overflow(&mut self, entry: CachedInterrupt) -> bool {
        if self.entries.len() >= self.ring_size {
            self.entries.pop_front();
            self.overflows += 1;
            self.entries.push_back(entry);
            false
        } else {
            self.entries.push_back(entry);
            true
        }
    }

    /// Consumer-side: pop the oldest entry. Returns `None` when
    /// empty. The `overflows` counter is reset by the caller via
    /// `take_overflows` so the trace warning fires once per drain
    /// (C `getCallbackValue` behaviour).
    fn pop(&mut self) -> Option<CachedInterrupt> {
        self.entries.pop_front()
    }

    /// Read and clear the overflow counter for a single warning emit.
    fn take_overflows(&mut self) -> u64 {
        std::mem::take(&mut self.overflows)
    }
}

impl AsynDeviceSupport {
    /// Create from a [`PortHandle`].
    pub fn from_handle(handle: PortHandle, link: AsynLink, iface_type: &str) -> Self {
        let iface = InterfaceType::from_asyn_name(iface_type);
        Self {
            handle,
            addr: link.addr,
            timeout: link.timeout,
            drv_info: link.drv_info,
            reason: 0,
            reason_set: false,
            iface_type: iface_type.to_string(),
            iface,
            mask: 0xFFFFFFFF,
            int32_mask: None,
            max_array_elements: 307200,
            octet_max_size: 256,
            last_alarm_status: 0,
            last_alarm_severity: 0,
            last_ts: None,
            record_name: String::new(),
            scan: ScanType::Passive,
            initial_readback: false,
            asyn_readback: false,
            write_only: false,
            interrupt_sub: None,
            interrupt_fifo: Arc::new(std::sync::Mutex::new(InterruptFifo::new())),
            output_callback_pending: false,
            smoo_primed: false,
            enum_shape: None,
            enum_choices: None,
            enum_interrupt_sub: None,
        }
    }

    /// Create with a typed interface from a [`PortHandle`].
    pub fn with_interface_handle(handle: PortHandle, link: AsynLink, iface: InterfaceType) -> Self {
        Self::from_handle(handle, link, iface.asyn_name())
    }

    /// Set the bit mask for UInt32Digital read/write operations.
    ///
    /// For the `asynInt32` interface the same `@asynMask` 3rd arg is a
    /// signed bit COUNT (nbits), not a raw bitmask (C devAsynInt32.c:
    /// 232-247): reinterpret the stored 32-bit pattern as `i32` and derive
    /// the mask + sign-extend + device-bounds config from it.
    pub fn with_mask(mut self, mask: u32) -> Self {
        self.mask = mask;
        if self.iface_type == "asynInt32" {
            self.int32_mask = Int32Mask::from_nbits(mask as i32);
        }
        self
    }

    /// Enable initial readback: on init, read the current value from the driver
    /// and set it on the record (for output records).
    pub fn with_initial_readback(mut self) -> Self {
        self.initial_readback = true;
        self
    }

    /// Enable `asyn:READBACK` mode (asyn upstream PRs #60 / #208).
    ///
    /// Manual override. The framework auto-parses
    /// `info("asyn:READBACK", "...")` from the record's info map via
    /// [`Self::apply_record_info`] — `wire_device_support` and
    /// `IocBuilder` both call that hook automatically, so this manual
    /// setter is only needed for callers that construct an adapter
    /// outside the IocApplication wiring (e.g. unit tests, embedded
    /// scripts). C asyn parity: matches `asynDbGetInfo(pr,
    /// "asyn:READBACK")` calls in `devAsynInt32.c:329`,
    /// `devAsynFloat64.c:218`, `devAsynInt64.c:257`,
    /// `devAsynUInt32Digital.c:286`, `devAsynOctet.c:337`,
    /// `devAsynXXXArray.cpp:172`.
    pub fn set_asyn_readback(&mut self, on: bool) {
        self.asyn_readback = on;
    }

    /// Override initial-readback mode. The framework auto-parses
    /// `info("asyn:INITIAL_READBACK", "...")` via
    /// [`Self::apply_record_info`] (mirror of `asynDbGetInfo(precord,
    /// "asyn:INITIAL_READBACK")` at `devAsynOctet.c:357`).
    pub fn set_initial_readback(&mut self, on: bool) {
        self.initial_readback = on;
    }

    /// Override the I/O Intr ring buffer size. The framework
    /// auto-parses `info("asyn:FIFO", "<n>")` via
    /// [`Self::apply_record_info`]; this manual setter exists for
    /// callers outside the IocApplication wiring. C parity:
    /// `devAsynInt32.c::createRingBuffer` at line 354-365 — sets
    /// `pPvt->ringSize` to `atoi(info)` or `DEFAULT_RING_BUFFER_SIZE`
    /// when the info tag is absent.
    pub fn set_fifo_size(&mut self, n: usize) {
        let mut g = self.interrupt_fifo.lock().unwrap();
        g.ring_size = n.max(1);
        // Drop any over-capacity entries already buffered so the new
        // limit takes effect immediately. Counts the truncation as
        // overflows so the trace warning fires.
        while g.entries.len() > g.ring_size {
            g.entries.pop_front();
            g.overflows = g.overflows.saturating_add(1);
        }
    }

    /// Set the driver info string (used for `drv_user_create` during init).
    /// Allows record-name-based device support to configure the adapter
    /// in `set_record_info()` before `init()` runs.
    pub fn set_drv_info(&mut self, drv_info: &str) {
        self.drv_info = drv_info.to_string();
    }

    /// Set the interface type string (e.g. "asynInt32", "asynFloat64").
    /// Allows record-name-based device support to configure the adapter
    /// in `set_record_info()` before `init()` runs.
    pub fn set_iface_type(&mut self, iface_type: &str) {
        self.iface_type = iface_type.to_string();
        self.iface = InterfaceType::from_asyn_name(iface_type);
    }

    /// Set the param reason (index) directly, skipping `drv_user_create` during init.
    /// Use when the caller already knows the param index.
    pub fn set_reason(&mut self, reason: usize) {
        self.reason = reason;
        self.reason_set = true;
    }

    /// Get the param reason (index).
    pub fn reason(&self) -> usize {
        self.reason
    }

    /// Get the asyn address.
    pub fn addr(&self) -> i32 {
        self.addr
    }

    /// Get a reference to the underlying port handle.
    pub fn handle(&self) -> &PortHandle {
        &self.handle
    }

    /// Build a write request op from an EpicsValue (public wrapper for subclasses).
    pub fn write_op_pub(&self, val: &EpicsValue) -> Option<RequestOp> {
        self.write_op(val)
    }
}

/// Mirror of C asyn `computeShift(epicsUInt32 mask)` at
/// `devAsynUInt32Digital.c:627-636`: returns the position of the
/// lowest set bit (0..=32). A mask of 0 falls through the loop and
/// returns 32 — but mbbi/mbbo treat 0 as "use the full word", so
/// callers should guard before invoking.
fn compute_mask_shift(mask: u32) -> u32 {
    let mut bit: u32 = 1;
    for i in 0..32 {
        if (mask & bit) != 0 {
            return i;
        }
        bit <<= 1;
    }
    32
}

/// Parse an `info(...)` tag value as a boolean.
///
/// Truthy: non-empty and not `0` / `no` / `false` (case-insensitive).
/// Mirrors the broader EPICS convention; C asyn uses `atoi` directly
/// (`devAsynInt32.c:330` — `enableCallbacks = atoi(callbackString)`),
/// which only treats `"0"` / non-numeric as falsey. Our parse is a
/// strict superset for the documented `"1"` / `"0"` values and
/// additionally accepts the human-friendly `"Y"` / `"true"` forms.
fn parse_info_bool(raw: &str) -> bool {
    let v = raw.trim();
    !v.is_empty()
        && !v.eq_ignore_ascii_case("0")
        && !v.eq_ignore_ascii_case("no")
        && !v.eq_ignore_ascii_case("false")
}

fn asyn_to_ca_error(e: AsynError) -> CaError {
    CaError::Protocol(e.to_string())
}

/// Convert an asyn error to an EPICS (alarm status, alarm severity) pair
/// for the device-support READ path.
///
/// Mirrors C `asynStatusToEpicsAlarm` (asynEpicsUtils.c:234-266) with the
/// READ-path defaults the Int32/Float64/UInt32 device support pass:
/// `defaultStat = READ_ALARM`, `defaultSevr = INVALID_ALARM`
/// (devAsynInt32.c:844-846). Every non-success asyn status maps to
/// INVALID severity; only the condition code differs:
/// - Success      → (NO_ALARM, NO_ALARM)
/// - Timeout      → (TIMEOUT_ALARM, INVALID)   = (10, 3)
/// - Overflow     → (HW_LIMIT_ALARM, INVALID)  = (11, 3)
/// - Disconnected → (COMM_ALARM, INVALID)      = (9, 3)
/// - Disabled     → (DISABLE_ALARM, INVALID)   = (18, 3)
/// - Error/other  → (READ_ALARM, INVALID)      = (1, 3)
///
/// Pre-fix this mapping was wrong: Timeout / Overflow / Error all yielded
/// (7, 2) — condition 7 is STATE_ALARM, not READ_ALARM (which is 1), and
/// MAJOR(2) instead of INVALID(3) — and Disabled was lumped with
/// Disconnected as COMM_ALARM(9) instead of DISABLE_ALARM(18).
fn asyn_error_to_alarm(e: &AsynError) -> (u16, u16) {
    use crate::error::AsynStatus;
    use epics_base_rs::server::recgbl::alarm_status;
    use epics_base_rs::server::record::AlarmSeverity;
    let invalid = AlarmSeverity::Invalid as u16;
    match e {
        AsynError::Status {
            status: AsynStatus::Success,
            ..
        } => (alarm_status::NO_ALARM, AlarmSeverity::NoAlarm as u16),
        AsynError::Status {
            status: AsynStatus::Timeout,
            ..
        } => (alarm_status::TIMEOUT_ALARM, invalid),
        AsynError::Status {
            status: AsynStatus::Overflow,
            ..
        } => (alarm_status::HW_LIMIT_ALARM, invalid),
        AsynError::Status {
            status: AsynStatus::Disconnected,
            ..
        } => (alarm_status::COMM_ALARM, invalid),
        AsynError::Status {
            status: AsynStatus::Disabled,
            ..
        } => (alarm_status::DISABLE_ALARM, invalid),
        AsynError::Status {
            status: AsynStatus::Error,
            ..
        } => (alarm_status::READ_ALARM, invalid),
        // Non-status asyn errors take C's asynError/default branch.
        _ => (alarm_status::READ_ALARM, invalid),
    }
}

/// Convert an asyn ParamValue to an EpicsValue.
fn param_value_to_epics_value(pv: &crate::param::ParamValue) -> Option<EpicsValue> {
    use crate::param::ParamValue;
    match pv {
        ParamValue::Int32(v) => Some(EpicsValue::Long(*v)),
        ParamValue::Int64(v) => Some(EpicsValue::Double(*v as f64)),
        ParamValue::Float64(v) => Some(EpicsValue::Double(*v)),
        ParamValue::Octet(s) => Some(EpicsValue::String(s.clone().into())),
        ParamValue::UInt32Digital(v) => Some(EpicsValue::Long(*v as i32)),
        ParamValue::Enum { index, .. } => Some(EpicsValue::Enum(*index as u16)),
        ParamValue::Int8Array(a) => {
            Some(EpicsValue::CharArray(a.iter().map(|&x| x as u8).collect()))
        }
        ParamValue::Int16Array(a) => Some(EpicsValue::ShortArray(a.to_vec())),
        ParamValue::Int32Array(a) => Some(EpicsValue::LongArray(a.to_vec())),
        ParamValue::Int64Array(a) => {
            Some(EpicsValue::LongArray(a.iter().map(|&x| x as i32).collect()))
        }
        ParamValue::Float32Array(a) => Some(EpicsValue::FloatArray(a.to_vec())),
        ParamValue::Float64Array(a) => Some(EpicsValue::DoubleArray(a.to_vec())),
        _ => None,
    }
}

/// Convert a native-typed array interrupt to the element type of the consuming
/// record's asyn array interface.
///
/// C `NDPluginStdArrays::processCallbacks` fires an interrupt on every one of
/// the six array interfaces, each running `pNDArrayPool->convert(pArray, ...,
/// signedType)` so a record on any interface receives its own type-converted
/// copy (NDPluginStdArrays.cpp:169-197). The Rust runtime carries a single
/// native-typed array per param; this routes it through the same per-interface
/// `convert` the polled `readArray` path uses ([`AsynDeviceSupport::result_to_value`]),
/// so an I/O Intr waveform whose FTVL differs from the array's native element
/// type still gets correctly-converted data each frame instead of the raw
/// native array. Returns `None` for non-array interfaces (scalar records keep
/// [`param_value_to_epics_value`]).
fn convert_param_array_to_iface(
    iface_type: &str,
    pv: &crate::param::ParamValue,
) -> Option<EpicsValue> {
    use crate::param::ParamValue;
    // `src as $t` is exactly the polled converter: integer->integer is a
    // truncating C cast, float->integer saturates, and casts to float round —
    // matching `copy_ccast`/`copy_convert` in the plugin runtime.
    macro_rules! cast_vec {
        ($t:ty) => {{
            match pv {
                ParamValue::Int8Array(a) => a.iter().map(|&x| x as $t).collect::<Vec<$t>>(),
                ParamValue::Int16Array(a) => a.iter().map(|&x| x as $t).collect(),
                ParamValue::Int32Array(a) => a.iter().map(|&x| x as $t).collect(),
                ParamValue::Int64Array(a) => a.iter().map(|&x| x as $t).collect(),
                ParamValue::Float32Array(a) => a.iter().map(|&x| x as $t).collect(),
                ParamValue::Float64Array(a) => a.iter().map(|&x| x as $t).collect(),
                _ => return None,
            }
        }};
    }
    match iface_type {
        // i8 then reinterpret to u8, matching result_to_value's CharArray mapping.
        "asynInt8Array" => Some(EpicsValue::CharArray(
            cast_vec!(i8).into_iter().map(|x| x as u8).collect(),
        )),
        "asynInt16Array" => Some(EpicsValue::ShortArray(cast_vec!(i16))),
        "asynInt32Array" => Some(EpicsValue::LongArray(cast_vec!(i32))),
        // i64 then down-cast to i32, matching result_to_value's asynInt64Array mapping.
        "asynInt64Array" => Some(EpicsValue::LongArray(
            cast_vec!(i64).into_iter().map(|x| x as i32).collect(),
        )),
        "asynFloat32Array" => Some(EpicsValue::FloatArray(cast_vec!(f32))),
        "asynFloat64Array" => Some(EpicsValue::DoubleArray(cast_vec!(f64))),
        _ => None,
    }
}

/// Bridges async `AsyncCompletionHandle` to epics-base-rs `WriteCompletion`.
struct AsynAsyncWriteCompletion {
    handle: parking_lot::Mutex<Option<AsyncCompletionHandle>>,
}

impl WriteCompletion for AsynAsyncWriteCompletion {
    fn wait(&self, timeout: Duration) -> CaResult<()> {
        if let Some(h) = self.handle.lock().take() {
            match h.wait_blocking(timeout) {
                Ok(_) => Ok(()),
                Err(e) => Err(CaError::Protocol(e.to_string())),
            }
        } else {
            Ok(())
        }
    }
}

/// mbbi/mbbo state-string fields ZRST..FFST in state order (16 states).
/// C `setEnums` writes the driver's enum strings starting at `&pr->zrst`
/// (devAsynInt32.c:720) into this contiguous block.
const MBB_STRING_FIELDS: [&str; 16] = [
    "ZRST", "ONST", "TWST", "THST", "FRST", "FVST", "SXST", "SVST", "EIST", "NIST", "TEST", "ELST",
    "TVST", "TTST", "FTST", "FFST",
];
/// mbbi/mbbo state raw-value fields ZRVL..FFVL (parallel to [`MBB_STRING_FIELDS`]).
const MBB_VALUE_FIELDS: [&str; 16] = [
    "ZRVL", "ONVL", "TWVL", "THVL", "FRVL", "FVVL", "SXVL", "SVVL", "EIVL", "NIVL", "TEVL", "ELVL",
    "TVVL", "TTVL", "FTVL", "FFVL",
];
/// mbbi/mbbo state severity fields ZRSV..FFSV (parallel to [`MBB_STRING_FIELDS`]).
const MBB_SEVERITY_FIELDS: [&str; 16] = [
    "ZRSV", "ONSV", "TWSV", "THSV", "FRSV", "FVSV", "SXSV", "SVSV", "EISV", "NISV", "TESV", "ELSV",
    "TVSV", "TTSV", "FTSV", "FFSV",
];
/// bi/bo state-string fields ZNAM/ONAM (2 states; C `initBi` passes
/// `maxEnums=2`, `&pr->znam`, devAsynInt32.c:1138-1140).
const BI_STRING_FIELDS: [&str; 2] = ["ZNAM", "ONAM"];
/// bi/bo state severity fields ZSV/OSV (bi/bo carry no raw-value fields,
/// so C passes a NULL value pointer).
const BI_SEVERITY_FIELDS: [&str; 2] = ["ZSV", "OSV"];
/// C `MAX_ENUM_STRING_SIZE` (devAsynInt32.c:66): enum strings are capped
/// at 25 chars + NUL when copied into the record's DBF_STRING slots.
const MAX_ENUM_STRING_LEN: usize = 25;

/// The state-field family an enum record exposes — the C `initMbbi` vs
/// `initBi` distinction. `Mbb` covers mbbi/mbbo (16 states, ZRST/ZRVL/ZRSV…);
/// `Bi` covers bi/bo (2 states, ZNAM/ONAM + ZSV/OSV, no raw-value fields).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EnumRecordShape {
    Mbb,
    Bi,
}

impl EnumRecordShape {
    /// Discriminate by the state fields the record exposes (mirroring C's
    /// per-record init: a `ZRST` field => mbbi/mbbo, a `ZNAM` field =>
    /// bi/bo). `None` for any record that is not an enum state record.
    fn of_record(record: &dyn Record) -> Option<Self> {
        if record.get_field("ZRST").is_some() {
            Some(Self::Mbb)
        } else if record.get_field("ZNAM").is_some() {
            Some(Self::Bi)
        } else {
            None
        }
    }

    /// (string-fields, optional value-fields, severity-fields) for this
    /// shape. bi/bo carry no raw-value fields (C passes a NULL value ptr).
    #[allow(clippy::type_complexity)]
    fn fields(
        self,
    ) -> (
        &'static [&'static str],
        Option<&'static [&'static str]>,
        &'static [&'static str],
    ) {
        match self {
            Self::Mbb => (
                &MBB_STRING_FIELDS,
                Some(&MBB_VALUE_FIELDS),
                &MBB_SEVERITY_FIELDS,
            ),
            Self::Bi => (&BI_STRING_FIELDS, None, &BI_SEVERITY_FIELDS),
        }
    }
}

/// Build the `(field, value)` list C `setEnums` (devAsynInt32.c:415-435)
/// writes for `entries` onto a record of the given `shape` — the single
/// producer shared by init (apply directly to the record) and the runtime
/// callback (post DBE_PROPERTY). Faithful to `setEnums`: every output slot
/// up to the record's state count is first cleared (empty string / value 0
/// / severity 0), then the driver's entries fill slots `0..numIn`, so a
/// driver advertising fewer states blanks the record's surplus `.db`
/// strings. Strings are truncated to MAX_ENUM_STRING_SIZE-1.
fn enum_table_fields(
    shape: EnumRecordShape,
    entries: &[crate::param::EnumEntry],
) -> Vec<(String, EpicsValue)> {
    let (strings, values, severities) = shape.fields();
    let mut out = Vec::with_capacity(strings.len() * 3);
    for i in 0..strings.len() {
        let entry = entries.get(i);
        let mut s = entry.map(|e| e.string.clone()).unwrap_or_default();
        // C `setEnums` truncates to MAX_ENUM_STRING_SIZE-1 (byte memcpy);
        // EPICS state strings are ASCII so a char boundary at 25 is safe.
        if s.chars().count() > MAX_ENUM_STRING_LEN {
            s = s.chars().take(MAX_ENUM_STRING_LEN).collect();
        }
        out.push((strings[i].to_string(), EpicsValue::String(s.into())));
        if let Some(value_fields) = values {
            let value = entry.map(|e| e.value).unwrap_or(0);
            out.push((value_fields[i].to_string(), EpicsValue::ULong(value as u32)));
        }
        let severity = entry.map(|e| e.severity).unwrap_or(0);
        out.push((
            severities[i].to_string(),
            EpicsValue::Short(severity as i16),
        ));
    }
    out
}

impl AsynDeviceSupport {
    /// C parity for `devAsynInt32.c::initAi` (lines 821-828) +
    /// `convertAi` (lines 437-454): query the driver's int32 / int64
    /// bounds, then compute `ESLO = (EGUF - EGUL) / (high - low)` and
    /// `EOFF = (high*EGUL - low*EGUF) / (high - low)` and write
    /// them on the record. Caller has already verified the record
    /// exposes ESLO and the interface is `asynInt32` / `asynInt64`.
    fn apply_linear_eslo_eoff(&self, record: &mut dyn Record) {
        // C devAsynInt32.c:822-826 — the nbits-derived deviceLow/High (set
        // when @asynMask gives a bit count) take precedence; `getBounds` is
        // the fallback only when no nbits was specified.
        let (low, high) = if let Some(m) = self.int32_mask {
            (m.device_low as f64, m.device_high as f64)
        } else {
            let op = if self.iface_type == "asynInt64" {
                RequestOp::GetBoundsInt64
            } else {
                RequestOp::GetBoundsInt32
            };
            let user = AsynUser::new(self.reason)
                .with_addr(self.addr)
                .with_timeout(self.timeout);
            let result = match self.handle.submit_blocking(op, user) {
                Ok(r) => r,
                Err(_) => return,
            };
            match result.bounds {
                Some((l, h)) => (l as f64, h as f64),
                None => return,
            }
        };
        // C parity: `if (deviceHigh != deviceLow)` (convertAi:444).
        // A degenerate driver that reports 0,0 (the "I don't know"
        // sentinel — matches the early-out at initAi:824) leaves the
        // existing ESLO/EOFF untouched.
        if (high - low).abs() < f64::EPSILON {
            return;
        }
        let eguf = match record.get_field("EGUF") {
            Some(EpicsValue::Double(v)) => v,
            _ => return,
        };
        let egul = match record.get_field("EGUL") {
            Some(EpicsValue::Double(v)) => v,
            _ => return,
        };
        let denom = high - low;
        let eslo = (eguf - egul) / denom;
        let eoff = (high * egul - low * eguf) / denom;
        let _ = record.put_field("ESLO", EpicsValue::Double(eslo));
        let _ = record.put_field("EOFF", EpicsValue::Double(eoff));
    }

    /// Build a `RequestOp` for reading the current interface type.
    fn read_op(&self) -> Option<RequestOp> {
        match self.iface_type.as_str() {
            "asynInt32" => Some(RequestOp::Int32Read),
            "asynInt64" => Some(RequestOp::Int64Read),
            "asynFloat64" => Some(RequestOp::Float64Read),
            "asynOctet" => Some(RequestOp::OctetRead {
                buf_size: self.octet_max_size,
            }),
            "asynUInt32Digital" => Some(RequestOp::UInt32DigitalRead { mask: self.mask }),
            "asynEnum" => Some(RequestOp::EnumRead),
            "asynInt8Array" => Some(RequestOp::Int8ArrayRead {
                max_elements: self.max_array_elements,
            }),
            "asynInt16Array" => Some(RequestOp::Int16ArrayRead {
                max_elements: self.max_array_elements,
            }),
            "asynInt32Array" => Some(RequestOp::Int32ArrayRead {
                max_elements: self.max_array_elements,
            }),
            "asynInt64Array" => Some(RequestOp::Int64ArrayRead {
                max_elements: self.max_array_elements,
            }),
            "asynFloat32Array" => Some(RequestOp::Float32ArrayRead {
                max_elements: self.max_array_elements,
            }),
            "asynFloat64Array" => Some(RequestOp::Float64ArrayRead {
                max_elements: self.max_array_elements,
            }),
            _ => None,
        }
    }

    /// Apply the asynInt32 `@asynMask` mask + sign-extend to a raw read
    /// value (C `processCallbackInput`, devAsynInt32.c:485-488). A no-op
    /// when no nbits was configured (`int32_mask == None`).
    fn apply_int32_mask(&self, value: i32) -> i32 {
        self.int32_mask.map_or(value, |m| m.apply(value))
    }

    /// Extract an EpicsValue from a RequestResult based on interface type.
    fn result_to_value(&self, result: &RequestResult) -> Option<EpicsValue> {
        match self.iface_type.as_str() {
            "asynInt32" => result
                .int_val
                .map(|v| EpicsValue::Long(self.apply_int32_mask(v))),
            "asynInt64" => result.int64_val.map(|v| EpicsValue::Double(v as f64)),
            "asynFloat64" => result.float_val.map(EpicsValue::Double),
            "asynOctet" => result.data.as_ref().map(|d| {
                let n = result.nbytes.min(d.len());
                EpicsValue::String(String::from_utf8_lossy(&d[..n]).into_owned().into())
            }),
            "asynUInt32Digital" => result.uint_val.map(|v| EpicsValue::Long(v as i32)),
            "asynEnum" => result.enum_index.map(|v| EpicsValue::Enum(v as u16)),
            "asynInt8Array" => result
                .int8_array
                .clone()
                .map(|v| EpicsValue::CharArray(v.iter().map(|&x| x as u8).collect())),
            "asynInt16Array" => result.int16_array.clone().map(EpicsValue::ShortArray),
            "asynInt32Array" => result.int32_array.clone().map(EpicsValue::LongArray),
            "asynInt64Array" => result
                .int64_array
                .clone()
                .map(|v| EpicsValue::LongArray(v.iter().map(|&x| x as i32).collect())),
            "asynFloat32Array" => result.float32_array.clone().map(EpicsValue::FloatArray),
            "asynFloat64Array" => result.float64_array.clone().map(EpicsValue::DoubleArray),
            _ => None,
        }
    }

    /// Build a `RequestOp` for writing an `EpicsValue` for the current interface type.
    fn write_op(&self, val: &EpicsValue) -> Option<RequestOp> {
        // First try exact match, then coerce numeric types to match the interface.
        // C EPICS always converts record VAL to the interface type (e.g. ao→double).
        match (self.iface_type.as_str(), val) {
            ("asynInt32", EpicsValue::Long(v)) => Some(RequestOp::Int32Write { value: *v }),
            ("asynInt32", EpicsValue::Enum(v)) => Some(RequestOp::Int32Write { value: *v as i32 }),
            ("asynInt32", EpicsValue::Short(v)) => Some(RequestOp::Int32Write { value: *v as i32 }),
            ("asynInt32", EpicsValue::Double(v)) => {
                Some(RequestOp::Int32Write { value: *v as i32 })
            }
            ("asynInt32", EpicsValue::Float(v)) => Some(RequestOp::Int32Write { value: *v as i32 }),
            ("asynInt64", EpicsValue::Long(v)) => Some(RequestOp::Int64Write { value: *v as i64 }),
            ("asynInt64", EpicsValue::Double(v)) => {
                Some(RequestOp::Int64Write { value: *v as i64 })
            }
            ("asynFloat64", EpicsValue::Double(v)) => Some(RequestOp::Float64Write { value: *v }),
            ("asynFloat64", EpicsValue::Long(v)) => {
                Some(RequestOp::Float64Write { value: *v as f64 })
            }
            ("asynFloat64", EpicsValue::Float(v)) => {
                Some(RequestOp::Float64Write { value: *v as f64 })
            }
            ("asynFloat64", EpicsValue::Short(v)) => {
                Some(RequestOp::Float64Write { value: *v as f64 })
            }
            ("asynFloat64", EpicsValue::Enum(v)) => {
                Some(RequestOp::Float64Write { value: *v as f64 })
            }
            ("asynOctet", EpicsValue::String(s)) => Some(RequestOp::OctetWrite {
                data: s.as_bytes().to_vec(),
            }),
            ("asynOctet", EpicsValue::CharArray(data)) => {
                // Trim trailing nulls (waveform FTVL=CHAR pads to NELM)
                let len = data.iter().position(|&b| b == 0).unwrap_or(data.len());
                Some(RequestOp::OctetWrite {
                    data: data[..len].to_vec(),
                })
            }
            // Coerce numeric types to octet (e.g. longout writing to NDArrayPort string param)
            ("asynOctet", v) => {
                let s = format!("{v}");
                Some(RequestOp::OctetWrite {
                    data: s.as_bytes().to_vec(),
                })
            }
            ("asynUInt32Digital", EpicsValue::Long(v)) => Some(RequestOp::UInt32DigitalWrite {
                value: *v as u32,
                mask: self.mask,
            }),
            ("asynUInt32Digital", EpicsValue::Enum(v)) => Some(RequestOp::UInt32DigitalWrite {
                value: *v as u32,
                mask: self.mask,
            }),
            ("asynEnum", EpicsValue::Long(v)) => Some(RequestOp::EnumWrite { index: *v as usize }),
            ("asynEnum", EpicsValue::Enum(v)) => Some(RequestOp::EnumWrite { index: *v as usize }),
            ("asynInt8Array", EpicsValue::CharArray(data)) => Some(RequestOp::Int8ArrayWrite {
                data: data.iter().map(|&x| x as i8).collect(),
            }),
            ("asynInt16Array", EpicsValue::ShortArray(data)) => {
                Some(RequestOp::Int16ArrayWrite { data: data.clone() })
            }
            ("asynInt32Array", EpicsValue::LongArray(data)) => {
                Some(RequestOp::Int32ArrayWrite { data: data.clone() })
            }
            ("asynInt64Array", EpicsValue::LongArray(data)) => Some(RequestOp::Int64ArrayWrite {
                data: data.iter().map(|&x| x as i64).collect(),
            }),
            ("asynFloat32Array", EpicsValue::FloatArray(data)) => {
                Some(RequestOp::Float32ArrayWrite { data: data.clone() })
            }
            ("asynFloat64Array", EpicsValue::DoubleArray(data)) => {
                Some(RequestOp::Float64ArrayWrite { data: data.clone() })
            }
            _ => None,
        }
    }

    /// Store a freshly-read scalar value into the record and report whether
    /// the record's built-in RVAL→VAL conversion should be **skipped**
    /// (`true`) or **run** (`false`). This mirrors C device support's
    /// `processXxx` return value:
    ///
    /// - `asynFloat64` ai (devAsynFloat64.c:594-604): the device support
    ///   computes the engineering VAL itself — ASLO/AOFF then the SMOO filter
    ///   — and returns RTN_DO_NOT_CONVERT (`2`). Here: set VAL via
    ///   [`Self::apply_float64_ai_conversion`], return `true`. Gated on the
    ///   record exposing `SMOO` (the ai-only field) so an `ao` readback — which
    ///   has ASLO/AOFF but no SMOO and a different (output) conversion — is not
    ///   disturbed.
    /// - `asynInt32` ai (devAsynInt32.c:848-851): the device support sets
    ///   `pr->rval = value` and returns `0`, letting the ai record's `convert()`
    ///   apply ROFF/ASLO/AOFF, the LINR linearization (ESLO/EOFF), and SMOO.
    ///   Here: route the raw int through RVAL, return `false`. Gated on the
    ///   record exposing `ESLO` (the ai linearization field, same discriminator
    ///   as the init-time [`Self::apply_linear_eslo_eoff`]); without it the raw
    ///   counts reached VAL straight and the computed ESLO/EOFF were dead.
    ///   mbbi/bi (no ESLO) keep the direct VAL path — their `set_val` re-derives
    ///   from RVAL+state-table internally.
    /// - everything else: set VAL directly, return `true` (device support
    ///   produced the final value, as before).
    fn store_read_value(&mut self, record: &mut dyn Record, val: EpicsValue) -> bool {
        if self.iface_type == "asynFloat64" {
            if let EpicsValue::Double(raw) = val {
                if record.get_field("SMOO").is_some() {
                    let eng = self.apply_float64_ai_conversion(record, raw);
                    let _ = record.set_val(EpicsValue::Double(eng));
                    return true;
                }
            }
        }
        if self.iface_type == "asynInt32" {
            if let EpicsValue::Long(raw) = val {
                if record.get_field("ESLO").is_some() {
                    let _ = record.put_field("RVAL", EpicsValue::Long(raw));
                    return false;
                }
            }
        }
        let _ = record.set_val(val);
        true
    }

    /// The ASLO/AOFF/SMOO arithmetic of C `devAsynFloat64::processAi`
    /// (devAsynFloat64.c:595-602). `ASLO`/`AOFF`/`SMOO` are read live so a
    /// runtime change takes effect on the next read. SMOO primes on the first
    /// read (C skips smoothing while `pr->udf`); `smoo_primed` is the
    /// adapter-side `!udf` for the float64 path (the adapter is the sole VAL
    /// writer there), and the `!cur.is_finite()` guard mirrors C's
    /// `!finite(pr->val)`.
    fn apply_float64_ai_conversion(&mut self, record: &mut dyn Record, raw: f64) -> f64 {
        let field = |name: &str| match record.get_field(name) {
            Some(EpicsValue::Double(v)) => v,
            _ => 0.0,
        };
        let aslo = field("ASLO");
        let aoff = field("AOFF");
        let smoo = field("SMOO");
        let mut val64 = raw;
        if aslo != 0.0 {
            val64 *= aslo;
        }
        val64 += aoff;
        let cur = match record.val() {
            Some(EpicsValue::Double(v)) => v,
            _ => f64::NAN,
        };
        let out = if smoo == 0.0 || !self.smoo_primed || !cur.is_finite() {
            val64
        } else {
            cur * smoo + val64 * (1.0 - smoo)
        };
        self.smoo_primed = true;
        out
    }

    /// Push a driver's asynEnum table onto the record's state fields at init
    /// — the C `setEnums` call from `initCommon:314`. Discriminates the
    /// record family via [`EnumRecordShape::of_record`] and applies the
    /// [`enum_table_fields`] delta directly to the record. Anything that is
    /// not an enum state record is left untouched. The runtime
    /// re-propagation path ([`Self::property_post_receiver`]) reuses the
    /// same [`enum_table_fields`] producer, posting the delta DBE_PROPERTY
    /// instead of writing the record in-band.
    fn apply_enum_table(&self, record: &mut dyn Record, entries: &[crate::param::EnumEntry]) {
        let Some(shape) = EnumRecordShape::of_record(record) else {
            return;
        };
        for (field, value) in enum_table_fields(shape, entries) {
            let _ = record.put_field(&field, value);
        }
    }
}

impl DeviceSupport for AsynDeviceSupport {
    fn init(&mut self, record: &mut dyn Record) -> CaResult<()> {
        if !self.reason_set {
            match self.handle.drv_user_create_blocking(&self.drv_info) {
                Ok(reason) => {
                    self.reason = reason;
                }
                Err(e) => {
                    // Param not found — this record has no corresponding driver param.
                    eprintln!(
                        "[asyn] init FAILED: port='{}' drv_info='{}' err={e}",
                        self.handle.port_name(),
                        self.drv_info
                    );
                    self.reason_set = false;
                    return Ok(());
                }
            }
            self.reason_set = true;
        }

        // Read NELM from the record to set max_array_elements for array reads.
        if let Some(EpicsValue::Long(nelm)) = record.get_field("NELM") {
            if nelm > 0 {
                self.max_array_elements = nelm as usize;
            }
        }

        // Read SIZV from lsi/lso/printf records to size the asynOctet
        // read buffer. C parity: devAsynOctet.c:1103 passes
        // `plsi->sizv` to initCommon as the per-record buffer size;
        // the read path (line 1117-1124) then writes up to sizv-1
        // bytes and stuffs `\0` at the boundary. For stringin /
        // stringout (no SIZV) we keep the 256-byte default. SIZV is
        // DBF_USHORT (lsiRecord/lsoRecord/printfRecord.dbd.pod), so the
        // field reads back as an unsigned 16-bit value.
        if let Some(EpicsValue::UShort(sizv)) = record.get_field("SIZV") {
            if sizv > 0 {
                self.octet_max_size = sizv as usize;
            }
        }

        // asynUInt32Digital MASK/SHFT propagation. C devAsynUInt32Digital.c
        // (init paths for mbbi/mbbo/mbbiDirect/mbboDirect at lines 881,
        // 925, 1010, 1054) sets:
        //
        //     pr->mask = pPvt->mask;
        //     pr->shft = computeShift(pPvt->mask);
        //
        // so the record's standard RVAL→VAL conversion shifts the
        // masked bits down to bit 0. Without this, a link like
        // `@asynMask(port,0,0xFF00) BITS` reads RVAL with bits 8-15
        // set but the record's shft stays 0 and VAL ends up
        // 65280-shaped instead of 0-255. Apply this regardless of
        // mask value — a fully-set 0xFFFFFFFF mask shifts by 0
        // (no-op) which is the correct contract for "every bit".
        if self.iface_type == "asynUInt32Digital" && self.mask != 0 {
            let shft = compute_mask_shift(self.mask);
            let _ = record.put_field("MASK", EpicsValue::Long(self.mask as i32));
            let _ = record.put_field("SHFT", EpicsValue::Short(shft as i16));
        }

        // ai/ao LINEAR ESLO/EOFF wiring.
        //
        // C devAsynInt32.c::initAi (line 822-828) / initAo / initAiAverage:
        //
        //     if (deviceLow == 0 && deviceHigh == 0) {
        //         pasynInt32SyncIO->getBounds(..., &deviceLow, &deviceHigh);
        //     }
        //     convertAi(pr, 1);   // line 437-454: ESLO/EOFF from EGUF/EGUL+bounds
        //
        // The bounds query is only meaningful for asynInt32/asynInt64
        // (asynFloat64 has no getBounds in C; mbbi/mbbo use the mask
        // path computed above). The record only applies the result
        // when LINR != NO_CONVERSION, but C writes ESLO/EOFF
        // unconditionally and lets record processing decide whether
        // to honour it.
        if (self.iface_type == "asynInt32" || self.iface_type == "asynInt64")
            && record.get_field("ESLO").is_some()
        {
            self.apply_linear_eslo_eoff(record);
        }

        // Driver enum-string table -> record state fields. C asyn int32/uint32
        // device support (devAsynInt32.c::initCommon:297-324,
        // devAsynUInt32Digital.c:547-601) queries the driver's asynEnum
        // interface and `setEnums` the strings/values/severities onto the
        // record (ZRST/ZRVL/ZRSV… for mbbi/mbbo, ZNAM/ONAM… for bi/bo). The
        // record family is identified by the state fields it exposes (ZRST or
        // ZNAM); the EnumRead itself only returns a table when the driver
        // provides one (an Enum param or an overridden read_enum), mirroring
        // C's `findInterface(asynEnumType) != NULL` gate — a driver without
        // an enum table makes EnumRead fail and the record keeps its .db
        // strings.
        if let Some(shape) = EnumRecordShape::of_record(record) {
            let user = AsynUser::new(self.reason)
                .with_addr(self.addr)
                .with_timeout(self.timeout);
            if let Ok(result) = self.handle.submit_blocking(RequestOp::EnumRead, user) {
                if let Some(entries) = result.enum_entries {
                    self.apply_enum_table(record, &entries);
                    // Capture the shape + table so the runtime callback
                    // (property_post_receiver) can re-propagate on change.
                    // Only set when the driver actually provided a table —
                    // C registers callbackEnum only inside the
                    // `findInterface(asynEnumType) && maxEnums>0` block.
                    self.enum_shape = Some(shape);
                    self.enum_choices = Some(entries);
                }
            }
        }

        if self.initial_readback {
            if let Some(op) = self.read_op() {
                let user = AsynUser::new(self.reason)
                    .with_addr(self.addr)
                    .with_timeout(self.timeout);
                if let Ok(result) = self.handle.submit_blocking(op, user) {
                    if let Some(val) = self.result_to_value(&result) {
                        let _ = record.set_val(val);
                    }
                }
            }
        }
        Ok(())
    }

    fn read(&mut self, record: &mut dyn Record) -> CaResult<DeviceReadOutcome> {
        if !self.reason_set {
            return Ok(DeviceReadOutcome::ok());
        }

        // Write-only (asynOctetWrite): waveform is an input record type so
        // process calls read(), not write(). Perform the write here instead.
        if self.write_only {
            if let Some(val) = record.val() {
                if let Some(op) = self.write_op(&val) {
                    let user = AsynUser::new(self.reason)
                        .with_addr(self.addr)
                        .with_timeout(self.timeout);
                    let _ = self.handle.submit_blocking(op, user);
                }
            }
            return Ok(DeviceReadOutcome::ok());
        }

        // For I/O Intr records, pop the oldest entry from the ring
        // buffer. C parity: `devAsynInt32.c::getCallbackValue` —
        // returns the next FIFO entry, logs+resets the overflow
        // counter on consume. An `asyn:READBACK` output record consumes
        // from the same ring: the framework calls `read()` on it only on a
        // driver-callback cycle (`process_record_readback`), so popping
        // the FIFO here is the output-record analogue of
        // `processBo`'s `getCallbackValue` readback branch.
        if self.scan == ScanType::IoIntr || self.asyn_readback {
            // The record reached its read stage, so this driver-callback
            // cycle processed: clear the armed flag (C `processBo` clears
            // `newOutputCallbackValue` when it runs). A survived flag after
            // the cycle means the PACT guard bailed before here, which
            // `reconcile_readback_callback` then repairs.
            self.output_callback_pending = false;
            let (entry, overflows) = {
                let mut fifo = self.interrupt_fifo.lock().unwrap();
                (fifo.pop(), fifo.take_overflows())
            };
            if overflows > 0 {
                tracing::warn!(
                    target: "asyn_rs::adapter",
                    port = %self.handle.port_name(),
                    record = %self.record_name,
                    overflows = overflows,
                    "ring buffer overflows (C asyn ASYN_TRACE_WARNING)"
                );
            }
            let mut skip_convert = true;
            if let Some(ci) = entry {
                // Array interrupts carry the native element type; convert to this
                // record's interface type so a mismatched FTVL gets the same
                // per-type `convert` the polled path applies (C fires all six
                // array interfaces, NDPluginStdArrays.cpp:169-197). Scalar
                // interfaces fall back to the verbatim mapping.
                let val = convert_param_array_to_iface(&self.iface_type, &ci.value)
                    .or_else(|| param_value_to_epics_value(&ci.value));
                if let Some(val) = val {
                    skip_convert = self.store_read_value(record, val);
                }
                self.last_ts = Some(ci.timestamp);
                // C devAsynInt32.c:843-847 — apply the driver alarm carried
                // by the ring-buffer element. Symmetric with the polled
                // read path (last_alarm_* set from RequestResult below).
                self.last_alarm_status = ci.alarm_status;
                self.last_alarm_severity = ci.alarm_severity;
            }
            // Honor store_read_value's skip-convert decision: computed()
            // (skip RVAL→VAL convert, C return 2) for paths that produced
            // the final VAL themselves, ok() (run the record convert, C
            // return 0) for the asynInt32 ai path that routed raw to RVAL.
            return Ok(if skip_convert {
                DeviceReadOutcome::computed()
            } else {
                DeviceReadOutcome::ok()
            });
        }

        let mut skip_convert = true;
        if let Some(op) = self.read_op() {
            let user = AsynUser::new(self.reason)
                .with_addr(self.addr)
                .with_timeout(self.timeout);
            match self.handle.submit_blocking(op, user) {
                Ok(result) => {
                    if let Some(val) = self.result_to_value(&result) {
                        skip_convert = self.store_read_value(record, val);
                    }
                    self.last_alarm_status = result.alarm_status;
                    self.last_alarm_severity = result.alarm_severity;
                    self.last_ts = result.timestamp;
                }
                Err(e) => {
                    // Convert asyn error to EPICS alarm (C parity: asynStatusToEpicsAlarm)
                    let (alarm_status, alarm_severity) = asyn_error_to_alarm(&e);
                    self.last_alarm_status = alarm_status;
                    self.last_alarm_severity = alarm_severity;
                }
            }
        }
        // computed()/ok() per store_read_value (see the I/O Intr branch above).
        Ok(if skip_convert {
            DeviceReadOutcome::computed()
        } else {
            DeviceReadOutcome::ok()
        })
    }

    fn write(&mut self, record: &mut dyn Record) -> CaResult<()> {
        if !self.reason_set {
            return Ok(());
        }
        if let Some(val) = record.val() {
            if let Some(op) = self.write_op(&val) {
                let user = AsynUser::new(self.reason)
                    .with_addr(self.addr)
                    .with_timeout(self.timeout);
                self.handle
                    .submit_blocking(op, user)
                    .map_err(asyn_to_ca_error)?;
            }
        }
        Ok(())
    }

    fn dtyp(&self) -> &str {
        &self.iface_type
    }

    fn last_alarm(&self) -> Option<(u16, u16)> {
        if self.last_alarm_status == 0 && self.last_alarm_severity == 0 {
            None
        } else {
            Some((self.last_alarm_status, self.last_alarm_severity))
        }
    }

    fn last_timestamp(&self) -> Option<SystemTime> {
        self.last_ts
    }

    fn set_record_info(&mut self, name: &str, scan: ScanType) {
        self.record_name = name.to_string();
        self.scan = scan;
    }

    fn apply_record_info(&mut self, info: &std::collections::HashMap<String, String>) {
        // C parity: `asynDbGetInfo(pr, "asyn:READBACK")` +
        // `asynDbGetInfo(pr, "asyn:INITIAL_READBACK")` at
        // devAsynInt32.c:329, devAsynFloat64.c:218,
        // devAsynInt64.c:257, devAsynUInt32Digital.c:286,
        // devAsynOctet.c:337+357, devAsynXXXArray.cpp:172.
        // C semantics: a non-NULL info-string is fed through `atoi`,
        // any non-zero numeric result enables the flag — which means
        // "0", "0x0", "00" disable; "1", "Y" (atoi → 0!), garbage
        // strings (atoi → 0) disable. We use the broader EPICS-style
        // "truthy / falsey" parse here so values like "Y" or "true"
        // also work (they would NOT work under strict atoi parity,
        // but the broader parse is a strict superset for "1" / "0").
        if let Some(raw) = info.get("asyn:READBACK") {
            self.set_asyn_readback(parse_info_bool(raw));
        }
        if let Some(raw) = info.get("asyn:INITIAL_READBACK") {
            self.set_initial_readback(parse_info_bool(raw));
        }
        // C parity: `info("asyn:FIFO", "<n>")` at
        // devAsynInt32.c:361-362 — `atoi(sizeString)` overrides
        // DEFAULT_RING_BUFFER_SIZE (10). C uses raw `atoi`, which
        // returns 0 for unparseable input; we mirror that by
        // ignoring zero / negative values so the default isn't
        // accidentally clobbered by a typo.
        if let Some(raw) = info.get("asyn:FIFO") {
            if let Ok(n) = raw.trim().parse::<i64>() {
                if n > 0 {
                    self.set_fifo_size(n as usize);
                }
            }
        }
    }

    fn write_begin(
        &mut self,
        record: &mut dyn Record,
    ) -> CaResult<Option<Box<dyn WriteCompletion>>> {
        let val = match record.val() {
            Some(v) => v,
            None => return Ok(None),
        };
        let op = match self.write_op(&val) {
            Some(op) => op,
            None => return Ok(None),
        };
        let user = AsynUser::new(self.reason)
            .with_addr(self.addr)
            .with_timeout(self.timeout);

        // For non-blocking ports, use synchronous submit to match C EPICS behavior:
        // the write completes within the same dbProcess call, so CP chain targets
        // see the updated value immediately. This prevents actor channel overflow
        // and stale reads during fast motor moves.
        if !self.handle.can_block() {
            let _ = self
                .handle
                .submit_blocking(op, user)
                .map_err(asyn_to_ca_error)?;
            return Ok(None); // completed synchronously, no async completion needed
        }

        let completion = self.handle.try_submit(op, user).map_err(asyn_to_ca_error)?;
        Ok(Some(Box::new(AsynAsyncWriteCompletion {
            handle: parking_lot::Mutex::new(Some(completion)),
        })))
    }

    fn io_intr_receiver(&mut self) -> Option<tokio::sync::mpsc::Receiver<()>> {
        // Activate the driver-callback path for either:
        //   1. records with `SCAN="I/O Intr"` (legacy behaviour), OR
        //   2. records flagged via `set_asyn_readback(true)` (asyn
        //      upstream PRs #60 / #208 — output records that follow
        //      driver-side changes regardless of SCAN).
        if !self.reason_set {
            return None;
        }
        if self.scan != ScanType::IoIntr && !self.asyn_readback {
            return None;
        }

        // C parity: a UInt32Digital record registers its @asynMask as the
        // interrupt mask (devAsynUInt32Digital.c:293/343); the driver fires
        // the callback only when `pInterrupt->mask & interruptMask`
        // (asynPortDriver.cpp:720) and delivers `pInterrupt->mask & value`
        // (asynPortDriver.cpp:729). Other interfaces carry no changed-bit
        // mask (uint32_changed_mask == 0), so a mask filter would gate every
        // interrupt out — restrict the gate (and the value masking below) to
        // UInt32Digital.
        let is_uint32 = self.iface_type == "asynUInt32Digital";
        let mask = self.mask;
        // asynInt32 @asynMask nbits config, applied to interrupt values
        // the same way the polled read masks them (C interruptCallbackInput,
        // devAsynInt32.c:537-540). `None` for every non-int32 / no-nbits
        // case, so the closure leaves the value untouched.
        let int32_mask = self.int32_mask;
        let filter = InterruptFilter {
            reason: Some(self.reason),
            addr: Some(self.addr),
            uint32_mask: if is_uint32 { Some(mask) } else { None },
        };

        let (sub, mut intr_rx) = self.handle.interrupts().register_interrupt_user(filter);
        self.interrupt_sub = Some(sub);

        // Bridge mailbox-based InterruptReceiver to the mpsc<()> wakeup channel
        // consumed by setup_io_intr(). The mailbox already coalesces intermediate
        // updates, so no data is lost even if the record processes slowly.
        let (tx, rx) = tokio::sync::mpsc::channel(16);
        let fifo = self.interrupt_fifo.clone();
        tokio::spawn(async move {
            while let Some(iv) = intr_rx.recv().await {
                // C parity (asynPortDriver.cpp:729 + devAsynUInt32Digital.c:464):
                // deliver `mask & value` for UInt32Digital so the I/O-Intr value
                // matches the polled read (RequestOp::UInt32DigitalRead { mask }).
                // interruptCallbackInput stores the already-masked value in the
                // ring buffer; mirror that here. For asynInt32 apply the
                // @asynMask nbits mask + sign-extend (devAsynInt32.c:537-540),
                // matching the polled `result_to_value` path. Other values
                // pass through.
                let value = match iv.value {
                    crate::param::ParamValue::UInt32Digital(v) if is_uint32 => {
                        crate::param::ParamValue::UInt32Digital(v & mask)
                    }
                    crate::param::ParamValue::Int32(v) => {
                        crate::param::ParamValue::Int32(int32_mask.map_or(v, |m| m.apply(v)))
                    }
                    other => other,
                };
                let entry = CachedInterrupt {
                    value,
                    timestamp: iv.timestamp,
                    // Carry the driver alarm so the IoIntr read path can
                    // recGblSetSevr it (C devAsynInt32.c:561-563/843-847);
                    // dropping it left I/O-Intr records permanently
                    // NO_ALARM even when the driver flagged an alarm.
                    alarm_status: iv.alarm_status,
                    alarm_severity: iv.alarm_severity,
                };
                // C parity (devAsynInt32.c:564-576):
                //   - On overflow (ring full), drop oldest +
                //     overflows++ and DO NOT call scanIoRequest.
                //     The already-pending process will pick up the
                //     newer tail; a duplicate request would just
                //     flood dbScan.
                //   - On normal add, request the record to process.
                let was_fresh_add = {
                    let mut g = fifo.lock().unwrap();
                    g.push_with_overflow(entry)
                };
                if was_fresh_add && tx.send(()).await.is_err() {
                    break;
                }
            }
        });
        Some(rx)
    }

    /// An `asyn_readback`-flagged record follows driver-side changes via the
    /// interrupt callback regardless of its `SCAN` (upstream PRs #60 / #208).
    /// Decouple its poll-feedback wiring from the `SCAN` menu so the callback
    /// processes it even when `SCAN != "I/O Intr"`. Plain `SCAN="I/O Intr"`
    /// records (`asyn_readback == false`) keep the SCAN-gated behaviour.
    fn io_intr_scan_independent(&self) -> bool {
        self.asyn_readback
    }

    fn arm_readback_callback(&mut self) {
        // C `devAsynInt32.c::outputCallbackCallback` sets
        // `newOutputCallbackValue = 1` immediately before `dbProcess`. Only
        // the output-readback path (asyn:READBACK, PRs #60 / #208) carries
        // the C `newOutputCallbackValue` contract; input I/O-Intr records use
        // the scan/RPRO reprocess model (`processCommon`), never the discard
        // fallback, so they are left unarmed and `reconcile` is a no-op there.
        if self.asyn_readback {
            self.output_callback_pending = true;
        }
    }

    fn reconcile_readback_callback(&mut self) {
        // C `outputCallbackCallback` after `dbProcess`:
        //   if (pPvt->newOutputCallbackValue != 0) { getCallbackValue(pPvt);
        //       pPvt->newOutputCallbackValue = 0; }
        // The flag still being set means the record never reached its read
        // stage (the PACT entry guard bailed because a put / FLNK cycle still
        // owned the record). Discard the oldest ring entry so the callback
        // ring stays balanced — every armed callback consumes exactly one
        // entry. `read()` already cleared the flag on the cycles that did
        // process, so this only fires on a genuine bail.
        if self.output_callback_pending {
            if let Ok(mut fifo) = self.interrupt_fifo.lock() {
                let _ = fifo.pop();
            }
            self.output_callback_pending = false;
        }
    }

    fn property_post_receiver(
        &mut self,
    ) -> Option<tokio::sync::mpsc::Receiver<Vec<(String, EpicsValue)>>> {
        // Runtime asynEnum table re-propagation. C devAsynInt32.c registers a
        // per-record enum callback (callbackEnum, :711-762) that re-applies
        // `setEnums` + `db_post_events(DBE_PROPERTY)` whenever the driver
        // changes the table via `doCallbacksEnum`. Gate exactly as C's
        // `findInterface(asynEnumType) && maxEnums>0`: only a record that
        // exposed an enum state family AND received a driver table at init
        // (so `enum_shape` is set) arms the callback. Independent of SCAN —
        // a property post is not a value scan, so it never processes the
        // record.
        let shape = self.enum_shape?;
        if !self.reason_set {
            return None;
        }

        // Subscribe to the enum param's interrupts. In asyn-rs the enum value
        // (index) and the enum table (choices) live on one `ParamValue::Enum`
        // param, so both a value change and a `set_enum_choices` change arrive
        // here; C separates them across the asynInt32 and asynEnum interfaces.
        // The bridge below recovers that separation by posting only when the
        // *choices* differ from the last-applied table (seeded with the init
        // table) — so an int32 value change fires no DBE_PROPERTY, matching C.
        let filter = InterruptFilter {
            reason: Some(self.reason),
            addr: Some(self.addr),
            uint32_mask: None,
        };
        let (sub, mut intr_rx) = self.handle.interrupts().register_interrupt_user(filter);
        self.enum_interrupt_sub = Some(sub);

        let (tx, rx) = tokio::sync::mpsc::channel(16);
        let mut last_choices = self.enum_choices.clone();
        tokio::spawn(async move {
            while let Some(iv) = intr_rx.recv().await {
                let crate::param::ParamValue::Enum { choices, .. } = &iv.value else {
                    continue;
                };
                let changed = match &last_choices {
                    Some(prev) => prev.as_ref() != choices.as_ref(),
                    None => true,
                };
                if !changed {
                    continue;
                }
                last_choices = Some(choices.clone());
                let fields = enum_table_fields(shape, choices);
                if tx.send(fields).await.is_err() {
                    break;
                }
            }
        });
        Some(rx)
    }
}

// ===== Universal asyn device support =====

/// Normalize array DTYP names by stripping "In"/"Out" direction suffixes.
///
/// C EPICS uses distinct DTYPs for input vs output array records
/// (e.g. `asynFloat64ArrayIn`, `asynFloat64ArrayOut`), but the underlying
/// asyn interface name is just `asynFloat64Array`. This function strips
/// the direction suffix so the adapter's `read_op()`/`write_op()` matchers
/// can find the correct interface.
/// Normalize asyn DTYP names to their base interface type.
///
/// C EPICS uses direction-specific DTYPs for some interfaces:
/// - `asynFloat64ArrayIn` / `asynFloat64ArrayOut` → `asynFloat64Array`
/// - `asynOctetRead` / `asynOctetWrite` → `asynOctet`
///
/// The underlying asyn interface is direction-agnostic.
fn normalize_asyn_dtyp(dtyp: &str) -> String {
    // Array direction suffixes: asynXxxArrayIn/Out → asynXxxArray
    if let Some(base) = dtyp.strip_suffix("In").or_else(|| dtyp.strip_suffix("Out")) {
        if base.ends_with("Array") {
            return base.to_string();
        }
    }
    // Octet direction suffixes: asynOctetRead/Write → asynOctet
    if dtyp == "asynOctetRead" || dtyp == "asynOctetWrite" {
        return "asynOctet".to_string();
    }
    dtyp.to_string()
}

/// Create a universal asyn device support factory.
///
/// Handles all standard asyn DTYPs (`asynInt32`, `asynFloat64`, `asynOctet`,
/// array types, etc.) by parsing `@asyn(PORT,ADDR,TIMEOUT)DRVINFO` links
/// and dispatching to the appropriate port driver.
///
/// During `init()`, `drv_user_create(drvInfo)` is called on the port, which
/// resolves the drvInfo string to a param index via `find_param()`. This
/// matches the C EPICS asyn device support behavior exactly.
///
/// Handles all records with `@asyn(PORT,...)` links. Register via
/// `register_asyn_device_support(app)` or `AdIoc` (which registers it
/// automatically).
///
/// ```ignore
/// app = asyn_rs::adapter::register_asyn_device_support(app);
/// ```
pub fn universal_asyn_factory(
    ctx: &epics_base_rs::server::ioc_app::DeviceSupportContext,
) -> Option<Box<dyn DeviceSupport>> {
    // Try @asyn() link in INP or OUT
    let (link_str, is_output) = if ctx.out.contains("@asyn") || ctx.out.contains("@asynMask") {
        (ctx.out, true)
    } else if ctx.inp.contains("@asyn") || ctx.inp.contains("@asynMask") {
        // asynOctetWrite uses INP field for output (C EPICS convention for waveform records)
        let is_write_dtyp = ctx.dtyp == "asynOctetWrite";
        (ctx.inp, is_write_dtyp)
    } else {
        return None;
    };

    // Parse the link
    let link = if link_str.contains("@asynMask") {
        let ml = parse_asyn_mask_link(link_str).ok()?;
        AsynLink {
            port_name: ml.port_name,
            addr: ml.addr,
            timeout: ml.timeout,
            drv_info: ml.drv_info,
        }
    } else {
        parse_asyn_link(link_str).ok()?
    };

    // Look up port in global registry
    let entry = crate::asyn_record::get_port(&link.port_name)?;

    // Normalize DTYP: strip "In"/"Out" suffixes from array types.
    // C EPICS uses DTYPs like "asynFloat64ArrayIn" / "asynFloat64ArrayOut"
    // but the underlying asyn interface is "asynFloat64Array".
    let dtyp = normalize_asyn_dtyp(ctx.dtyp);

    let mut adapter = AsynDeviceSupport::from_handle(entry.handle, link, &dtyp);

    if is_output {
        if ctx.dtyp == "asynOctetWrite" {
            // asynOctetWrite is write-only — no reads allowed.
            // Reading would replace waveform CharArray with String, breaking element_count.
            adapter.write_only = true;
        } else {
            // Output records: read back current driver value on init.
            // Mirrors C `initAo` / `initBo` / `initLongout` / `initMbbo`
            // which call `pasynManager->queueRequest(... ASYN_INIT ...)`
            // to pull the driver's current value into the record before
            // record processing starts.
            adapter = adapter.with_initial_readback();
        }
    }
    // Input records: do NOT auto-readback. C `initAi`/`initLongin`/
    // `initMbbi`/`initBi` (devAsynInt32.c:812+, similar for Float64 /
    // Int64 / UInt32Digital / Octet) only sets up the asynUser and
    // gets bounds — the first value comes from `processAi` (driven by
    // the scan task or an I/O Intr callback), not from a synchronous
    // read at init. The previous "matches C init_common() behavior"
    // comment was wrong: C devAsynOctet init_common() also only
    // installs callbacks; the initial value flows through the scan
    // path. Auto-readback on inputs caused two problems: a spurious
    // blocking read against an unconnected driver, and overwriting a
    // template's deliberate default value with a stale/zero readback.

    // UInt32Digital: apply mask
    if link_str.contains("@asynMask") {
        if let Ok(ml) = parse_asyn_mask_link(link_str) {
            adapter = adapter.with_mask(ml.mask);
        }
    }

    Some(Box::new(adapter))
}

/// Register universal asyn device support on an IocApplication.
///
/// This is the Rust equivalent of C EPICS's standard asyn device support
/// registration. Call this BEFORE registering plugin or driver-specific
/// factories so they take precedence (dynamic factories chain last-registered-first).
pub fn register_asyn_device_support(
    app: epics_base_rs::server::ioc_app::IocApplication,
) -> epics_base_rs::server::ioc_app::IocApplication {
    app.register_dynamic_device_support(universal_asyn_factory)
}

/// IocBuilder companion to [`register_asyn_device_support`] —
/// installs the universal asyn factory on the pure-Rust build path
/// (added `register_dynamic_device_support` to IocBuilder).
/// Without this helper, callers using `IocBuilder` instead of
/// `IocApplication` would have to wire `universal_asyn_factory`
/// manually; that asymmetry is exactly what `register_asyn_device_support`
/// hides for the IocApplication path.
pub fn register_asyn_device_support_for_builder(
    builder: epics_base_rs::server::ioc_builder::IocBuilder,
) -> epics_base_rs::server::ioc_builder::IocBuilder {
    builder.register_dynamic_device_support(universal_asyn_factory)
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Every asyn read error maps to the C `asynStatusToEpicsAlarm` pair
    /// under the device-support READ-path defaults (READ_ALARM / INVALID).
    /// Condition codes per epics-base alarm.h: READ=1, COMM=9, TIMEOUT=10,
    /// HW_LIMIT=11, DISABLE=18; INVALID severity = 3.
    #[test]
    fn asyn_error_to_alarm_matches_c_status_to_epics_alarm() {
        use crate::error::AsynStatus;
        let mk = |s: AsynStatus| {
            asyn_error_to_alarm(&AsynError::Status {
                status: s,
                message: String::new(),
            })
        };
        assert_eq!(mk(AsynStatus::Success), (0, 0));
        assert_eq!(
            mk(AsynStatus::Timeout),
            (10, 3),
            "Timeout -> TIMEOUT_ALARM(10), INVALID(3)"
        );
        assert_eq!(
            mk(AsynStatus::Overflow),
            (11, 3),
            "Overflow -> HW_LIMIT_ALARM(11), INVALID(3)"
        );
        assert_eq!(
            mk(AsynStatus::Disconnected),
            (9, 3),
            "Disconnected -> COMM_ALARM(9), INVALID(3)"
        );
        assert_eq!(
            mk(AsynStatus::Disabled),
            (18, 3),
            "Disabled -> DISABLE_ALARM(18), INVALID(3)"
        );
        assert_eq!(
            mk(AsynStatus::Error),
            (1, 3),
            "Error -> READ_ALARM(1), INVALID(3)"
        );
        // Non-status asyn errors take C's asynError/default branch.
        assert_eq!(
            asyn_error_to_alarm(&AsynError::PortNotFound("p".into())),
            (1, 3)
        );
    }

    /// Regression: the IocBuilder companion helper exists
    /// and accepts a pure-Rust builder. Pre-fix, `register_asyn_device_support`
    /// only accepted IocApplication, so an IocBuilder caller had to
    /// expose `universal_asyn_factory` themselves.
    #[tokio::test]
    async fn register_asyn_device_support_for_builder_compiles_and_attaches() {
        use epics_base_rs::server::device_support::{DeviceReadOutcome, DeviceSupport};
        use epics_base_rs::server::ioc_builder::IocBuilder;
        use epics_base_rs::server::record::ScanType;
        use epics_base_rs::types::EpicsValue;
        let _ = (
            ScanType::Passive,
            EpicsValue::Double(0.0),
            std::any::type_name::<dyn DeviceSupport>(),
            std::any::type_name::<DeviceReadOutcome>(),
        );

        // The helper consumes and returns the builder — chain on .build().
        let _builder = register_asyn_device_support_for_builder(IocBuilder::new());
    }

    #[test]
    fn test_parse_full() {
        let link = parse_asyn_link("@asyn(myPort, 0, 1.0) TEMPERATURE").unwrap();
        assert_eq!(link.port_name, "myPort");
        assert_eq!(link.addr, 0);
        assert_eq!(link.timeout, Duration::from_secs_f64(1.0));
        assert_eq!(link.drv_info, "TEMPERATURE");
    }

    #[test]
    fn test_parse_port_only() {
        let link = parse_asyn_link("@asyn(port1) PARAM").unwrap();
        assert_eq!(link.port_name, "port1");
        assert_eq!(link.addr, 0);
        assert_eq!(link.timeout, Duration::from_secs(1));
        assert_eq!(link.drv_info, "PARAM");
    }

    #[test]
    fn test_parse_port_and_addr() {
        let link = parse_asyn_link("@asyn(port2, 3) VALUE").unwrap();
        assert_eq!(link.port_name, "port2");
        assert_eq!(link.addr, 3);
        assert_eq!(link.drv_info, "VALUE");
    }

    #[test]
    fn test_parse_fractional_timeout() {
        let link = parse_asyn_link("@asyn(dev, 1, 0.5) CMD").unwrap();
        assert_eq!(link.timeout, Duration::from_secs_f64(0.5));
    }

    #[test]
    fn test_parse_no_drv_info() {
        let link = parse_asyn_link("@asyn(port1)").unwrap();
        assert_eq!(link.drv_info, "");
    }

    #[test]
    fn test_parse_invalid_prefix() {
        assert!(parse_asyn_link("@wrong(port)").is_err());
    }

    #[test]
    fn test_parse_missing_paren() {
        assert!(parse_asyn_link("@asyn(port").is_err());
    }

    #[test]
    fn test_parse_invalid_addr() {
        assert!(parse_asyn_link("@asyn(port, abc) X").is_err());
    }

    #[test]
    fn test_parse_invalid_timeout() {
        assert!(parse_asyn_link("@asyn(port, 0, xyz) X").is_err());
    }

    #[test]
    fn test_parse_space_separated() {
        // NDCircularBuff.template uses space-separated format: @asyn(PORT 0)DRVINFO
        let link = parse_asyn_link("@asyn(CB1 0)CIRC_BUFF_CONTROL").unwrap();
        assert_eq!(link.port_name, "CB1");
        assert_eq!(link.addr, 0);
        assert_eq!(link.drv_info, "CIRC_BUFF_CONTROL");
    }

    #[test]
    fn test_parse_space_separated_with_timeout() {
        let link = parse_asyn_link("@asyn(PORT1 2 1.5) PARAM").unwrap();
        assert_eq!(link.port_name, "PORT1");
        assert_eq!(link.addr, 2);
        assert_eq!(link.timeout, Duration::from_secs_f64(1.5));
        assert_eq!(link.drv_info, "PARAM");
    }

    #[test]
    fn test_parse_addr_hex_base0() {
        // C asynEpicsUtils.c:114 strtol(.., 0): `0x10` is hex 16.
        let link = parse_asyn_link("@asyn(port, 0x10) PARAM").unwrap();
        assert_eq!(link.addr, 16);
        let upper = parse_asyn_link("@asyn(port, 0X1F) PARAM").unwrap();
        assert_eq!(upper.addr, 31);
    }

    #[test]
    fn test_parse_addr_octal_base0() {
        // strtol(.., 0): a leading `0` selects octal, so `010` is 8 — NOT
        // decimal 10, which the old decimal-only parser silently bound.
        let link = parse_asyn_link("@asyn(port, 010) PARAM").unwrap();
        assert_eq!(link.addr, 8);
    }

    #[test]
    fn test_parse_addr_decimal_and_zero_unchanged() {
        // Plain decimal and a bare "0" still parse as base 10.
        assert_eq!(parse_asyn_link("@asyn(p, 10) X").unwrap().addr, 10);
        assert_eq!(parse_asyn_link("@asyn(p, 0) X").unwrap().addr, 0);
        assert_eq!(parse_asyn_link("@asyn(p, -5) X").unwrap().addr, -5);
    }

    // --- asynMask link tests ---

    #[test]
    fn test_parse_mask_link_full() {
        let link = parse_asyn_mask_link("@asynMask(port1, 0, 0xFF, 2.0) BITS").unwrap();
        assert_eq!(link.port_name, "port1");
        assert_eq!(link.addr, 0);
        assert_eq!(link.mask, 0xFF);
        assert_eq!(link.timeout, Duration::from_secs_f64(2.0));
        assert_eq!(link.drv_info, "BITS");
    }

    #[test]
    fn test_parse_mask_link_no_timeout() {
        let link = parse_asyn_mask_link("@asynMask(port1, 0, 255) BITS").unwrap();
        assert_eq!(link.mask, 255);
        assert_eq!(link.timeout, Duration::from_secs(1));
    }

    #[test]
    fn test_parse_mask_link_hex_upper() {
        let link = parse_asyn_mask_link("@asynMask(p, 0, 0XFF00) X").unwrap();
        assert_eq!(link.mask, 0xFF00);
    }

    #[test]
    fn test_parse_mask_link_too_few_args() {
        assert!(parse_asyn_mask_link("@asynMask(port1, 0) BITS").is_err());
    }

    #[test]
    fn test_parse_mask_link_invalid_prefix() {
        assert!(parse_asyn_mask_link("@asyn(port1, 0, 0xFF) BITS").is_err());
    }

    #[test]
    fn test_parse_mask_link_negative_nbits_binds() {
        // asynInt32 @asynMask 3rd arg is a signed bit count; a negative
        // count (bipolar) must bind, not fail the u32 parse. Stored as the
        // 32-bit pattern: -8 => 0xFFFFFFF8, recoverable via `as i32`.
        let link = parse_asyn_mask_link("@asynMask(p, 0, -8) X").unwrap();
        assert_eq!(link.mask as i32, -8);
    }

    #[test]
    fn test_parse_mask_link_octal_base0() {
        // C asynEpicsUtils.c:193 strtoul(.., 0): mask `010` is octal 8.
        let link = parse_asyn_mask_link("@asynMask(p, 0, 010) X").unwrap();
        assert_eq!(link.mask, 8);
    }

    #[test]
    fn test_parse_mask_link_addr_hex_base0() {
        // The mask-link addr is also strtol(.., 0) (asynEpicsUtils.c:186).
        let link = parse_asyn_mask_link("@asynMask(p, 0x2, 0xFF) X").unwrap();
        assert_eq!(link.addr, 2);
        assert_eq!(link.mask, 0xFF);
    }

    #[test]
    fn strtol_base0_covers_hex_octal_decimal_sign() {
        assert_eq!(strtol_base0_i32("0x10"), Some(16));
        assert_eq!(strtol_base0_i32("010"), Some(8));
        assert_eq!(strtol_base0_i32("10"), Some(10));
        assert_eq!(strtol_base0_i32("0"), Some(0));
        assert_eq!(strtol_base0_i32("-0x10"), Some(-16));
        assert_eq!(strtol_base0_i32("+7"), Some(7));
        // Invalid digit for the detected base / non-numeric → reject.
        assert_eq!(strtol_base0_i32("08"), None);
        assert_eq!(strtol_base0_i32("0o20"), None);
        assert_eq!(strtol_base0_i32("abc"), None);
        assert_eq!(strtol_base0_i32("0x"), None);
    }

    #[test]
    fn strtoul_base0_negation_wraps_mod_2_32() {
        // C strtoul negates-then-casts: -8 => 0xFFFFFFF8.
        assert_eq!(strtoul_base0_u32("-8"), Some(0xFFFF_FFF8));
        assert_eq!(strtoul_base0_u32("0xFF00"), Some(0xFF00));
        assert_eq!(strtoul_base0_u32("010"), Some(8));
        assert_eq!(strtoul_base0_u32("255"), Some(255));
    }

    #[test]
    fn int32_mask_unipolar_from_nbits() {
        // C devAsynInt32.c:239-246 unipolar (positive nbits): mask = low
        // nbits, deviceLow=0, deviceHigh=mask, no sign extension.
        let m = Int32Mask::from_nbits(8).unwrap();
        assert!(!m.bipolar);
        assert_eq!(m.mask, 0xFF);
        assert_eq!(m.sign_bit, 0x80);
        assert_eq!(m.device_low, 0);
        assert_eq!(m.device_high, 255);
        // Masks the low byte; the high "sign" bit is NOT extended.
        assert_eq!(m.apply(0xFF), 255);
        assert_eq!(m.apply(0x80), 128);
        assert_eq!(m.apply(0x1_2345), 0x45);
    }

    #[test]
    fn int32_mask_bipolar_from_nbits() {
        // C devAsynInt32.c:235-243 bipolar (negative nbits): sign-extend on
        // read; deviceLow=~(mask/2)+1, deviceHigh=mask/2.
        let m = Int32Mask::from_nbits(-8).unwrap();
        assert!(m.bipolar);
        assert_eq!(m.mask, 0xFF);
        assert_eq!(m.sign_bit, 0x80);
        assert_eq!(m.device_low, -127);
        assert_eq!(m.device_high, 127);
        // Sign bit set => extend to a negative i32.
        assert_eq!(m.apply(0xFF), -1);
        assert_eq!(m.apply(0x80), -128);
        // Sign bit clear => positive, masked to the low byte.
        assert_eq!(m.apply(0x7F), 127);
        assert_eq!(m.apply(0x1_2345), 0x45);
    }

    #[test]
    fn int32_mask_zero_nbits_is_none() {
        // C leaves mask=0 when no bit count is given => masking is a no-op.
        assert!(Int32Mask::from_nbits(0).is_none());
    }

    #[test]
    fn int32_mask_only_for_asyn_int32_interface() {
        // with_mask derives the nbits config only for the asynInt32
        // interface; a UInt32Digital adapter keeps the raw mask, no nbits.
        // No submit happens here, so the channels need no actor.
        let mk_handle = |name: &str| {
            let interrupts = Arc::new(InterruptManager::new(256));
            let (tx, _rx) = tokio::sync::mpsc::channel(256);
            (PortHandle::new(tx, name.into(), interrupts), _rx)
        };
        let link = |port: &str| AsynLink {
            port_name: port.into(),
            addr: 0,
            timeout: Duration::from_secs(1),
            drv_info: String::new(),
        };

        let (h1, _rx1) = mk_handle("p1");
        let int32 =
            AsynDeviceSupport::from_handle(h1, link("p1"), "asynInt32").with_mask((-8i32) as u32);
        assert_eq!(int32.int32_mask, Int32Mask::from_nbits(-8));

        let (h2, _rx2) = mk_handle("p2");
        let uint =
            AsynDeviceSupport::from_handle(h2, link("p2"), "asynUInt32Digital").with_mask(0xFF00);
        assert!(uint.int32_mask.is_none());
        assert_eq!(uint.mask, 0xFF00);
    }

    #[test]
    fn result_to_value_masks_and_sign_extends_int32() {
        // The polled read path (result_to_value) must apply the nbits mask +
        // sign-extend (C processCallbackInput, devAsynInt32.c:485-488).
        let interrupts = Arc::new(InterruptManager::new(256));
        let (tx, _rx) = tokio::sync::mpsc::channel(256);
        let handle = PortHandle::new(tx, "p".into(), interrupts);
        let link = AsynLink {
            port_name: "p".into(),
            addr: 0,
            timeout: Duration::from_secs(1),
            drv_info: String::new(),
        };
        // bipolar 8-bit (@asynMask ... -8): raw 0xFF reads back as -1.
        let ads =
            AsynDeviceSupport::from_handle(handle, link, "asynInt32").with_mask((-8i32) as u32);
        let result = RequestResult::int32_read(0xFF);
        assert_eq!(ads.result_to_value(&result), Some(EpicsValue::Long(-1)));
    }

    #[test]
    fn array_interrupt_converts_to_record_interface_type() {
        use crate::param::ParamValue;
        // An F64-native array delivered on the I/O Intr path must be converted to
        // each consuming interface's element type (C fires all six array
        // interfaces type-converted, NDPluginStdArrays.cpp:169-197).
        let f64arr = ParamValue::Float64Array(std::sync::Arc::from([1.7f64, 2.9, -3.1].as_slice()));

        assert_eq!(
            convert_param_array_to_iface("asynInt16Array", &f64arr),
            Some(EpicsValue::ShortArray(vec![1, 2, -3])),
        );
        assert_eq!(
            convert_param_array_to_iface("asynFloat64Array", &f64arr),
            Some(EpicsValue::DoubleArray(vec![1.7, 2.9, -3.1])),
        );

        // Integer narrowing is a truncating C cast, matching the polled ccast
        // path (40000 as i16 wraps to -25536), not a saturating convert.
        let i32arr = ParamValue::Int32Array(std::sync::Arc::from([40000i32].as_slice()));
        assert_eq!(
            convert_param_array_to_iface("asynInt16Array", &i32arr),
            Some(EpicsValue::ShortArray(vec![-25536])),
        );

        // asynInt8Array goes through i8 then reinterprets to u8 (300.0 -> i8
        // saturates to 127 -> u8 127), matching result_to_value's CharArray map.
        let over = ParamValue::Float64Array(std::sync::Arc::from([300.0f64].as_slice()));
        assert_eq!(
            convert_param_array_to_iface("asynInt8Array", &over),
            Some(EpicsValue::CharArray(vec![127])),
        );

        // Scalar interfaces are not array interfaces: returns None so the caller
        // falls back to the verbatim mapping.
        assert_eq!(convert_param_array_to_iface("asynFloat64", &f64arr), None);
    }

    use crate::error::AsynResult;
    use crate::interrupt::{InterruptManager, InterruptValue};
    use crate::param::ParamType;
    use crate::port::{PortDriver, PortDriverBase, PortFlags};
    use crate::port_actor::PortActor;
    use std::sync::Arc;

    struct TestPort {
        base: PortDriverBase,
    }
    impl TestPort {
        fn new() -> Self {
            let mut base = PortDriverBase::new("test", 1, PortFlags::default());
            base.create_param("VAL", ParamType::Int32).unwrap();
            Self { base }
        }
    }
    impl PortDriver for TestPort {
        fn base(&self) -> &PortDriverBase {
            &self.base
        }
        fn base_mut(&mut self) -> &mut PortDriverBase {
            &mut self.base
        }
    }

    /// Port that reports configurable getBounds — used for the ai
    /// LINEAR ESLO/EOFF wiring tests.
    struct BoundedPort {
        base: PortDriverBase,
        low32: i32,
        high32: i32,
    }
    impl BoundedPort {
        fn new(low32: i32, high32: i32) -> Self {
            let mut base = PortDriverBase::new("test_bounds", 1, PortFlags::default());
            base.create_param("VAL", ParamType::Int32).unwrap();
            Self {
                base,
                low32,
                high32,
            }
        }
    }
    impl PortDriver for BoundedPort {
        fn base(&self) -> &PortDriverBase {
            &self.base
        }
        fn base_mut(&mut self) -> &mut PortDriverBase {
            &mut self.base
        }
        fn get_bounds_int32(&self, _user: &AsynUser) -> AsynResult<(i32, i32)> {
            Ok((self.low32, self.high32))
        }
    }

    fn make_bounded_adapter(low: i32, high: i32, iface: &str) -> AsynDeviceSupport {
        let driver = BoundedPort::new(low, high);
        let interrupts = Arc::new(InterruptManager::new(256));
        let (tx, rx) = tokio::sync::mpsc::channel(256);
        let actor = PortActor::new(Box::new(driver), rx);
        std::thread::Builder::new()
            .name("test-bounds-actor".into())
            .spawn(move || actor.run())
            .unwrap();
        let handle = PortHandle::new(tx, "test_bounds".into(), interrupts);
        let link = AsynLink {
            port_name: "test_bounds".into(),
            addr: 0,
            timeout: Duration::from_secs(1),
            drv_info: "VAL".into(),
        };
        let mut ads = AsynDeviceSupport::from_handle(handle, link, iface);
        ads.set_record_info("TEST:AI", ScanType::Passive);
        ads
    }

    fn make_adapter(scan: ScanType) -> AsynDeviceSupport {
        let driver = TestPort::new();
        let interrupts = Arc::new(InterruptManager::new(256));
        let (tx, rx) = tokio::sync::mpsc::channel(256);
        let actor = PortActor::new(Box::new(driver), rx);
        std::thread::Builder::new()
            .name("test-adapter-actor".into())
            .spawn(move || actor.run())
            .unwrap();
        let handle = PortHandle::new(tx, "test".into(), interrupts);

        let link = AsynLink {
            port_name: "test".into(),
            addr: 0,
            timeout: Duration::from_secs(1),
            drv_info: "VAL".into(),
        };
        let mut ads = AsynDeviceSupport::from_handle(handle, link, "asynInt32");
        ads.set_record_info("TEST:REC", scan);
        ads
    }

    #[test]
    fn test_io_intr_receiver_none_when_passive() {
        let mut ads = make_adapter(ScanType::Passive);
        assert!(ads.io_intr_receiver().is_none());
    }

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn test_io_intr_receiver_some_when_io_intr() {
        let mut ads = make_adapter(ScanType::IoIntr);
        // init() resolves drv_user_create → sets reason_set = true
        use epics_base_rs::server::records::longin::LonginRecord;
        let mut rec = LonginRecord::new(0);
        ads.init(&mut rec).unwrap();
        let rx = ads.io_intr_receiver();
        assert!(rx.is_some());
    }

    /// Build a `ScanType::IoIntr` adapter on the `asynUInt32Digital`
    /// interface with the given record `@asynMask`.
    fn make_uint32_io_intr_adapter(mask: u32) -> AsynDeviceSupport {
        let driver = TestPort::new();
        let interrupts = Arc::new(InterruptManager::new(256));
        let (tx, rx) = tokio::sync::mpsc::channel(256);
        let actor = PortActor::new(Box::new(driver), rx);
        std::thread::Builder::new()
            .name("test-u32-actor".into())
            .spawn(move || actor.run())
            .unwrap();
        let handle = PortHandle::new(tx, "test".into(), interrupts);
        let link = AsynLink {
            port_name: "test".into(),
            addr: 0,
            timeout: Duration::from_secs(1),
            drv_info: "VAL".into(),
        };
        let mut ads =
            AsynDeviceSupport::from_handle(handle, link, "asynUInt32Digital").with_mask(mask);
        ads.set_record_info("TEST:U32", ScanType::IoIntr);
        ads
    }

    /// Poll the interrupt FIFO until an entry arrives (bridge runs on a
    /// spawned task), returning its value. Panics if none arrives.
    async fn await_fifo_value(
        fifo: &Arc<std::sync::Mutex<InterruptFifo>>,
    ) -> crate::param::ParamValue {
        for _ in 0..200 {
            if let Some(entry) = fifo.lock().unwrap().pop() {
                return entry.value;
            }
            tokio::time::sleep(Duration::from_millis(5)).await;
        }
        panic!("interrupt value never reached FIFO");
    }

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn io_intr_uint32_masks_delivered_value_to_record_mask() {
        // C uint32Callback delivers `pInterrupt->mask & value`
        // (asynPortDriver.cpp:729); the @asynMask=0x0F record must see
        // a 0xFF param value masked to 0x0F, matching the polled read.
        use epics_base_rs::server::records::longin::LonginRecord;
        let mut ads = make_uint32_io_intr_adapter(0x0F);
        let mut rec = LonginRecord::new(0);
        ads.init(&mut rec).unwrap();
        let reason = ads.reason;
        let interrupts = ads.handle.interrupts().clone();
        let fifo = ads.interrupt_fifo.clone();
        let _rx = ads.io_intr_receiver().expect("io intr receiver for IoIntr");

        interrupts.notify(InterruptValue {
            reason,
            addr: 0,
            value: crate::param::ParamValue::UInt32Digital(0xFF),
            timestamp: SystemTime::now(),
            uint32_changed_mask: 0x01,
            ..Default::default()
        });

        match await_fifo_value(&fifo).await {
            crate::param::ParamValue::UInt32Digital(bits) => assert_eq!(
                bits, 0x0F,
                "I/O-Intr value must be masked to the record @asynMask"
            ),
            other => panic!("expected UInt32Digital, got {other:?}"),
        }
    }

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn io_intr_uint32_gates_out_non_overlapping_change() {
        // C uint32Callback fires only when `pInterrupt->mask &
        // interruptMask` (asynPortDriver.cpp:720): a change confined to
        // bits outside the @asynMask=0x0F record (changed=0xF0) must be
        // gated out, while an overlapping change still arrives.
        use epics_base_rs::server::records::longin::LonginRecord;
        let mut ads = make_uint32_io_intr_adapter(0x0F);
        let mut rec = LonginRecord::new(0);
        ads.init(&mut rec).unwrap();
        let reason = ads.reason;
        let interrupts = ads.handle.interrupts().clone();
        let fifo = ads.interrupt_fifo.clone();
        let _rx = ads.io_intr_receiver().expect("io intr receiver for IoIntr");

        interrupts.notify(InterruptValue {
            reason,
            addr: 0,
            value: crate::param::ParamValue::UInt32Digital(0xFF),
            timestamp: SystemTime::now(),
            uint32_changed_mask: 0xF0,
            ..Default::default()
        });
        // matches() rejects synchronously at notify(); a bounded wait
        // then proves the value was dropped, not merely delayed.
        tokio::time::sleep(Duration::from_millis(50)).await;
        assert!(
            fifo.lock().unwrap().pop().is_none(),
            "non-overlapping change must be gated out, not delivered"
        );

        // The bridge is still live: an overlapping change arrives, proving
        // the gate (not a dead bridge) dropped the previous notify.
        interrupts.notify(InterruptValue {
            reason,
            addr: 0,
            value: crate::param::ParamValue::UInt32Digital(0xFF),
            timestamp: SystemTime::now(),
            uint32_changed_mask: 0x01,
            ..Default::default()
        });
        assert!(matches!(
            await_fifo_value(&fifo).await,
            crate::param::ParamValue::UInt32Digital(0x0F)
        ));
    }

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn io_intr_non_uint32_not_gated_by_default_mask() {
        // Guard: the default 0xFFFFFFFF mask must NOT gate a non-UInt32
        // interrupt (uint32_changed_mask == 0). An asynInt32 I/O-Intr
        // record must receive its interrupt unchanged.
        use epics_base_rs::server::records::longin::LonginRecord;
        let mut ads = make_adapter(ScanType::IoIntr);
        let mut rec = LonginRecord::new(0);
        ads.init(&mut rec).unwrap();
        let reason = ads.reason;
        let interrupts = ads.handle.interrupts().clone();
        let fifo = ads.interrupt_fifo.clone();
        let _rx = ads.io_intr_receiver().expect("io intr receiver for IoIntr");

        interrupts.notify(InterruptValue {
            reason,
            addr: 0,
            value: crate::param::ParamValue::Int32(42),
            timestamp: SystemTime::now(),
            uint32_changed_mask: 0,
            ..Default::default()
        });
        assert!(
            matches!(
                await_fifo_value(&fifo).await,
                crate::param::ParamValue::Int32(42)
            ),
            "non-UInt32 interrupt must pass through ungated and unmasked"
        );
    }

    #[test]
    fn test_adapter_init_resolves_reason() {
        let mut ads = make_adapter(ScanType::Passive);

        use epics_base_rs::server::records::longin::LonginRecord;
        let mut rec = LonginRecord::new(0);
        ads.init(&mut rec).unwrap();
        assert_eq!(ads.reason, 0); // "VAL" is param index 0
    }

    /// The output driver-callback ring must stay balanced: every armed
    /// readback cycle consumes exactly one ring entry, even when the cycle
    /// never reaches the read stage. C parity:
    /// `devAsynInt32.c::outputCallbackCallback` sets `newOutputCallbackValue`
    /// before `dbProcess` and, if the record did not process (PACT busy),
    /// calls `getCallbackValue` to discard the ring entry anyway. Without
    /// this fallback a start callback that bails on the PACT entry guard
    /// (it raced the bo's own put) leaves its value stranded, the finalize
    /// callback pops the stale start value, and the finalize value is never
    /// popped — the AD `Acquire` bo getting stuck at 1 after a fast acquire.
    /// Boundary cases: bail-with-entries, read-clears-flag, empty-ring,
    /// and the input gate (non-asyn:READBACK is never armed).
    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn readback_reconcile_keeps_ring_balanced_on_bail() {
        use epics_base_rs::server::records::bo::BoRecord;

        fn push(ads: &AsynDeviceSupport, v: i32) {
            ads.interrupt_fifo
                .lock()
                .unwrap()
                .push_with_overflow(CachedInterrupt {
                    value: crate::param::ParamValue::Int32(v),
                    timestamp: SystemTime::UNIX_EPOCH,
                    alarm_status: 0,
                    alarm_severity: 0,
                });
        }
        let len = |ads: &AsynDeviceSupport| ads.interrupt_fifo.lock().unwrap().entries.len();

        // asyn:READBACK output adapter; init so reason_set is true (read pops).
        let mut ads = make_adapter(ScanType::Passive);
        let mut rec = BoRecord::new(1);
        ads.init(&mut rec).unwrap();
        ads.set_asyn_readback(true);

        // Two output callbacks arrive: start=1 then finalize=0.
        push(&ads, 1);
        push(&ads, 0);
        assert_eq!(len(&ads), 2);

        // Cycle 1 — armed but the process bails before read() (the PACT entry
        // guard fired). reconcile must discard the oldest (start) entry so the
        // ring stays balanced: one callback == one consumed entry.
        ads.arm_readback_callback();
        ads.reconcile_readback_callback();
        assert_eq!(len(&ads), 1, "a bailed cycle must still consume one entry");

        // Cycle 2 — armed and the process reaches read(): it pops the finalize
        // 0 into VAL and clears the armed flag, so reconcile is a no-op.
        ads.arm_readback_callback();
        ads.read(&mut rec).unwrap();
        assert_eq!(len(&ads), 0, "read() pops the finalize entry");
        ads.reconcile_readback_callback();
        assert_eq!(
            len(&ads),
            0,
            "reconcile after a real read must not double-pop"
        );
        assert_eq!(
            rec.get_field("VAL"),
            Some(EpicsValue::Enum(0)),
            "VAL reads the finalize 0 back (Done)"
        );

        // Empty-ring boundary: an armed bail with nothing queued is a no-op.
        ads.arm_readback_callback();
        ads.reconcile_readback_callback();
        assert_eq!(len(&ads), 0);

        // Input gate: a non-asyn:READBACK device (plain I/O Intr / input) is
        // never armed, so reconcile must NOT discard — inputs use the
        // scan/RPRO reprocess model, not the output discard fallback.
        let mut input = make_adapter(ScanType::IoIntr);
        push(&input, 9);
        input.arm_readback_callback();
        input.reconcile_readback_callback();
        assert_eq!(len(&input), 1, "input I/O Intr must not discard on a bail");
    }

    // --- ai LINEAR ESLO/EOFF from getBounds (C devAsynInt32.c::convertAi) ---

    /// C `convertAi` formula: ESLO=(EGUF-EGUL)/(high-low),
    /// EOFF=(high*EGUL-low*EGUF)/(high-low). With low=0, high=4095,
    /// EGUL=0.0, EGUF=10.0 → ESLO ≈ 10/4095 ≈ 0.002442, EOFF=0.0.
    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn ai_linear_eslo_eoff_filled_from_get_bounds_int32() {
        let mut ads = make_bounded_adapter(0, 4095, "asynInt32");
        use epics_base_rs::server::records::ai::AiRecord;
        let mut rec = AiRecord::new(0.0);
        // Configure EGUL/EGUF before init so convertAi has them.
        rec.eguf = 10.0;
        rec.egul = 0.0;
        rec.linr = 2; // LINEAR (LINR codes: 0=NO_CONVERSION, 1=SLOPE, 2=LINEAR)
        ads.init(&mut rec).unwrap();

        let eslo = match rec.get_field("ESLO").unwrap() {
            EpicsValue::Double(v) => v,
            _ => panic!(),
        };
        let eoff = match rec.get_field("EOFF").unwrap() {
            EpicsValue::Double(v) => v,
            _ => panic!(),
        };
        assert!(
            (eslo - 10.0 / 4095.0).abs() < 1e-9,
            "ESLO must equal (EGUF-EGUL)/(high-low): got {eslo}"
        );
        assert!(
            eoff.abs() < 1e-9,
            "EOFF must equal 0 for symmetric range: got {eoff}"
        );
    }

    /// EGUF=10, EGUL=-10 with bounds [-2048, 2047] → ESLO ≈ 20/4095.
    /// EOFF = (2047*-10 - -2048*10)/4095 = (-20470 + 20480)/4095 = 10/4095.
    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn ai_linear_eslo_eoff_signed_range() {
        let mut ads = make_bounded_adapter(-2048, 2047, "asynInt32");
        use epics_base_rs::server::records::ai::AiRecord;
        let mut rec = AiRecord::new(0.0);
        rec.eguf = 10.0;
        rec.egul = -10.0;
        rec.linr = 2;
        ads.init(&mut rec).unwrap();
        let eslo = match rec.get_field("ESLO").unwrap() {
            EpicsValue::Double(v) => v,
            _ => panic!(),
        };
        let eoff = match rec.get_field("EOFF").unwrap() {
            EpicsValue::Double(v) => v,
            _ => panic!(),
        };
        let denom = (2047 - -2048) as f64;
        assert!((eslo - 20.0 / denom).abs() < 1e-9);
        let expected_eoff = (2047.0 * -10.0 - -2048.0 * 10.0) / denom;
        assert!(
            (eoff - expected_eoff).abs() < 1e-9,
            "EOFF expected {expected_eoff} got {eoff}"
        );
    }

    /// Driver returning low==high (no usable range) must leave the
    /// record's ESLO/EOFF unchanged — matches C `convertAi:444`
    /// (`if (deviceHigh != deviceLow)`).
    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn ai_linear_skip_when_bounds_equal() {
        let mut ads = make_bounded_adapter(0, 0, "asynInt32");
        use epics_base_rs::server::records::ai::AiRecord;
        let mut rec = AiRecord::new(0.0);
        rec.eguf = 10.0;
        rec.egul = 0.0;
        rec.linr = 2;
        // Pre-set ESLO/EOFF to sentinel values to detect untouched.
        rec.eslo = 123.456;
        rec.eoff = 42.0;
        ads.init(&mut rec).unwrap();
        assert!((rec.eslo - 123.456).abs() < 1e-9);
        assert!((rec.eoff - 42.0).abs() < 1e-9);
    }

    /// Records without an ESLO field (e.g. longin) must skip the
    /// LINEAR wiring entirely — the `record.get_field("ESLO").is_some()`
    /// guard in `init()` prevents a wasted GetBoundsInt32 round-trip.
    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn longin_skips_linear_wiring() {
        let mut ads = make_bounded_adapter(0, 4095, "asynInt32");
        use epics_base_rs::server::records::longin::LonginRecord;
        let mut rec = LonginRecord::new(0);
        // Should not error or block — just no-ops because LonginRecord
        // doesn't expose ESLO.
        ads.init(&mut rec).unwrap();
    }

    /// A driver that does NOT implement getBounds reports low=high=0
    /// (C asynInt32Base.c:99), so convertAi skips the LINEAR ESLO/EOFF
    /// computation (devAsynInt32.c:444). TestPort overrides no bounds,
    /// exercising the PortDriver default; ESLO/EOFF must be left at the
    /// record's pre-set values.
    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn ai_linear_skip_when_driver_uses_default_bounds() {
        let mut ads = make_adapter(ScanType::Passive); // TestPort: default bounds
        use epics_base_rs::server::records::ai::AiRecord;
        let mut rec = AiRecord::new(0.0);
        rec.eguf = 10.0;
        rec.egul = 0.0;
        rec.linr = 2; // LINEAR
        rec.eslo = 123.456;
        rec.eoff = 42.0;
        ads.init(&mut rec).unwrap();
        assert!(
            (rec.eslo - 123.456).abs() < 1e-9,
            "ESLO must be untouched when the driver relies on the default (0,0) bounds: got {}",
            rec.eslo
        );
        assert!(
            (rec.eoff - 42.0).abs() < 1e-9,
            "EOFF must be untouched when the driver relies on the default (0,0) bounds: got {}",
            rec.eoff
        );
    }

    /// C `devAsynInt32::processAi` (devAsynInt32.c:848-851) sets
    /// `pr->rval = value` and returns `0`, so the ai record's own
    /// `convert()` runs — applying ROFF/ASLO/AOFF and the LINR
    /// linearisation (ESLO/EOFF). The asyn-rs adapter must therefore route
    /// the raw count through RVAL and return `ok()` (run convert), NOT
    /// `computed()` (skip convert). Before the fix the read wrote VAL
    /// directly + returned `computed()`, so the init-computed ESLO/EOFF
    /// were dead and a LINEAR ai surfaced raw counts.
    #[test]
    fn int32_ai_linear_read_applies_eslo_eoff() {
        use epics_base_rs::server::records::ai::AiRecord;
        let mut ads = make_bounded_adapter(0, 4095, "asynInt32");
        let mut rec = AiRecord::new(0.0);
        // EGUF/EGUL/LINR before init so init's convertAi fills ESLO/EOFF:
        // ESLO=(10-0)/(4095-0)=10/4095, EOFF=0.
        rec.eguf = 10.0;
        rec.egul = 0.0;
        rec.linr = 2; // LINEAR
        ads.init(&mut rec).unwrap();
        // Drive the I/O Intr read path with a raw full-scale count.
        ads.set_record_info("TEST:AI", ScanType::IoIntr);
        ads.interrupt_fifo
            .lock()
            .unwrap()
            .push_with_overflow(intr_entry(4095, 0));

        let outcome = ads.read(&mut rec).unwrap();
        assert!(
            !outcome.did_compute,
            "asynInt32 ai routes raw→RVAL and runs the record convert (C return 0)"
        );
        // Framework runs the record convert when did_compute is false.
        rec.process().unwrap();
        let val = match rec.get_field("VAL").unwrap() {
            EpicsValue::Double(v) => v,
            _ => panic!(),
        };
        assert!(
            (val - 10.0).abs() < 1e-6,
            "LINEAR ESLO/EOFF applied at read: 4095*(10/4095) = 10.0, got {val}"
        );
    }

    /// LINR=NO_CONVERSION ai: the record convert still runs (C returns 0
    /// unconditionally) but applies no ESLO/EOFF, so VAL == raw count —
    /// confirming the raw→RVAL + run-convert path preserves the identity
    /// case the old direct-VAL path produced.
    #[test]
    fn int32_ai_no_conversion_read_passes_raw() {
        use epics_base_rs::server::records::ai::AiRecord;
        let mut ads = make_bounded_adapter(0, 4095, "asynInt32");
        let mut rec = AiRecord::new(0.0);
        rec.linr = 0; // NO_CONVERSION
        ads.init(&mut rec).unwrap();
        ads.set_record_info("TEST:AI", ScanType::IoIntr);
        ads.interrupt_fifo
            .lock()
            .unwrap()
            .push_with_overflow(intr_entry(1234, 0));

        let outcome = ads.read(&mut rec).unwrap();
        assert!(!outcome.did_compute);
        rec.process().unwrap();
        let val = match rec.get_field("VAL").unwrap() {
            EpicsValue::Double(v) => v,
            _ => panic!(),
        };
        assert!(
            (val - 1234.0).abs() < 1e-9,
            "NO_CONVERSION ai: VAL == raw count, got {val}"
        );
    }

    /// A record without an ESLO field (longin) is not an ai — the
    /// `get_field("ESLO").is_some()` discriminator keeps the direct VAL
    /// path and `computed()` (skip convert), unchanged from before. This
    /// scopes the convert-routing fix to ai records; mbbi/bi/longin (no
    /// ESLO) are unaffected.
    #[test]
    fn int32_longin_read_stays_direct_computed() {
        use epics_base_rs::server::records::longin::LonginRecord;
        let mut ads = make_bounded_adapter(0, 4095, "asynInt32");
        let mut rec = LonginRecord::new(0);
        ads.init(&mut rec).unwrap();
        ads.set_record_info("TEST:LI", ScanType::IoIntr);
        ads.interrupt_fifo
            .lock()
            .unwrap()
            .push_with_overflow(intr_entry(77, 0));

        let outcome = ads.read(&mut rec).unwrap();
        assert!(
            outcome.did_compute,
            "non-ai (no ESLO) keeps the direct VAL path + computed()"
        );
        let val = match rec.get_field("VAL").unwrap() {
            EpicsValue::Long(v) => v,
            _ => panic!(),
        };
        assert_eq!(val, 77, "longin VAL set directly to the raw count");
    }

    // --- driver enum-string table -> record state fields
    //     (C devAsynInt32.c::initCommon enum block + setEnums) ---

    /// Build an `asynEnum` adapter whose driver exposes a `MODE` enum param
    /// carrying `choices`. `init()` reads this table and pushes it onto the
    /// bound record's state fields.
    fn make_enum_adapter(choices: std::sync::Arc<[crate::param::EnumEntry]>) -> AsynDeviceSupport {
        struct EnumPort {
            base: PortDriverBase,
        }
        impl PortDriver for EnumPort {
            fn base(&self) -> &PortDriverBase {
                &self.base
            }
            fn base_mut(&mut self) -> &mut PortDriverBase {
                &mut self.base
            }
        }

        let mut base = PortDriverBase::new("test_enum", 1, PortFlags::default());
        base.create_param("MODE", ParamType::Enum).unwrap();
        base.set_enum_choices_param(0, 0, choices).unwrap();
        let driver = EnumPort { base };

        let interrupts = Arc::new(InterruptManager::new(256));
        let (tx, rx) = tokio::sync::mpsc::channel(256);
        let actor = PortActor::new(Box::new(driver), rx);
        std::thread::Builder::new()
            .name("test-enum-actor".into())
            .spawn(move || actor.run())
            .unwrap();
        let handle = PortHandle::new(tx, "test_enum".into(), interrupts);
        let link = AsynLink {
            port_name: "test_enum".into(),
            addr: 0,
            timeout: Duration::from_secs(1),
            drv_info: "MODE".into(),
        };
        let mut ads = AsynDeviceSupport::from_handle(handle, link, "asynEnum");
        ads.set_record_info("TEST:ENUM", ScanType::Passive);
        ads
    }

    fn enum_entry(s: &str, value: i32, severity: u16) -> crate::param::EnumEntry {
        crate::param::EnumEntry {
            string: s.into(),
            value,
            severity,
        }
    }

    fn field_string(rec: &dyn Record, name: &str) -> String {
        match rec.get_field(name).unwrap() {
            EpicsValue::String(s) => s.as_str_lossy().into_owned(),
            other => panic!("{name} not a String: {other:?}"),
        }
    }

    /// C `devAsynInt32::initCommon` (297-324) reads the driver's asynEnum
    /// table and `setEnums` (415-435) copies strings/values/severities onto
    /// the mbbi state fields ZRST/ZRVL/ZRSV…. Before the fix the actor
    /// dropped the table (`let (idx, _entries)`) so the record kept its .db
    /// strings.
    #[test]
    fn mbbi_init_propagates_driver_enum_table() {
        use epics_base_rs::server::records::mbbi::MbbiRecord;
        let choices: std::sync::Arc<[crate::param::EnumEntry]> =
            std::sync::Arc::from(vec![enum_entry("OFF", 0, 0), enum_entry("ON", 5, 2)]);
        let mut ads = make_enum_adapter(choices);
        let mut rec = MbbiRecord::new(0);
        // A surplus .db string on a state the driver does not define must be
        // blanked, matching C setEnums zeroing all numOut slots first.
        rec.put_field("TWST", EpicsValue::String("STALE".into()))
            .unwrap();

        ads.init(&mut rec).unwrap();

        assert_eq!(field_string(&rec, "ZRST"), "OFF");
        assert_eq!(field_string(&rec, "ONST"), "ON");
        assert_eq!(
            rec.get_field("ZRVL").unwrap(),
            EpicsValue::ULong(0),
            "ZRVL from entry 0 value"
        );
        assert_eq!(
            rec.get_field("ONVL").unwrap(),
            EpicsValue::ULong(5),
            "ONVL from entry 1 value"
        );
        assert_eq!(
            rec.get_field("ZRSV").unwrap(),
            EpicsValue::Short(0),
            "ZRSV from entry 0 severity"
        );
        assert_eq!(
            rec.get_field("ONSV").unwrap(),
            EpicsValue::Short(2),
            "ONSV from entry 1 severity"
        );
        assert_eq!(
            field_string(&rec, "TWST"),
            "",
            "state beyond the driver table is cleared (C setEnums zeroes numOut)"
        );
    }

    /// C `initBi` (devAsynInt32.c:1138-1140) passes `maxEnums=2`,
    /// `&pr->znam`, NULL values, `&pr->zsv`: bi/bo get only ZNAM/ONAM and
    /// ZSV/OSV — no raw-value fields.
    #[test]
    fn bi_init_propagates_znam_onam_and_severities() {
        use epics_base_rs::server::records::bi::BiRecord;
        let choices: std::sync::Arc<[crate::param::EnumEntry]> =
            std::sync::Arc::from(vec![enum_entry("CLOSED", 0, 0), enum_entry("OPEN", 1, 1)]);
        let mut ads = make_enum_adapter(choices);
        let mut rec = BiRecord::new(0);

        ads.init(&mut rec).unwrap();

        assert_eq!(field_string(&rec, "ZNAM"), "CLOSED");
        assert_eq!(field_string(&rec, "ONAM"), "OPEN");
        assert_eq!(
            rec.get_field("ZSV").unwrap(),
            EpicsValue::Short(0),
            "ZSV from entry 0 severity"
        );
        assert_eq!(
            rec.get_field("OSV").unwrap(),
            EpicsValue::Short(1),
            "OSV from entry 1 severity"
        );
    }

    /// A record exposing no enum state fields (ai) must be left untouched —
    /// `apply_enum_table` returns early and `init` performs no enum read.
    #[test]
    fn ai_init_ignores_enum_table() {
        use epics_base_rs::server::records::ai::AiRecord;
        let choices: std::sync::Arc<[crate::param::EnumEntry]> =
            std::sync::Arc::from(vec![enum_entry("X", 0, 0)]);
        let mut ads = make_enum_adapter(choices);
        let mut rec = AiRecord::new(0.0);
        // Must not panic or error; ai has no ZRST/ZNAM so nothing is written.
        ads.init(&mut rec).unwrap();
        assert!(rec.get_field("ZRST").is_none());
        assert!(rec.get_field("ZNAM").is_none());
    }

    /// The `enum_table_fields` producer (shared by init + runtime callback)
    /// yields the full mbb state block: string/value/severity per slot, with
    /// surplus slots cleared (empty / 0 / 0) — C `setEnums` semantics.
    #[test]
    fn enum_table_fields_mbb_shape() {
        let entries = vec![enum_entry("OFF", 0, 0), enum_entry("ON", 5, 2)];
        let fields = enum_table_fields(EnumRecordShape::Mbb, &entries);
        // 16 states x (string + value + severity) = 48 entries.
        assert_eq!(fields.len(), 48);
        let get = |name: &str| {
            fields
                .iter()
                .find(|(f, _)| f == name)
                .map(|(_, v)| v.clone())
        };
        assert_eq!(get("ZRST"), Some(EpicsValue::String("OFF".into())));
        assert_eq!(get("ONST"), Some(EpicsValue::String("ON".into())));
        assert_eq!(get("ZRVL"), Some(EpicsValue::ULong(0)));
        assert_eq!(get("ONVL"), Some(EpicsValue::ULong(5)));
        assert_eq!(get("ZRSV"), Some(EpicsValue::Short(0)));
        assert_eq!(get("ONSV"), Some(EpicsValue::Short(2)));
        // Surplus state beyond the driver table is blanked.
        assert_eq!(get("TWST"), Some(EpicsValue::String("".into())));
        assert_eq!(get("TWVL"), Some(EpicsValue::ULong(0)));
    }

    /// bi/bo shape: 2 states, ZNAM/ONAM + ZSV/OSV, no raw-value fields.
    #[test]
    fn enum_table_fields_bi_shape() {
        let entries = vec![enum_entry("CLOSED", 0, 0), enum_entry("OPEN", 1, 1)];
        let fields = enum_table_fields(EnumRecordShape::Bi, &entries);
        // 2 states x (string + severity) = 4 entries; no value fields.
        assert_eq!(fields.len(), 4);
        assert!(!fields.iter().any(|(f, _)| f == "ZRVL" || f == "ONVL"));
        let get = |name: &str| {
            fields
                .iter()
                .find(|(f, _)| f == name)
                .map(|(_, v)| v.clone())
        };
        assert_eq!(get("ZNAM"), Some(EpicsValue::String("CLOSED".into())));
        assert_eq!(get("ONAM"), Some(EpicsValue::String("OPEN".into())));
        assert_eq!(get("ZSV"), Some(EpicsValue::Short(0)));
        assert_eq!(get("OSV"), Some(EpicsValue::Short(1)));
    }

    /// Runtime asynEnum re-propagation: after init captures the table, a
    /// driver enum-table change delivered through the interrupt path must
    /// surface on the `property_post_receiver` channel as the new mbb field
    /// block — but an interrupt carrying the SAME choices (a value-only
    /// change) must fire NO post (C's asynEnum callback only runs on a table
    /// change, never on an int32 value callback).
    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn property_post_receiver_emits_only_on_table_change() {
        use crate::interrupt::InterruptValue;
        use crate::param::{EnumEntry, ParamValue};
        use epics_base_rs::server::records::mbbi::MbbiRecord;

        let init_choices: Arc<[EnumEntry]> =
            Arc::from(vec![enum_entry("OFF", 0, 0), enum_entry("ON", 1, 0)]);
        let mut ads = make_enum_adapter(init_choices.clone());
        let mut rec = MbbiRecord::new(0);
        ads.init(&mut rec).unwrap();

        let mut rx = ads
            .property_post_receiver()
            .expect("enum record arms the property callback");

        // 1. A value-only interrupt (same choices) → no post.
        ads.handle.interrupts().notify(InterruptValue {
            reason: ads.reason,
            addr: ads.addr,
            value: ParamValue::Enum {
                index: 1,
                choices: init_choices.clone(),
            },
            ..Default::default()
        });

        // 2. A table-change interrupt (new choices) → post the new block.
        let new_choices: Arc<[EnumEntry]> = Arc::from(vec![
            enum_entry("LOW", 0, 0),
            enum_entry("MID", 1, 1),
            enum_entry("HIGH", 2, 2),
        ]);
        ads.handle.interrupts().notify(InterruptValue {
            reason: ads.reason,
            addr: ads.addr,
            value: ParamValue::Enum {
                index: 0,
                choices: new_choices.clone(),
            },
            ..Default::default()
        });

        let fields = tokio::time::timeout(Duration::from_secs(2), rx.recv())
            .await
            .expect("property post must arrive within timeout")
            .expect("channel open");
        let get = |name: &str| {
            fields
                .iter()
                .find(|(f, _)| f == name)
                .map(|(_, v)| v.clone())
        };
        // The delivered block is the NEW table (value-only #1 was suppressed).
        assert_eq!(get("ZRST"), Some(EpicsValue::String("LOW".into())));
        assert_eq!(get("ONST"), Some(EpicsValue::String("MID".into())));
        assert_eq!(get("TWST"), Some(EpicsValue::String("HIGH".into())));
        assert_eq!(get("TWVL"), Some(EpicsValue::ULong(2)));
        assert_eq!(get("TWSV"), Some(EpicsValue::Short(2)));

        // No further post is queued (the value-only interrupt produced none).
        assert!(
            rx.try_recv().is_err(),
            "value-only interrupt must not post a property update"
        );
    }

    /// Every getBounds default returns (0, 0), matching C
    /// asynInt32Base.c:99 / asynInt64Base.c:99 (`*low = *high = 0`).
    #[test]
    fn get_bounds_defaults_match_c_low_high_zero() {
        use crate::interfaces::int32::AsynInt32;
        use crate::interfaces::int64::AsynInt64;

        struct Bare32;
        impl AsynInt32 for Bare32 {
            fn read_int32(&mut self, _u: &AsynUser) -> AsynResult<i32> {
                Ok(0)
            }
            fn write_int32(&mut self, _u: &mut AsynUser, _v: i32) -> AsynResult<()> {
                Ok(())
            }
        }
        struct Bare64;
        impl AsynInt64 for Bare64 {
            fn read_int64(&mut self, _u: &AsynUser) -> AsynResult<i64> {
                Ok(0)
            }
            fn write_int64(&mut self, _u: &mut AsynUser, _v: i64) -> AsynResult<()> {
                Ok(())
            }
        }

        let u = AsynUser::new(0);
        assert_eq!(Bare32.get_bounds(&u).unwrap(), (0, 0));
        assert_eq!(Bare64.get_bounds(&u).unwrap(), (0, 0));
        // PortDriver defaults — TestPort overrides neither.
        assert_eq!(TestPort::new().get_bounds_int32(&u).unwrap(), (0, 0));
        assert_eq!(TestPort::new().get_bounds_int64(&u).unwrap(), (0, 0));
    }

    // --- asyn:FIFO ring buffer ---

    fn intr_entry(v: i32, t_ms: u64) -> CachedInterrupt {
        CachedInterrupt {
            value: crate::param::ParamValue::Int32(v),
            timestamp: SystemTime::UNIX_EPOCH + Duration::from_millis(t_ms),
            alarm_status: 0,
            alarm_severity: 0,
        }
    }

    #[test]
    fn fifo_default_size_matches_c_constant() {
        // C devAsynInt32.c:63 → DEFAULT_RING_BUFFER_SIZE = 10.
        let f = InterruptFifo::new();
        assert_eq!(f.ring_size, 10);
        assert!(f.entries.is_empty());
        assert_eq!(f.overflows, 0);
    }

    #[test]
    fn fifo_push_pop_fifo_order() {
        let mut f = InterruptFifo::new();
        assert!(f.push_with_overflow(intr_entry(1, 1)));
        assert!(f.push_with_overflow(intr_entry(2, 2)));
        assert!(f.push_with_overflow(intr_entry(3, 3)));
        let popped: Vec<_> = std::iter::from_fn(|| f.pop())
            .map(|c| match c.value {
                crate::param::ParamValue::Int32(v) => v,
                _ => panic!(),
            })
            .collect();
        assert_eq!(popped, vec![1, 2, 3], "FIFO order, not LIFO");
        assert_eq!(f.take_overflows(), 0);
    }

    #[test]
    fn fifo_overflow_drops_oldest_and_counts() {
        // C parity: devAsynInt32.c:566-571 — when ringHead wraps onto
        // ringTail, advance ringTail (drop oldest) + overflows++.
        let mut f = InterruptFifo::new();
        f.ring_size = 3;
        assert!(f.push_with_overflow(intr_entry(1, 1)));
        assert!(f.push_with_overflow(intr_entry(2, 2)));
        assert!(f.push_with_overflow(intr_entry(3, 3)));
        // Now full: every additional push must be reported as overflow.
        assert!(!f.push_with_overflow(intr_entry(4, 4)));
        assert!(!f.push_with_overflow(intr_entry(5, 5)));
        assert_eq!(f.overflows, 2);
        // Buffer now holds [3, 4, 5] — oldest two dropped.
        let popped: Vec<_> = std::iter::from_fn(|| f.pop())
            .map(|c| match c.value {
                crate::param::ParamValue::Int32(v) => v,
                _ => panic!(),
            })
            .collect();
        assert_eq!(popped, vec![3, 4, 5]);
    }

    #[test]
    fn fifo_take_overflows_resets() {
        let mut f = InterruptFifo::new();
        f.ring_size = 1;
        f.push_with_overflow(intr_entry(1, 1));
        f.push_with_overflow(intr_entry(2, 2)); // overflow
        f.push_with_overflow(intr_entry(3, 3)); // overflow
        assert_eq!(f.take_overflows(), 2);
        // Second call must return 0 — counter was reset.
        assert_eq!(f.take_overflows(), 0);
    }

    #[test]
    fn set_fifo_size_truncates_existing_entries() {
        let mut ads = make_adapter(ScanType::IoIntr);
        {
            let mut g = ads.interrupt_fifo.lock().unwrap();
            g.ring_size = 10;
            g.push_with_overflow(intr_entry(1, 1));
            g.push_with_overflow(intr_entry(2, 2));
            g.push_with_overflow(intr_entry(3, 3));
            g.push_with_overflow(intr_entry(4, 4));
        }
        // Shrink — must drop the 2 oldest and count them as overflows.
        ads.set_fifo_size(2);
        let g = ads.interrupt_fifo.lock().unwrap();
        assert_eq!(g.entries.len(), 2);
        assert_eq!(g.overflows, 2);
    }

    #[test]
    fn apply_record_info_parses_asyn_fifo() {
        let mut ads = make_adapter(ScanType::IoIntr);
        let mut info = std::collections::HashMap::new();
        info.insert("asyn:FIFO".to_string(), "32".to_string());
        ads.apply_record_info(&info);
        assert_eq!(ads.interrupt_fifo.lock().unwrap().ring_size, 32);

        // C atoi("garbage") = 0 → keep default (we additionally
        // require n > 0).
        info.insert("asyn:FIFO".to_string(), "garbage".to_string());
        ads.apply_record_info(&info);
        assert_eq!(
            ads.interrupt_fifo.lock().unwrap().ring_size,
            32,
            "non-numeric must not clobber size"
        );

        // Negative / zero is rejected too.
        info.insert("asyn:FIFO".to_string(), "0".to_string());
        ads.apply_record_info(&info);
        assert_eq!(ads.interrupt_fifo.lock().unwrap().ring_size, 32);
    }

    #[test]
    fn compute_mask_shift_matches_c() {
        // C devAsynUInt32Digital.c:627-636 — position of lowest set bit.
        assert_eq!(compute_mask_shift(0x0001), 0);
        assert_eq!(compute_mask_shift(0x0002), 1);
        assert_eq!(compute_mask_shift(0x0080), 7);
        assert_eq!(compute_mask_shift(0x0F00), 8);
        assert_eq!(compute_mask_shift(0xFF00), 8);
        assert_eq!(compute_mask_shift(0x8000_0000), 31);
        // 0 mask is the "no bits" sentinel — C falls through to 32 too.
        assert_eq!(compute_mask_shift(0), 32);
    }

    /// C devAsynUInt32Digital.c:881 / 925 / 1010 / 1054 sets
    /// `pr->mask` and `pr->shft = computeShift(mask)` from the link
    /// mask so the record's RVAL→VAL conversion shifts the bits to
    /// bit-0. Verify the Rust adapter writes both fields at init.
    #[test]
    fn uint32_digital_init_propagates_mask_and_shft_to_record() {
        let mut ads = make_adapter(ScanType::Passive);
        ads.set_iface_type("asynUInt32Digital");
        ads = ads.with_mask(0xFF00);

        use epics_base_rs::server::records::mbbi::MbbiRecord;
        let mut rec = MbbiRecord::default();
        ads.init(&mut rec).unwrap();

        assert_eq!(
            rec.get_field("MASK"),
            Some(EpicsValue::ULong(0xFF00)),
            "MASK must propagate"
        );
        assert_eq!(
            rec.get_field("SHFT"),
            Some(EpicsValue::UShort(8)),
            "SHFT must equal computeShift(0xFF00) = 8"
        );
    }

    /// C parity: devAsynOctet initCommon passes `plsi->sizv` as the
    /// asynOctet read-buffer size; an lsi record with a non-default
    /// SIZV must produce read ops sized accordingly, not the fixed
    /// 256-byte stringin default.
    #[test]
    fn octet_buffer_picks_up_sizv_from_record() {
        let mut ads = make_adapter(ScanType::Passive);
        // The adapter only routes asynOctet through octet_max_size,
        // so re-target it.
        ads.set_iface_type("asynOctet");
        // Default (no init yet) is the stringin fallback.
        assert_eq!(ads.octet_max_size, 256);

        use epics_base_rs::server::records::lsi::LsiRecord;
        let mut rec = LsiRecord::new("");
        // Default SIZV = 256 — still matches default.
        ads.init(&mut rec).unwrap();
        assert_eq!(ads.octet_max_size, 256);

        // Bump SIZV and re-init: adapter must follow.
        rec.sizv = 1024;
        ads.init(&mut rec).unwrap();
        assert_eq!(ads.octet_max_size, 1024);
    }

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn apply_record_info_enables_readback_for_truthy_value() {
        // info("asyn:READBACK", "1") on a Passive output must allow
        // io_intr_receiver to activate (asyn upstream PRs #60 / #208).
        let mut ads = make_adapter(ScanType::Passive);
        use epics_base_rs::server::records::longin::LonginRecord;
        let mut rec = LonginRecord::new(0);
        ads.init(&mut rec).unwrap();
        // Without the info tag, Passive scan keeps io_intr_receiver=None.
        assert!(ads.io_intr_receiver().is_none());
        // Apply the tag — adapter should now expose an Intr receiver.
        let mut info = std::collections::HashMap::new();
        info.insert("asyn:READBACK".to_string(), "1".to_string());
        ads.apply_record_info(&info);
        assert!(
            ads.io_intr_receiver().is_some(),
            "asyn:READBACK=1 must enable readback path"
        );
    }

    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn apply_record_info_falsey_values_do_not_enable_readback() {
        let mut ads = make_adapter(ScanType::Passive);
        use epics_base_rs::server::records::longin::LonginRecord;
        let mut rec = LonginRecord::new(0);
        ads.init(&mut rec).unwrap();
        for falsey in ["0", "no", "NO", "false", "False", ""] {
            let mut info = std::collections::HashMap::new();
            info.insert("asyn:READBACK".to_string(), falsey.to_string());
            ads.apply_record_info(&info);
            assert!(
                ads.io_intr_receiver().is_none(),
                "value {falsey:?} must not enable readback"
            );
        }
    }

    /// C parity for `asynDbGetInfo(precord, "asyn:INITIAL_READBACK")`
    /// at devAsynOctet.c:357 — info tag overrides the per-record
    /// default. Verifies the auto-parse path: the framework calls
    /// `apply_record_info`, which must flip `initial_readback`
    /// without the caller having to invoke `set_initial_readback`.
    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
    async fn apply_record_info_handles_initial_readback_tag() {
        let mut ads = make_adapter(ScanType::Passive);
        // Starts off (default for adapters built with from_handle).
        assert!(!ads.initial_readback);

        let mut info = std::collections::HashMap::new();
        info.insert("asyn:INITIAL_READBACK".to_string(), "1".to_string());
        ads.apply_record_info(&info);
        assert!(ads.initial_readback, "info tag must enable readback");

        // Falsey value resets it.
        info.insert("asyn:INITIAL_READBACK".to_string(), "0".to_string());
        ads.apply_record_info(&info);
        assert!(!ads.initial_readback, "value '0' must disable readback");
    }

    #[test]
    fn test_adapter_write_read() {
        let mut ads = make_adapter(ScanType::Passive);

        use epics_base_rs::server::records::longin::LonginRecord;
        let mut rec = LonginRecord::new(0);
        ads.init(&mut rec).unwrap();

        // Write a value
        rec.set_val(EpicsValue::Long(42)).unwrap();
        ads.write(&mut rec).unwrap();

        // Read it back
        let mut rec2 = LonginRecord::new(0);
        ads.read(&mut rec2).unwrap();
        assert_eq!(rec2.val(), Some(EpicsValue::Long(42)));
    }

    #[test]
    fn io_intr_read_applies_cached_driver_alarm() {
        // C devAsynInt32.c:561-563/843-847 — the I/O-Intr ring element
        // carries the driver alarm and processXxx recGblSetSevr's it.
        // The CachedInterrupt previously dropped it, leaving I/O-Intr
        // records permanently NO_ALARM.
        use epics_base_rs::server::records::longin::LonginRecord;
        let mut ads = make_adapter(ScanType::IoIntr);
        let mut rec = LonginRecord::new(0);
        ads.init(&mut rec).unwrap(); // resolves reason_set
        {
            let mut g = ads.interrupt_fifo.lock().unwrap();
            g.push_with_overflow(CachedInterrupt {
                value: crate::param::ParamValue::Int32(7),
                timestamp: SystemTime::UNIX_EPOCH,
                alarm_status: 3,   // e.g. READ_ALARM
                alarm_severity: 1, // e.g. MINOR
            });
        }
        ads.read(&mut rec).unwrap();
        assert_eq!(rec.val(), Some(EpicsValue::Long(7)));
        assert_eq!(
            ads.last_alarm(),
            Some((3, 1)),
            "IoIntr read must surface the driver alarm carried by the ring entry"
        );
    }

    #[test]
    fn io_intr_read_no_alarm_when_entry_clean() {
        use epics_base_rs::server::records::longin::LonginRecord;
        let mut ads = make_adapter(ScanType::IoIntr);
        let mut rec = LonginRecord::new(0);
        ads.init(&mut rec).unwrap(); // resolves reason_set
        {
            let mut g = ads.interrupt_fifo.lock().unwrap();
            g.push_with_overflow(intr_entry(9, 0)); // alarm 0/0
        }
        ads.read(&mut rec).unwrap();
        assert_eq!(rec.val(), Some(EpicsValue::Long(9)));
        assert_eq!(ads.last_alarm(), None, "clean entry => no alarm");
    }

    /// A `ScanType::IoIntr` adapter on `asynFloat64` whose driver exposes a
    /// `Float64` VAL param (so `init`/`drv_user_create` resolves `reason_set`).
    fn make_float64_io_intr_adapter() -> AsynDeviceSupport {
        struct F64Port {
            base: PortDriverBase,
        }
        impl F64Port {
            fn new() -> Self {
                let mut base = PortDriverBase::new("test_f64", 1, PortFlags::default());
                base.create_param("VAL", ParamType::Float64).unwrap();
                Self { base }
            }
        }
        impl PortDriver for F64Port {
            fn base(&self) -> &PortDriverBase {
                &self.base
            }
            fn base_mut(&mut self) -> &mut PortDriverBase {
                &mut self.base
            }
        }

        let driver = F64Port::new();
        let interrupts = Arc::new(InterruptManager::new(256));
        let (tx, rx) = tokio::sync::mpsc::channel(256);
        let actor = PortActor::new(Box::new(driver), rx);
        std::thread::Builder::new()
            .name("test-f64-actor".into())
            .spawn(move || actor.run())
            .unwrap();
        let handle = PortHandle::new(tx, "test_f64".into(), interrupts);
        let link = AsynLink {
            port_name: "test_f64".into(),
            addr: 0,
            timeout: Duration::from_secs(1),
            drv_info: "VAL".into(),
        };
        let mut ads = AsynDeviceSupport::from_handle(handle, link, "asynFloat64");
        ads.set_record_info("TEST:AI", ScanType::IoIntr);
        ads
    }

    fn push_f64(ads: &AsynDeviceSupport, raw: f64) {
        let mut g = ads.interrupt_fifo.lock().unwrap();
        g.push_with_overflow(CachedInterrupt {
            value: crate::param::ParamValue::Float64(raw),
            timestamp: SystemTime::UNIX_EPOCH,
            alarm_status: 0,
            alarm_severity: 0,
        });
    }

    /// C `devAsynFloat64::processAi` (devAsynFloat64.c:594-604) computes the ai
    /// VAL itself: ASLO/AOFF scaling then the SMOO filter, then returns
    /// RTN_DO_NOT_CONVERT. The asyn-rs adapter returns `computed()` (skips the
    /// record convert) so it must apply the same ASLO/AOFF/SMOO — otherwise the
    /// raw driver double reaches VAL unscaled and unfiltered.
    #[test]
    fn float64_ai_read_applies_aslo_aoff_and_smoo() {
        use epics_base_rs::server::records::ai::AiRecord;
        let mut ads = make_float64_io_intr_adapter();
        let mut rec = AiRecord::new(0.0);
        ads.init(&mut rec).unwrap(); // reason_set
        rec.put_field("ASLO", EpicsValue::Double(2.0)).unwrap();
        rec.put_field("AOFF", EpicsValue::Double(1.0)).unwrap();
        rec.put_field("SMOO", EpicsValue::Double(0.0)).unwrap();

        // First read: SMOO=0 -> pure ASLO/AOFF: 10*2 + 1 = 21.
        push_f64(&ads, 10.0);
        ads.read(&mut rec).unwrap();
        assert_eq!(
            rec.val(),
            Some(EpicsValue::Double(21.0)),
            "ASLO/AOFF applied: raw*aslo + aoff"
        );

        // Second read with SMOO=0.5: val = prev*smoo + val64*(1-smoo)
        //   val64 = 20*2 + 1 = 41; val = 21*0.5 + 41*0.5 = 31.
        rec.put_field("SMOO", EpicsValue::Double(0.5)).unwrap();
        push_f64(&ads, 20.0);
        ads.read(&mut rec).unwrap();
        assert_eq!(
            rec.val(),
            Some(EpicsValue::Double(31.0)),
            "SMOO filter: prev*smoo + (raw*aslo+aoff)*(1-smoo)"
        );
    }

    /// SMOO primes on the first read — C skips smoothing while `pr->udf`
    /// (devAsynFloat64.c:599), so the first value is taken whole rather than
    /// smoothed toward the record's initial VAL.
    #[test]
    fn float64_ai_smoo_primes_on_first_read() {
        use epics_base_rs::server::records::ai::AiRecord;
        let mut ads = make_float64_io_intr_adapter();
        let mut rec = AiRecord::new(0.0);
        ads.init(&mut rec).unwrap();
        // SMOO=0.5 from the very first read; ASLO/AOFF identity.
        rec.put_field("SMOO", EpicsValue::Double(0.5)).unwrap();

        push_f64(&ads, 10.0);
        ads.read(&mut rec).unwrap();
        // Primed: VAL = 10, NOT 0*0.5 + 10*0.5 = 5.
        assert_eq!(
            rec.val(),
            Some(EpicsValue::Double(10.0)),
            "first read primes the SMOO filter (no smoothing toward VAL=0)"
        );

        // Now smoothing engages: 10*0.5 + 20*0.5 = 15.
        push_f64(&ads, 20.0);
        ads.read(&mut rec).unwrap();
        assert_eq!(rec.val(), Some(EpicsValue::Double(15.0)));
    }

    /// PR #162: `aai` (array analog input) and `aao` (array analog output)
    /// records use direction-specific DTYPs like `asynFloat64ArrayIn` and
    /// `asynFloat64ArrayOut` that must collapse to the underlying interface
    /// `asynFloat64Array` for the adapter dispatch. Without this the read_op
    /// and write_op matchers (around L383/L417/L489) miss the DTYP and fail
    /// to bind the record.
    #[test]
    fn dtyp_normalize_aai_aao_array_in_out() {
        // Float64 — most common aai/aao pattern.
        assert_eq!(
            normalize_asyn_dtyp("asynFloat64ArrayIn"),
            "asynFloat64Array"
        );
        assert_eq!(
            normalize_asyn_dtyp("asynFloat64ArrayOut"),
            "asynFloat64Array"
        );
        // Int32 family — covers waveform/aai/aao integer variants.
        assert_eq!(normalize_asyn_dtyp("asynInt32ArrayIn"), "asynInt32Array");
        assert_eq!(normalize_asyn_dtyp("asynInt32ArrayOut"), "asynInt32Array");
        // Other widths sanity-check the suffix rule, not exhaustive of C asyn.
        assert_eq!(normalize_asyn_dtyp("asynInt8ArrayIn"), "asynInt8Array");
        assert_eq!(normalize_asyn_dtyp("asynInt16ArrayOut"), "asynInt16Array");
        assert_eq!(normalize_asyn_dtyp("asynInt64ArrayIn"), "asynInt64Array");
        assert_eq!(
            normalize_asyn_dtyp("asynFloat32ArrayOut"),
            "asynFloat32Array"
        );
    }

    #[test]
    fn dtyp_normalize_preserves_non_array_dtyps() {
        // Scalar DTYPs must pass through unchanged — only Array/Octet
        // direction suffixes are stripped.
        assert_eq!(normalize_asyn_dtyp("asynInt32"), "asynInt32");
        assert_eq!(normalize_asyn_dtyp("asynFloat64"), "asynFloat64");
        // Octet read/write direction-specific DTYPs collapse to asynOctet
        // (C EPICS lsi/lso/printf adapter convention).
        assert_eq!(normalize_asyn_dtyp("asynOctetRead"), "asynOctet");
        assert_eq!(normalize_asyn_dtyp("asynOctetWrite"), "asynOctet");
    }
}
