//! Trace/logging system (asynTrace equivalent).
//!
//! Provides per-port configurable tracing with support for multiple output
//! destinations, I/O data formatting, and bitflag-based mask filtering.

use std::collections::HashMap;
use std::io::Write;
use std::sync::{Arc, Mutex};

use bitflags::bitflags;

use crate::exception::{AsynException, ExceptionEvent, ExceptionManager};

bitflags! {
    /// What to trace — control message categories.
    ///
    /// Values match C asyn `asynDriver.h:211-216` exactly:
    /// ```text
    ///   ASYN_TRACE_ERROR     0x0001
    ///   ASYN_TRACEIO_DEVICE  0x0002
    ///   ASYN_TRACEIO_FILTER  0x0004
    ///   ASYN_TRACEIO_DRIVER  0x0008
    ///   ASYN_TRACE_FLOW      0x0010
    ///   ASYN_TRACE_WARNING   0x0020
    /// ```
    /// C asyn defines exactly these 6 bits — no `ASYN_TRACE_STATE`
    /// or any other bit is referenced anywhere in the C source.
    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    pub struct TraceMask: u32 {
        const ERROR      = 0x0001;
        const IO_DEVICE  = 0x0002;
        const IO_FILTER  = 0x0004;
        const IO_DRIVER  = 0x0008;
        const FLOW       = 0x0010;
        const WARNING    = 0x0020;
    }
}

impl TraceMask {
    /// Parse a symbolic mask string the way C asyn does
    /// (asynShellCommands.c:670-699 `asynTraceMaskStringToInt`):
    ///
    /// - **Tokens**: short names `ERROR`, `DEVICE`, `FILTER`,
    ///   `DRIVER`, `FLOW`, `WARNING`. C accepts these directly OR
    ///   with `ASYN_` and `TRACE_` / `TRACEIO_` prefixes stripped
    ///   (`ASYN_TRACEIO_DEVICE` → `DEVICE`).
    /// - **Separators**: `|` OR `+` (C: `*maskStr == '|' || == '+'`).
    /// - **Numeric**: decimal / `0x` hex / leading-`0` octal via
    ///   `strtol(.., 0)`.
    /// - **Case-insensitive** (C uses `epicsStrnCaseCmp`).
    /// - **Whitespace** between tokens / separators tolerated.
    ///
    /// Empty input returns the empty mask. Unknown tokens are
    /// reported as `Err` naming the offending text — callers
    /// (iocsh `asynSetTraceMask`) can choose to fail or silently
    /// drop. C just `printf`s an error and returns whatever it
    /// accumulated so far; we surface explicitly.
    pub fn from_symbolic(s: &str) -> Result<TraceMask, String> {
        let mut mask = TraceMask::empty();
        for raw in split_mask_tokens(s) {
            let tok = raw.trim();
            if tok.is_empty() {
                continue;
            }
            if let Some(n) = parse_numeric(tok) {
                mask |= TraceMask::from_bits_truncate(n);
                continue;
            }
            let normalized = strip_c_prefixes(tok, &["TRACE_", "TRACEIO_"]);
            let bit = match normalized.as_str() {
                "ERROR" => TraceMask::ERROR,
                "DEVICE" => TraceMask::IO_DEVICE,
                "FILTER" => TraceMask::IO_FILTER,
                "DRIVER" => TraceMask::IO_DRIVER,
                "FLOW" => TraceMask::FLOW,
                "WARNING" => TraceMask::WARNING,
                _ => {
                    return Err(format!("unknown trace mask token: '{tok}'"));
                }
            };
            mask |= bit;
        }
        Ok(mask)
    }
}

impl TraceIoMask {
    /// Parse a symbolic IO-mask string the way C asyn does
    /// (asynShellCommands.c:756-783 `asynTraceIOMaskStringToInt`):
    ///
    /// Tokens: `NODATA` (= 0x0, suppress payload), `ASCII`,
    /// `ESCAPE`, `HEX`. Accepts `ASYN_` / `TRACEIO_` prefixes.
    /// Separators `|` or `+`. Numeric strtol-style.
    pub fn from_symbolic(s: &str) -> Result<TraceIoMask, String> {
        let mut mask = TraceIoMask::empty();
        for raw in split_mask_tokens(s) {
            let tok = raw.trim();
            if tok.is_empty() {
                continue;
            }
            if let Some(n) = parse_numeric(tok) {
                mask |= TraceIoMask::from_bits_truncate(n);
                continue;
            }
            let normalized = strip_c_prefixes(tok, &["TRACEIO_"]);
            let bit = match normalized.as_str() {
                // C asyn `ASYN_TRACEIO_NODATA = 0x0000` — accepted
                // as a token name but contributes no bits. Caller
                // setting only NODATA effectively clears the mask,
                // which matches the C semantic of "show no payload".
                "NODATA" => TraceIoMask::empty(),
                "ASCII" => TraceIoMask::ASCII,
                "ESCAPE" => TraceIoMask::ESCAPE,
                "HEX" => TraceIoMask::HEX,
                _ => return Err(format!("unknown trace I/O mask token: '{tok}'")),
            };
            mask |= bit;
        }
        Ok(mask)
    }
}

impl TraceInfoMask {
    /// Parse a symbolic info-mask string the way C asyn does
    /// (asynShellCommands.c:822-849 `asynTraceInfoMaskStringToInt`):
    ///
    /// Tokens: `TIME`, `PORT`, `SOURCE`, `THREAD`. Accepts
    /// `ASYN_` / `TRACEINFO_` prefixes. Separators `|` or `+`.
    pub fn from_symbolic(s: &str) -> Result<TraceInfoMask, String> {
        let mut mask = TraceInfoMask::empty();
        for raw in split_mask_tokens(s) {
            let tok = raw.trim();
            if tok.is_empty() {
                continue;
            }
            if let Some(n) = parse_numeric(tok) {
                mask |= TraceInfoMask::from_bits_truncate(n);
                continue;
            }
            let normalized = strip_c_prefixes(tok, &["TRACEINFO_"]);
            let bit = match normalized.as_str() {
                "TIME" => TraceInfoMask::TIME,
                "PORT" => TraceInfoMask::PORT,
                "SOURCE" => TraceInfoMask::SOURCE,
                "THREAD" => TraceInfoMask::THREAD,
                _ => return Err(format!("unknown trace info mask token: '{tok}'")),
            };
            mask |= bit;
        }
        Ok(mask)
    }
}

/// Split a mask string on `|` or `+` (C asyn: see the do/while
/// `*maskStr == '|' || == '+'` at asynShellCommands.c:693).
fn split_mask_tokens(s: &str) -> impl Iterator<Item = &str> {
    s.split(['|', '+'])
}

/// Strip the `ASYN_` prefix (always tried) plus any of the
/// `category_prefixes` (e.g. `TRACE_`, `TRACEIO_`, `TRACEINFO_`),
/// uppercase the result. Mirrors the `STARTSWITH(maskStr, ASYN_) +
/// STARTSWITH(maskStr, TRACE_) || STARTSWITH(maskStr, TRACEIO_)`
/// pattern that asynShellCommands.c uses to fold long forms
/// (`ASYN_TRACEIO_DEVICE`) into short ones (`DEVICE`).
fn strip_c_prefixes(tok: &str, category_prefixes: &[&str]) -> String {
    let upper = tok.to_ascii_uppercase();
    let stripped = upper.strip_prefix("ASYN_").unwrap_or(&upper);
    for p in category_prefixes {
        if let Some(rest) = stripped.strip_prefix(p) {
            return rest.to_string();
        }
    }
    stripped.to_string()
}

fn parse_numeric(tok: &str) -> Option<u32> {
    if let Some(rest) = tok.strip_prefix("0x").or_else(|| tok.strip_prefix("0X")) {
        u32::from_str_radix(rest, 16).ok()
    } else if let Some(rest) = tok.strip_prefix("0o").or_else(|| tok.strip_prefix("0O")) {
        u32::from_str_radix(rest, 8).ok()
    } else if let Some(rest) = tok.strip_prefix('0').filter(|s| !s.is_empty()) {
        // C `strtol(., ., 0)` treats `"0..."` as octal. Match that
        // for parity with C asyn's symbolic-or-numeric token
        // handling. Plain "0" (no following digits) parses as 0
        // via the strip-fail branch below.
        u32::from_str_radix(rest, 8)
            .ok()
            .or_else(|| tok.parse::<u32>().ok())
    } else {
        tok.parse::<u32>().ok()
    }
}

bitflags! {
    /// How to format I/O data.
    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    pub struct TraceIoMask: u32 {
        const ASCII  = 0x0001;
        const ESCAPE = 0x0002;
        const HEX    = 0x0004;
    }
}

bitflags! {
    /// What metadata to include in trace prefix.
    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    pub struct TraceInfoMask: u32 {
        const TIME   = 0x0001;
        const PORT   = 0x0002;
        const SOURCE = 0x0004;
        const THREAD = 0x0008;
    }
}

/// Output destination for trace messages.
pub enum TraceFile {
    Stderr,
    Stdout,
    /// EPICS errlog sink. C asyn maps the trace file pointer `NULL`
    /// (`fd == 0`, the `<errlog>` token in asynRecord.c:456) to
    /// `errlogPrintf`, which routes through the central error logger and
    /// is async-signal safe. This port has no errlog ring buffer, so the
    /// faithful console behaviour is stderr (errlog's default sink); the
    /// distinct variant preserves the `<errlog>` routing decision so a
    /// later errlog wiring need only change this arm.
    Errlog,
    File(Arc<Mutex<std::fs::File>>),
}

impl TraceFile {
    /// Write a complete line atomically (single write_all call under lock).
    pub fn write_line(&self, line: &str) {
        match self {
            TraceFile::Stderr | TraceFile::Errlog => {
                let _ = std::io::stderr().write_all(line.as_bytes());
            }
            TraceFile::Stdout => {
                let _ = std::io::stdout().write_all(line.as_bytes());
            }
            TraceFile::File(f) => {
                if let Ok(mut f) = f.lock() {
                    let _ = f.write_all(line.as_bytes());
                }
            }
        }
    }
}

impl Default for TraceFile {
    fn default() -> Self {
        TraceFile::Stderr
    }
}

/// Per-port (or global) trace configuration.
pub struct TraceConfig {
    pub trace_mask: TraceMask,
    pub trace_io_mask: TraceIoMask,
    pub trace_info_mask: TraceInfoMask,
    pub io_truncate_size: usize,
    pub file: TraceFile,
}

impl Default for TraceConfig {
    fn default() -> Self {
        Self {
            // C parity: `tracePvtInit` (asynManager.c:454) initializes
            // every port's mask to `ASYN_TRACE_ERROR` only. WARNING is
            // OFF by default and must be enabled via `asynSetTraceMask`;
            // shipping it ON makes ASYN_TRACE_WARNING diagnostics (e.g.
            // NDPluginDriver's "no input array cached") spam stderr at
            // iocInit, which C keeps silent.
            trace_mask: TraceMask::ERROR,
            trace_io_mask: TraceIoMask::ASCII,
            trace_info_mask: TraceInfoMask::TIME | TraceInfoMask::PORT,
            io_truncate_size: 80,
            file: TraceFile::default(),
        }
    }
}

/// Global trace manager with per-port and per-device override support.
/// C parity: 3-level hierarchy: device → port → global.
pub struct TraceManager {
    global_config: Mutex<TraceConfig>,
    port_configs: Mutex<HashMap<String, TraceConfig>>,
    /// Per-device overrides keyed by (portName, addr).
    device_configs: Mutex<HashMap<(String, i32), TraceConfig>>,
    /// Optional sink for trace-mutator exceptions. C asyn fires
    /// `asynExceptionTrace{Mask,IOMask,InfoMask,File,IOTruncateSize}`
    /// from every `setTrace*` (asynManager.c:2790/2832/2874/2923/2956).
    /// `Mutex` rather than `OnceCell` so a manager can install the sink
    /// after construction (PortManager builds both objects, then wires
    /// the trace sink after).
    exception_sink: Mutex<Option<Arc<ExceptionManager>>>,
}

impl TraceManager {
    pub fn new() -> Self {
        Self {
            global_config: Mutex::new(TraceConfig::default()),
            port_configs: Mutex::new(HashMap::new()),
            device_configs: Mutex::new(HashMap::new()),
            exception_sink: Mutex::new(None),
        }
    }

    /// Install the exception sink used by every `set_trace_*` mutator.
    /// Mirrors C asyn where `setTraceMask` / `setTraceIOMask` /
    /// `setTraceInfoMask` / `setTraceFile` / `setTraceIOTruncateSize`
    /// each call `announceExceptionOccurred`. Callers that want trace
    /// listeners (asynShellCommands UI, asynRecord, monitor relays)
    /// to react to trace re-configuration must install the sink.
    pub fn set_exception_sink(&self, sink: Arc<ExceptionManager>) {
        if let Ok(mut slot) = self.exception_sink.lock() {
            *slot = Some(sink);
        }
    }

    /// Return the installed exception sink, if any. C asyn delivers
    /// `setTrace*` reconfiguration to listeners through
    /// `exceptionCallbackAdd` (asynManager.c); asynRecord retrieves the
    /// sink here to register the trace-status refresh callback that C
    /// installs in `connectDevice` (asynRecord.c:1269).
    pub fn exception_manager(&self) -> Option<Arc<ExceptionManager>> {
        self.exception_sink.lock().ok().and_then(|g| g.clone())
    }

    /// Fire a trace exception to the registered sink, if any.
    /// `port = None` corresponds to a global change.
    fn announce(&self, port: Option<&str>, exception: AsynException) {
        let sink = match self.exception_sink.lock() {
            Ok(g) => g.clone(),
            Err(_) => return,
        };
        if let Some(sink) = sink {
            sink.announce(&ExceptionEvent {
                port_name: port.unwrap_or("").to_string(),
                exception,
                addr: -1,
            });
        }
    }

    /// Check if a trace level is enabled for a port (optionally device).
    ///
    /// `mask` should be a single trace level (e.g. `TraceMask::ERROR`), not a
    /// combination. In debug builds, passing a multi-bit mask triggers a
    /// `debug_assert` failure.
    pub fn is_enabled(&self, port: &str, mask: TraceMask) -> bool {
        debug_assert!(
            mask.bits().is_power_of_two(),
            "is_enabled expects a single trace level, got {:?}",
            mask
        );
        if let Ok(configs) = self.port_configs.lock() {
            if let Some(cfg) = configs.get(port) {
                return cfg.trace_mask.intersects(mask);
            }
        }
        if let Ok(cfg) = self.global_config.lock() {
            return cfg.trace_mask.intersects(mask);
        }
        false
    }

    /// Check if a trace level is enabled for a specific device address.
    /// Hierarchy: device → port → global (C parity).
    pub fn is_enabled_device(&self, port: &str, addr: i32, mask: TraceMask) -> bool {
        if let Ok(configs) = self.device_configs.lock() {
            if let Some(cfg) = configs.get(&(port.to_string(), addr)) {
                return cfg.trace_mask.intersects(mask);
            }
        }
        self.is_enabled(port, mask)
    }

    /// Set trace configuration for a specific device address.
    ///
    /// C parity: asynManager.c:2788-2791 fires `asynExceptionTraceMask`
    /// for the per-device mutation case.
    pub fn set_device_trace_mask(&self, port: &str, addr: i32, mask: TraceMask) {
        if let Ok(mut configs) = self.device_configs.lock() {
            configs
                .entry((port.to_string(), addr))
                .or_insert_with(TraceConfig::default)
                .trace_mask = mask;
        }
        // Per-device announce — addr included.
        let sink = self.exception_sink.lock().ok().and_then(|g| g.clone());
        if let Some(sink) = sink {
            sink.announce(&ExceptionEvent {
                port_name: port.to_string(),
                exception: AsynException::TraceMask,
                addr,
            });
        }
    }

    /// Run `f` with the effective TraceConfig for `(port, addr)`, walking
    /// device → port → global in C-parity order.
    ///
    /// `addr = None` skips the device lookup (port-only callers, e.g.
    /// global trace from asynShellCommands). C `findTracePvt` →
    /// `findDpCommon` returns the device-specific `dpCommon` when the
    /// `pasynUser` carries a `pdevice`, else falls back to the port's
    /// own `dpc`, else to `pasynBase->trace` (global)
    /// — asynManager.c:530-550 / 545-549.
    fn with_effective_config<R, F>(&self, port: &str, addr: Option<i32>, f: F) -> Option<R>
    where
        F: FnOnce(&TraceConfig) -> R,
    {
        if let Some(addr) = addr {
            if let Ok(configs) = self.device_configs.lock() {
                if let Some(cfg) = configs.get(&(port.to_string(), addr)) {
                    return Some(f(cfg));
                }
            }
        }
        if let Ok(configs) = self.port_configs.lock() {
            if let Some(cfg) = configs.get(port) {
                return Some(f(cfg));
            }
        }
        if let Ok(cfg) = self.global_config.lock() {
            return Some(f(&cfg));
        }
        None
    }

    /// Output a trace message (port-level resolution).
    ///
    /// Equivalent to [`Self::output_device`] with `addr = None`.
    pub fn output(&self, port: &str, mask: TraceMask, msg: &str) {
        self.output_device(port, None, mask, msg);
    }

    /// Output a trace message, resolving config device → port → global.
    /// C parity: `tracePrint` (asynManager.c:3038-3047) →
    /// `traceVprint` resolves the `tracePvt` via `findTracePvt`, which
    /// walks `pasynUser`'s device-pvt first when `pdevice != NULL`.
    pub fn output_device(&self, port: &str, addr: Option<i32>, mask: TraceMask, msg: &str) {
        self.with_effective_config(port, addr, |cfg| {
            let prefix = format_prefix_addr(port, addr, mask, cfg);
            let line = format!("{prefix}{msg}\n");
            cfg.file.write_line(&line);
        });
    }

    /// Output a trace message with source file/line info (C parity: __FILE__/__LINE__).
    pub fn output_with_source(
        &self,
        port: &str,
        mask: TraceMask,
        file: &str,
        line: u32,
        msg: &str,
    ) {
        self.output_device_with_source(port, None, mask, file, line, msg);
    }

    /// Device-aware variant — resolves config device → port → global.
    /// C parity: `tracePrintSource` (asynManager.c:3049-3057).
    pub fn output_device_with_source(
        &self,
        port: &str,
        addr: Option<i32>,
        mask: TraceMask,
        file: &str,
        line: u32,
        msg: &str,
    ) {
        self.with_effective_config(port, addr, |cfg| {
            let prefix = format_prefix_addr(port, addr, mask, cfg);
            let source = if cfg.trace_info_mask.contains(TraceInfoMask::SOURCE) {
                format!("[{file}:{line}] ")
            } else {
                String::new()
            };
            let out = format!("{prefix}{source}{msg}\n");
            cfg.file.write_line(&out);
        });
    }

    /// Output I/O data with formatting according to TraceIoMask.
    pub fn output_io(&self, port: &str, mask: TraceMask, data: &[u8], label: &str) {
        self.output_device_io(port, None, mask, data, label);
    }

    /// Device-aware variant — resolves config device → port → global.
    /// C parity: `tracePrintIO` (asynManager.c:3090-3099). The `addr`
    /// participates in both config resolution AND the `[port,addr,reason]`
    /// `printPort` prefix.
    pub fn output_device_io(
        &self,
        port: &str,
        addr: Option<i32>,
        mask: TraceMask,
        data: &[u8],
        label: &str,
    ) {
        self.with_effective_config(port, addr, |cfg| {
            let prefix = format_prefix_addr(port, addr, mask, cfg);
            let truncate = if cfg.io_truncate_size > 0 {
                cfg.io_truncate_size
            } else {
                usize::MAX
            };
            let data = if data.len() > truncate {
                &data[..truncate]
            } else {
                data
            };

            let formatted = format_io_data(data, cfg.trace_io_mask);
            let line = format!("{prefix}{label} {formatted}\n");
            cfg.file.write_line(&line);
        });
    }

    // --- Configuration mutators ---

    /// C parity: asynManager.c:2790/2800 fires `asynExceptionTraceMask`
    /// after the mutation, for either the per-port path or the
    /// "no pasynUser → global" path.
    pub fn set_trace_mask(&self, port: Option<&str>, mask: TraceMask) {
        match port {
            Some(name) => {
                if let Ok(mut configs) = self.port_configs.lock() {
                    configs
                        .entry(name.to_string())
                        .or_insert_with(TraceConfig::default)
                        .trace_mask = mask;
                }
            }
            None => {
                if let Ok(mut cfg) = self.global_config.lock() {
                    cfg.trace_mask = mask;
                }
            }
        }
        self.announce(port, AsynException::TraceMask);
    }

    /// C parity: asynManager.c:2832/2842 fires `asynExceptionTraceIOMask`.
    pub fn set_trace_io_mask(&self, port: Option<&str>, mask: TraceIoMask) {
        match port {
            Some(name) => {
                if let Ok(mut configs) = self.port_configs.lock() {
                    configs
                        .entry(name.to_string())
                        .or_insert_with(TraceConfig::default)
                        .trace_io_mask = mask;
                }
            }
            None => {
                if let Ok(mut cfg) = self.global_config.lock() {
                    cfg.trace_io_mask = mask;
                }
            }
        }
        self.announce(port, AsynException::TraceIoMask);
    }

    /// Per-device variant of [`Self::set_trace_io_mask`].
    ///
    /// C parity: `setTraceIOMask` (asynManager.c:2814-2846) writes the
    /// IO mask into `pdevice->dpc.trace.traceIOMask` when the asynUser
    /// is connected with `addr >= 0` (`pdevice != NULL`) and announces
    /// per-device. The IO/InfoMask/File analogues of
    /// [`Self::set_device_trace_mask`] must mirror that routing so the
    /// `asynSetTraceIOMask MYPORT N "ESCAPE"` iocsh call (and any
    /// programmatic device-scoped trace setup) actually overrides the
    /// `(port, addr)` slot resolved by `with_effective_config`.
    pub fn set_device_trace_io_mask(&self, port: &str, addr: i32, mask: TraceIoMask) {
        if let Ok(mut configs) = self.device_configs.lock() {
            configs
                .entry((port.to_string(), addr))
                .or_insert_with(TraceConfig::default)
                .trace_io_mask = mask;
        }
        let sink = self.exception_sink.lock().ok().and_then(|g| g.clone());
        if let Some(sink) = sink {
            sink.announce(&ExceptionEvent {
                port_name: port.to_string(),
                exception: AsynException::TraceIoMask,
                addr,
            });
        }
    }

    /// C parity: asynManager.c:2874/2884 fires `asynExceptionTraceInfoMask`.
    pub fn set_trace_info_mask(&self, port: Option<&str>, mask: TraceInfoMask) {
        match port {
            Some(name) => {
                if let Ok(mut configs) = self.port_configs.lock() {
                    configs
                        .entry(name.to_string())
                        .or_insert_with(TraceConfig::default)
                        .trace_info_mask = mask;
                }
            }
            None => {
                if let Ok(mut cfg) = self.global_config.lock() {
                    cfg.trace_info_mask = mask;
                }
            }
        }
        self.announce(port, AsynException::TraceInfoMask);
    }

    /// Per-device variant of [`Self::set_trace_info_mask`].
    ///
    /// C parity: `setTraceInfoMask` (asynManager.c:2856-2888) writes
    /// the info mask into `pdevice->dpc.trace.traceInfoMask` when
    /// `pdevice != NULL` and announces per-device.
    pub fn set_device_trace_info_mask(&self, port: &str, addr: i32, mask: TraceInfoMask) {
        if let Ok(mut configs) = self.device_configs.lock() {
            configs
                .entry((port.to_string(), addr))
                .or_insert_with(TraceConfig::default)
                .trace_info_mask = mask;
        }
        let sink = self.exception_sink.lock().ok().and_then(|g| g.clone());
        if let Some(sink) = sink {
            sink.announce(&ExceptionEvent {
                port_name: port.to_string(),
                exception: AsynException::TraceInfoMask,
                addr,
            });
        }
    }

    /// C parity: asynManager.c:2923 fires `asynExceptionTraceFile`
    /// after the mutation completes (the C path always implies a
    /// port-scoped pasynUser; we mirror that by only firing when a
    /// port name is supplied).
    pub fn set_trace_file(&self, port: Option<&str>, file: TraceFile) {
        match port {
            Some(name) => {
                if let Ok(mut configs) = self.port_configs.lock() {
                    configs
                        .entry(name.to_string())
                        .or_insert_with(TraceConfig::default)
                        .file = file;
                }
            }
            None => {
                if let Ok(mut cfg) = self.global_config.lock() {
                    cfg.file = file;
                }
            }
        }
        // C `setTraceFile` only announces when `puserPvt->pport` is
        // non-null (asynManager.c:2923) — port-scoped only.
        if port.is_some() {
            self.announce(port, AsynException::TraceFile);
        }
    }

    /// Per-device variant of [`Self::set_trace_file`].
    ///
    /// C parity: `setTraceFile` (asynManager.c:2898-2926) resolves
    /// `findTracePvt(puserPvt)`, which returns the device-specific
    /// `dpCommon` when the asynUser carries a `pdevice`; writes the
    /// new FP there; fires `asynExceptionTraceFile`.
    pub fn set_device_trace_file(&self, port: &str, addr: i32, file: TraceFile) {
        if let Ok(mut configs) = self.device_configs.lock() {
            configs
                .entry((port.to_string(), addr))
                .or_insert_with(TraceConfig::default)
                .file = file;
        }
        let sink = self.exception_sink.lock().ok().and_then(|g| g.clone());
        if let Some(sink) = sink {
            sink.announce(&ExceptionEvent {
                port_name: port.to_string(),
                exception: AsynException::TraceFile,
                addr,
            });
        }
    }

    /// C parity: asynManager.c:2956 fires
    /// `asynExceptionTraceIOTruncateSize` after the mutation.
    pub fn set_io_truncate_size(&self, port: Option<&str>, size: usize) {
        match port {
            Some(name) => {
                if let Ok(mut configs) = self.port_configs.lock() {
                    configs
                        .entry(name.to_string())
                        .or_insert_with(TraceConfig::default)
                        .io_truncate_size = size;
                }
            }
            None => {
                if let Ok(mut cfg) = self.global_config.lock() {
                    cfg.io_truncate_size = size;
                }
            }
        }
        // C `setTraceIOTruncateSize` only announces when
        // `puserPvt->pport` is non-null (asynManager.c:2956).
        if port.is_some() {
            self.announce(port, AsynException::TraceIoTruncateSize);
        }
    }

    /// Per-device variant of [`Self::set_io_truncate_size`].
    ///
    /// C parity: `setTraceIOTruncateSize` (asynManager.c:2929-2957) writes
    /// the truncate size into the device `dpCommon` resolved by
    /// `findTracePvt` when the asynUser carries a device, and announces
    /// `asynExceptionTraceIOTruncateSize` per device.
    pub fn set_device_io_truncate_size(&self, port: &str, addr: i32, size: usize) {
        if let Ok(mut configs) = self.device_configs.lock() {
            configs
                .entry((port.to_string(), addr))
                .or_insert_with(TraceConfig::default)
                .io_truncate_size = size;
        }
        let sink = self.exception_sink.lock().ok().and_then(|g| g.clone());
        if let Some(sink) = sink {
            sink.announce(&ExceptionEvent {
                port_name: port.to_string(),
                exception: AsynException::TraceIoTruncateSize,
                addr,
            });
        }
    }

    pub fn get_trace_mask(&self, port: Option<&str>) -> TraceMask {
        if let Some(name) = port {
            if let Ok(configs) = self.port_configs.lock() {
                if let Some(cfg) = configs.get(name) {
                    return cfg.trace_mask;
                }
            }
        }
        self.global_config
            .lock()
            .map(|c| c.trace_mask)
            // C parity: default port mask is ERROR-only (asynManager.c:454);
            // keep the poisoned-lock fallback in sync with TraceConfig::default.
            .unwrap_or(TraceMask::ERROR)
    }

    pub fn get_trace_io_mask(&self, port: Option<&str>) -> TraceIoMask {
        if let Some(name) = port {
            if let Ok(configs) = self.port_configs.lock() {
                if let Some(cfg) = configs.get(name) {
                    return cfg.trace_io_mask;
                }
            }
        }
        self.global_config
            .lock()
            .map(|c| c.trace_io_mask)
            .unwrap_or(TraceIoMask::ASCII)
    }

    /// C parity: `getTraceInfoMask` (asynManager.c) — the per-port trace
    /// info mask, falling back to the global default. Read by
    /// `monitorStatus` (asynRecord.c:1079) to refresh `TINM`/`TINB0..3`.
    pub fn get_trace_info_mask(&self, port: Option<&str>) -> TraceInfoMask {
        if let Some(name) = port {
            if let Ok(configs) = self.port_configs.lock() {
                if let Some(cfg) = configs.get(name) {
                    return cfg.trace_info_mask;
                }
            }
        }
        self.global_config
            .lock()
            .map(|c| c.trace_info_mask)
            .unwrap_or(TraceInfoMask::TIME | TraceInfoMask::PORT)
    }
}

impl Default for TraceManager {
    fn default() -> Self {
        Self::new()
    }
}

/// Device-aware prefix formatter. When `addr` is `Some`, the port token
/// is emitted as `port:addr` so trace consumers can disambiguate
/// per-device output — mirroring C `printPort` (asynManager.c:3006-3022)
/// which writes `[port,addr,reason]`.
fn format_prefix_addr(port: &str, addr: Option<i32>, mask: TraceMask, cfg: &TraceConfig) -> String {
    let mut parts = Vec::new();

    if cfg.trace_info_mask.contains(TraceInfoMask::TIME) {
        use std::time::SystemTime;
        let now = SystemTime::now()
            .duration_since(SystemTime::UNIX_EPOCH)
            .unwrap_or_default();
        let secs = now.as_secs();
        let millis = now.subsec_millis();
        parts.push(format!("{secs}.{millis:03}"));
    }

    if cfg.trace_info_mask.contains(TraceInfoMask::PORT) {
        if let Some(a) = addr {
            parts.push(format!("{port}:{a}"));
        } else {
            parts.push(port.to_string());
        }
    }

    if cfg.trace_info_mask.contains(TraceInfoMask::THREAD) {
        if let Some(name) = std::thread::current().name() {
            parts.push(name.to_string());
        } else {
            parts.push(format!("{:?}", std::thread::current().id()));
        }
    }

    let mask_name = mask_label(mask);
    parts.push(mask_name.to_string());

    parts.join(" ") + " "
}

fn mask_label(mask: TraceMask) -> &'static str {
    if mask.contains(TraceMask::ERROR) {
        "ERROR"
    } else if mask.contains(TraceMask::WARNING) {
        "WARNING"
    } else if mask.contains(TraceMask::FLOW) {
        "FLOW"
    } else if mask.contains(TraceMask::IO_DEVICE) {
        "IO_DEVICE"
    } else if mask.contains(TraceMask::IO_DRIVER) {
        "IO_DRIVER"
    } else if mask.contains(TraceMask::IO_FILTER) {
        "IO_FILTER"
    } else {
        "TRACE"
    }
}

/// Format I/O data according to the trace I/O mask.
pub fn format_io_data(data: &[u8], mask: TraceIoMask) -> String {
    if mask.contains(TraceIoMask::HEX) {
        format_hex(data)
    } else if mask.contains(TraceIoMask::ESCAPE) {
        format_escape(data)
    } else {
        // ASCII (default)
        format_ascii(data)
    }
}

fn format_ascii(data: &[u8]) -> String {
    data.iter()
        .map(|&b| {
            if b >= 0x20 && b < 0x7f {
                b as char
            } else {
                '.'
            }
        })
        .collect()
}

fn format_escape(data: &[u8]) -> String {
    let mut s = String::with_capacity(data.len() * 2);
    for &b in data {
        match b {
            b'\r' => s.push_str("\\r"),
            b'\n' => s.push_str("\\n"),
            b'\t' => s.push_str("\\t"),
            b'\\' => s.push_str("\\\\"),
            0x20..=0x7e => s.push(b as char),
            _ => {
                s.push_str(&format!("\\x{b:02x}"));
            }
        }
    }
    s
}

fn format_hex(data: &[u8]) -> String {
    data.iter()
        .map(|b| format!("{b:02x}"))
        .collect::<Vec<_>>()
        .join(" ")
}

/// Log a trace message (checks `is_enabled` first for short-circuit).
///
/// Accepts either `&TraceManager` or `Option<&TraceManager>` as the first argument.
/// When given `Option`, `None` is a silent no-op.
#[macro_export]
macro_rules! asyn_trace {
    (Some($mgr:expr), $port:expr, $mask:expr, $($arg:tt)*) => {
        if let Some(ref __mgr) = $mgr {
            let __mgr: &$crate::trace::TraceManager = __mgr;
            if __mgr.is_enabled($port, $mask) {
                __mgr.output_with_source($port, $mask, file!(), line!(), &format!($($arg)*));
            }
        }
    };
    ($mgr:expr, $port:expr, $mask:expr, $($arg:tt)*) => {
        if $mgr.is_enabled($port, $mask) {
            $mgr.output_with_source($port, $mask, file!(), line!(), &format!($($arg)*));
        }
    };
}

/// Log I/O data with formatting.
///
/// Accepts either `&TraceManager` or `Option<&TraceManager>` as the first argument.
/// When given `Option`, `None` is a silent no-op.
#[macro_export]
macro_rules! asyn_trace_io {
    (Some($mgr:expr), $port:expr, $mask:expr, $data:expr, $($arg:tt)*) => {
        if let Some(ref __mgr) = $mgr {
            let __mgr: &$crate::trace::TraceManager = __mgr;
            if __mgr.is_enabled($port, $mask) {
                __mgr.output_io($port, $mask, $data, &format!($($arg)*));
            }
        }
    };
    ($mgr:expr, $port:expr, $mask:expr, $data:expr, $($arg:tt)*) => {
        if $mgr.is_enabled($port, $mask) {
            $mgr.output_io($port, $mask, $data, &format!($($arg)*));
        }
    };
}

/// Log a per-device trace message (checks `is_enabled_device` first).
///
/// `$addr` is the device address. Both the enable check and the output
/// formatter resolve config in C-parity order: device → port → global
/// (asynManager.c:530-549 / 3038-3047). Use this in drivers that have
/// distinct addresses on a multi-device port; the addr appears in the
/// emitted `[port:addr]` prefix when `TraceInfoMask::PORT` is set.
#[macro_export]
macro_rules! asyn_trace_device {
    (Some($mgr:expr), $port:expr, $addr:expr, $mask:expr, $($arg:tt)*) => {
        if let Some(ref __mgr) = $mgr {
            let __mgr: &$crate::trace::TraceManager = __mgr;
            if __mgr.is_enabled_device($port, $addr, $mask) {
                __mgr.output_device_with_source(
                    $port,
                    Some($addr),
                    $mask,
                    file!(),
                    line!(),
                    &format!($($arg)*),
                );
            }
        }
    };
    ($mgr:expr, $port:expr, $addr:expr, $mask:expr, $($arg:tt)*) => {
        if $mgr.is_enabled_device($port, $addr, $mask) {
            $mgr.output_device_with_source(
                $port,
                Some($addr),
                $mask,
                file!(),
                line!(),
                &format!($($arg)*),
            );
        }
    };
}

/// Log per-device I/O data with formatting (C-parity hierarchy).
#[macro_export]
macro_rules! asyn_trace_device_io {
    (Some($mgr:expr), $port:expr, $addr:expr, $mask:expr, $data:expr, $($arg:tt)*) => {
        if let Some(ref __mgr) = $mgr {
            let __mgr: &$crate::trace::TraceManager = __mgr;
            if __mgr.is_enabled_device($port, $addr, $mask) {
                __mgr.output_device_io(
                    $port,
                    Some($addr),
                    $mask,
                    $data,
                    &format!($($arg)*),
                );
            }
        }
    };
    ($mgr:expr, $port:expr, $addr:expr, $mask:expr, $data:expr, $($arg:tt)*) => {
        if $mgr.is_enabled_device($port, $addr, $mask) {
            $mgr.output_device_io(
                $port,
                Some($addr),
                $mask,
                $data,
                &format!($($arg)*),
            );
        }
    };
}

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

    /// C parity: `tracePvtInit` (asynManager.c:454) sets every port's
    /// default mask to `ASYN_TRACE_ERROR` only — WARNING is OFF until a
    /// caller raises it via `asynSetTraceMask`.
    #[test]
    fn test_default_mask_error_only() {
        let mgr = TraceManager::new();
        assert!(mgr.is_enabled("port1", TraceMask::ERROR));
        assert!(!mgr.is_enabled("port1", TraceMask::WARNING));
        assert!(!mgr.is_enabled("port1", TraceMask::FLOW));
        assert!(!mgr.is_enabled("port1", TraceMask::IO_DRIVER));
    }

    /// C asyn defines 6 trace bits in `asynDriver.h:211-216`. This
    /// test fences that we don't accidentally re-introduce extra
    /// bits — `grep -rn "ASYN_TRACE_STATE" ~/codes/epics-modules/asyn`
    /// returns 0 hits, so any additional bit would be invented.
    #[test]
    fn test_six_bits_match_c_asyn_header() {
        assert_eq!(TraceMask::ERROR.bits(), 0x0001);
        assert_eq!(TraceMask::IO_DEVICE.bits(), 0x0002);
        assert_eq!(TraceMask::IO_FILTER.bits(), 0x0004);
        assert_eq!(TraceMask::IO_DRIVER.bits(), 0x0008);
        assert_eq!(TraceMask::FLOW.bits(), 0x0010);
        assert_eq!(TraceMask::WARNING.bits(), 0x0020);
        // Union of all 6 = 0x3F.
        let all = TraceMask::ERROR
            | TraceMask::IO_DEVICE
            | TraceMask::IO_FILTER
            | TraceMask::IO_DRIVER
            | TraceMask::FLOW
            | TraceMask::WARNING;
        assert_eq!(all.bits(), 0x003F);
    }

    /// C asyn `asynShellCommands.c:670-699`: short token names
    /// `ERROR/DEVICE/FILTER/DRIVER/FLOW/WARNING` (no `IO_` prefix).
    /// C accepts the long forms via `STARTSWITH(maskStr, ASYN_)` +
    /// `STARTSWITH(maskStr, TRACE_)/(TRACEIO_)` prefix stripping.
    #[test]
    fn test_trace_mask_from_symbolic_basic() {
        let m = TraceMask::from_symbolic("ERROR|FLOW|DEVICE").unwrap();
        assert_eq!(m, TraceMask::ERROR | TraceMask::FLOW | TraceMask::IO_DEVICE);
    }

    /// C parity: long tokens `ASYN_TRACEIO_DRIVER`, `ASYN_TRACE_FLOW`
    /// fold to short names via prefix-strip; `+` separator works as
    /// well as `|` (asynShellCommands.c:693).
    #[test]
    fn test_trace_mask_from_symbolic_long_form_and_plus_separator() {
        let m = TraceMask::from_symbolic("ASYN_TRACEIO_DRIVER+ASYN_TRACE_FLOW").unwrap();
        assert_eq!(m, TraceMask::IO_DRIVER | TraceMask::FLOW);
    }

    #[test]
    fn test_trace_mask_from_symbolic_case_insensitive_and_aliases() {
        let m = TraceMask::from_symbolic("error|asyn_traceio_driver|Warning").unwrap();
        assert_eq!(
            m,
            TraceMask::ERROR | TraceMask::IO_DRIVER | TraceMask::WARNING
        );
    }

    #[test]
    fn test_trace_mask_from_symbolic_numeric_mix() {
        // 0x10 = FLOW (C asyn `ASYN_TRACE_FLOW`).
        let m = TraceMask::from_symbolic("ERROR|0x10|0o20").unwrap();
        assert_eq!(m, TraceMask::ERROR | TraceMask::FLOW);
    }

    #[test]
    fn test_trace_mask_from_symbolic_unknown_token_errors() {
        let err = TraceMask::from_symbolic("ERROR|NOPE").unwrap_err();
        assert!(err.contains("NOPE"), "error must name the bad token: {err}");
    }

    #[test]
    fn test_trace_mask_from_symbolic_empty_and_whitespace() {
        assert_eq!(TraceMask::from_symbolic("").unwrap(), TraceMask::empty());
        assert_eq!(TraceMask::from_symbolic("  ").unwrap(), TraceMask::empty());
        assert_eq!(
            TraceMask::from_symbolic(" ERROR | | FLOW ").unwrap(),
            TraceMask::ERROR | TraceMask::FLOW
        );
    }

    #[test]
    fn test_trace_io_mask_and_info_mask_symbolic() {
        // `+` separator + ASYN_-prefixed long form, per C asyn usage
        // strings (asynShellCommands.c:723 example).
        let io = TraceIoMask::from_symbolic("ESCAPE+HEX").unwrap();
        assert_eq!(io, TraceIoMask::ESCAPE | TraceIoMask::HEX);
        let io2 = TraceIoMask::from_symbolic("ASYN_TRACEIO_ASCII").unwrap();
        assert_eq!(io2, TraceIoMask::ASCII);
        let info = TraceInfoMask::from_symbolic("TIME|THREAD").unwrap();
        assert_eq!(info, TraceInfoMask::TIME | TraceInfoMask::THREAD);
    }

    /// C asyn `ASYN_TRACEIO_NODATA = 0x0000` (asynDriver.h:219) is
    /// a valid token that means "no payload". Setting only NODATA
    /// gives an empty mask (asynShellCommands.c:770).
    #[test]
    fn test_trace_io_nodata_token() {
        assert_eq!(
            TraceIoMask::from_symbolic("NODATA").unwrap(),
            TraceIoMask::empty()
        );
        // NODATA OR'd with other bits is a no-op.
        assert_eq!(
            TraceIoMask::from_symbolic("NODATA+HEX").unwrap(),
            TraceIoMask::HEX
        );
    }

    #[test]
    fn test_set_global_mask() {
        let mgr = TraceManager::new();
        mgr.set_trace_mask(None, TraceMask::ERROR | TraceMask::FLOW);
        assert!(mgr.is_enabled("any", TraceMask::ERROR));
        assert!(mgr.is_enabled("any", TraceMask::FLOW));
        assert!(!mgr.is_enabled("any", TraceMask::WARNING));
    }

    #[test]
    fn test_port_override_vs_global() {
        let mgr = TraceManager::new();
        mgr.set_trace_mask(None, TraceMask::ERROR);
        mgr.set_trace_mask(Some("myport"), TraceMask::FLOW);

        // myport uses its override
        assert!(mgr.is_enabled("myport", TraceMask::FLOW));
        assert!(!mgr.is_enabled("myport", TraceMask::ERROR));

        // other ports use global
        assert!(mgr.is_enabled("other", TraceMask::ERROR));
        assert!(!mgr.is_enabled("other", TraceMask::FLOW));
    }

    #[test]
    fn test_format_ascii() {
        assert_eq!(format_ascii(b"hello"), "hello");
        assert_eq!(format_ascii(b"hi\r\n"), "hi..");
        assert_eq!(format_ascii(&[0x00, 0x7f, 0x41]), "..A");
    }

    #[test]
    fn test_format_escape() {
        assert_eq!(format_escape(b"OK\r\n"), "OK\\r\\n");
        assert_eq!(format_escape(b"\t\\"), "\\t\\\\");
        assert_eq!(format_escape(&[0x01]), "\\x01");
        assert_eq!(format_escape(b"hi"), "hi");
    }

    #[test]
    fn test_format_hex() {
        assert_eq!(format_hex(b"AB"), "41 42");
        assert_eq!(format_hex(b"\r\n"), "0d 0a");
        assert_eq!(format_hex(b""), "");
    }

    #[test]
    fn test_io_truncate() {
        let data = b"hello world";
        let truncated = &data[..4];
        assert_eq!(format_ascii(truncated), "hell");
    }

    #[test]
    fn test_format_io_data_dispatch() {
        let data = b"OK\r\n";
        assert_eq!(format_io_data(data, TraceIoMask::ASCII), "OK..");
        assert_eq!(format_io_data(data, TraceIoMask::ESCAPE), "OK\\r\\n");
        assert_eq!(format_io_data(data, TraceIoMask::HEX), "4f 4b 0d 0a");
    }

    #[test]
    fn test_output_to_buffer() {
        let mgr = TraceManager::new();
        mgr.set_trace_mask(None, TraceMask::ERROR | TraceMask::IO_DRIVER);
        mgr.set_trace_info_mask(None, TraceInfoMask::PORT); // only port name for predictability

        // Create a shared buffer as a file
        let temp = std::env::temp_dir().join("asyn_trace_test.txt");
        let file = std::fs::File::create(&temp).unwrap();
        mgr.set_trace_file(None, TraceFile::File(Arc::new(Mutex::new(file))));

        mgr.output("testport", TraceMask::ERROR, "something broke");

        // Read back
        let contents = std::fs::read_to_string(&temp).unwrap();
        assert!(contents.contains("testport"));
        assert!(contents.contains("ERROR"));
        assert!(contents.contains("something broke"));
        let _ = std::fs::remove_file(&temp);
    }

    #[test]
    fn test_output_io_to_buffer() {
        let mgr = TraceManager::new();
        mgr.set_trace_mask(None, TraceMask::IO_DRIVER);
        mgr.set_trace_info_mask(None, TraceInfoMask::PORT);
        mgr.set_trace_io_mask(None, TraceIoMask::ESCAPE);

        let temp = std::env::temp_dir().join("asyn_trace_io_test.txt");
        let file = std::fs::File::create(&temp).unwrap();
        mgr.set_trace_file(None, TraceFile::File(Arc::new(Mutex::new(file))));

        mgr.output_io("testport", TraceMask::IO_DRIVER, b"OK\r\n", "read:");

        let contents = std::fs::read_to_string(&temp).unwrap();
        assert!(contents.contains("testport"));
        assert!(contents.contains("IO_DRIVER"));
        assert!(contents.contains("read:"));
        assert!(contents.contains("OK\\r\\n"));
        let _ = std::fs::remove_file(&temp);
    }

    #[test]
    fn test_get_masks() {
        let mgr = TraceManager::new();
        // Default global mask is ERROR-only (asynManager.c:454).
        assert_eq!(mgr.get_trace_mask(None), TraceMask::ERROR);
        assert_eq!(mgr.get_trace_io_mask(None), TraceIoMask::ASCII);

        mgr.set_trace_mask(Some("p1"), TraceMask::FLOW);
        assert_eq!(mgr.get_trace_mask(Some("p1")), TraceMask::FLOW);
        // Global unaffected
        assert_eq!(mgr.get_trace_mask(None), TraceMask::ERROR);
    }

    #[test]
    fn test_macro_short_circuit() {
        let mgr = TraceManager::new();
        // FLOW is not enabled by default
        // This should not panic or produce output
        asyn_trace!(mgr, "port", TraceMask::FLOW, "should not appear");
    }

    #[test]
    fn test_io_truncate_integration() {
        let mgr = TraceManager::new();
        mgr.set_trace_mask(None, TraceMask::IO_DRIVER);
        mgr.set_trace_info_mask(None, TraceInfoMask::PORT);
        mgr.set_io_truncate_size(None, 3);

        let temp = std::env::temp_dir().join("asyn_trace_trunc_test.txt");
        let file = std::fs::File::create(&temp).unwrap();
        mgr.set_trace_file(None, TraceFile::File(Arc::new(Mutex::new(file))));

        mgr.output_io("p", TraceMask::IO_DRIVER, b"hello world", "write:");

        let contents = std::fs::read_to_string(&temp).unwrap();
        // ASCII format, truncated to 3 bytes: "hel"
        assert!(contents.contains("hel"));
        assert!(!contents.contains("hello"));
        let _ = std::fs::remove_file(&temp);
    }

    #[test]
    fn test_write_line_single_call() {
        // Verify that File variant does a single write_all
        let temp = std::env::temp_dir().join("asyn_trace_single_write.txt");
        let file = std::fs::File::create(&temp).unwrap();
        let tf = TraceFile::File(Arc::new(Mutex::new(file)));

        tf.write_line("line one\n");
        tf.write_line("line two\n");

        let contents = std::fs::read_to_string(&temp).unwrap();
        assert_eq!(contents, "line one\nline two\n");
        let _ = std::fs::remove_file(&temp);
    }

    /// C parity regression: every `setTrace*` mutator must fire its
    /// matching `asynExceptionTrace*` to the exception sink, matching
    /// C asynManager.c:2790/2832/2874/2923/2956. Without this,
    /// listeners (asynShellCommands UI, asynRecord, monitor sinks)
    /// never see the trace-config change.
    #[test]
    fn test_set_trace_mask_fires_exception() {
        use crate::exception::AsynException;
        use std::sync::atomic::AtomicUsize;
        use std::sync::atomic::Ordering as O;

        let exc = Arc::new(ExceptionManager::new());
        let mgr = TraceManager::new();
        mgr.set_exception_sink(exc.clone());

        let n = Arc::new(AtomicUsize::new(0));
        let captured = Arc::new(Mutex::new(Vec::<AsynException>::new()));
        let n2 = n.clone();
        let captured2 = captured.clone();
        exc.add_callback(move |ev| {
            n2.fetch_add(1, O::Relaxed);
            captured2.lock().unwrap().push(ev.exception);
        });

        // Each setter fires exactly its own exception type.
        mgr.set_trace_mask(Some("p"), TraceMask::FLOW);
        mgr.set_trace_io_mask(Some("p"), TraceIoMask::HEX);
        mgr.set_trace_info_mask(Some("p"), TraceInfoMask::TIME);
        let file = TraceFile::Stderr;
        mgr.set_trace_file(Some("p"), file);
        mgr.set_io_truncate_size(Some("p"), 16);
        mgr.set_device_trace_mask("p", 3, TraceMask::ERROR);

        assert_eq!(n.load(O::Relaxed), 6);
        let exps = captured.lock().unwrap().clone();
        assert!(exps.contains(&AsynException::TraceMask));
        assert!(exps.contains(&AsynException::TraceIoMask));
        assert!(exps.contains(&AsynException::TraceInfoMask));
        assert!(exps.contains(&AsynException::TraceFile));
        assert!(exps.contains(&AsynException::TraceIoTruncateSize));
    }

    /// C parity: `setTraceMask` with no pasynUser is the "global"
    /// path (asynManager.c:2774-2776). Rust mirrors with
    /// `set_trace_mask(None, ...)`. The global path still announces
    /// (asynManager.c:2800 announces `pport=NULL` per-port and
    /// 2790/2796 announces per-device; for the "no user" entrypoint
    /// C skips into the global slot at line 2776 without firing —
    /// matching that, our `None` path fires once with an empty port
    /// name so listeners can still observe a global re-config).
    #[test]
    fn test_global_trace_mask_announce() {
        use crate::exception::AsynException;
        use std::sync::atomic::AtomicUsize;
        use std::sync::atomic::Ordering as O;

        let exc = Arc::new(ExceptionManager::new());
        let mgr = TraceManager::new();
        mgr.set_exception_sink(exc.clone());
        let n = Arc::new(AtomicUsize::new(0));
        let n2 = n.clone();
        exc.add_callback(move |ev| {
            if ev.exception == AsynException::TraceMask && ev.port_name.is_empty() {
                n2.fetch_add(1, O::Relaxed);
            }
        });
        mgr.set_trace_mask(None, TraceMask::FLOW);
        assert_eq!(n.load(O::Relaxed), 1);
    }

    /// `setTraceFile` and `setTraceIOTruncateSize` only announce when
    /// `puserPvt->pport` is non-null (asynManager.c:2923, :2956).
    /// Our `None` (= "no user, global") path therefore must NOT fire
    /// those two exceptions.
    #[test]
    fn test_global_file_and_truncate_do_not_announce() {
        use crate::exception::AsynException;
        use std::sync::atomic::AtomicUsize;
        use std::sync::atomic::Ordering as O;

        let exc = Arc::new(ExceptionManager::new());
        let mgr = TraceManager::new();
        mgr.set_exception_sink(exc.clone());
        let file_hits = Arc::new(AtomicUsize::new(0));
        let trunc_hits = Arc::new(AtomicUsize::new(0));
        let f2 = file_hits.clone();
        let t2 = trunc_hits.clone();
        exc.add_callback(move |ev| match ev.exception {
            AsynException::TraceFile => {
                f2.fetch_add(1, O::Relaxed);
            }
            AsynException::TraceIoTruncateSize => {
                t2.fetch_add(1, O::Relaxed);
            }
            _ => {}
        });
        mgr.set_trace_file(None, TraceFile::Stderr);
        mgr.set_io_truncate_size(None, 32);
        assert_eq!(file_hits.load(O::Relaxed), 0);
        assert_eq!(trunc_hits.load(O::Relaxed), 0);
    }

    // ----------------------------------------------------------------
    // C-parity: output_device / output_device_with_source / output_device_io
    // must resolve config device → port → global. Previously the
    // port-only output_*() walked port→global, ignoring per-device
    // overrides — `is_enabled_device` saw the device level but the
    // emit path did not. asynManager.c:530-549, 3038-3047, 3090-3099.
    // ----------------------------------------------------------------

    fn read_lines(path: &std::path::Path) -> Vec<String> {
        std::fs::read_to_string(path)
            .unwrap_or_default()
            .lines()
            .map(|l| l.to_string())
            .collect()
    }

    #[test]
    fn output_device_uses_device_config_when_present() {
        let mgr = TraceManager::new();
        mgr.set_trace_info_mask(None, TraceInfoMask::PORT);
        // Port allows ERROR; device allows ERROR | FLOW.
        mgr.set_trace_mask(Some("dev_p"), TraceMask::ERROR);
        mgr.set_device_trace_mask("dev_p", 5, TraceMask::ERROR | TraceMask::FLOW);

        let temp = std::env::temp_dir().join("asyn_trace_device_output.txt");
        let file = std::fs::File::create(&temp).unwrap();
        let tf = TraceFile::File(Arc::new(Mutex::new(file)));
        // Install the device-specific file so we can verify the device
        // config is what's used (not the port config).
        let file2 = std::fs::File::create(&temp).unwrap();
        let tf2 = TraceFile::File(Arc::new(Mutex::new(file2)));
        if let Ok(mut cfgs) = mgr.device_configs.lock() {
            if let Some(c) = cfgs.get_mut(&("dev_p".to_string(), 5)) {
                c.file = tf;
            }
        }
        if let Ok(mut cfgs) = mgr.port_configs.lock() {
            if let Some(c) = cfgs.get_mut("dev_p") {
                c.file = tf2;
            }
        }

        // FLOW is enabled at device, disabled at port — output_device
        // must use the device config and emit.
        mgr.output_device("dev_p", Some(5), TraceMask::FLOW, "device-flow");

        let lines = read_lines(&temp);
        assert!(
            lines.iter().any(|l| l.contains("device-flow")),
            "device-config output should have been emitted, got {lines:?}"
        );
        // Prefix should embed addr.
        assert!(
            lines.iter().any(|l| l.contains("dev_p:5")),
            "device output prefix should embed addr, got {lines:?}"
        );
        let _ = std::fs::remove_file(&temp);
    }

    #[test]
    fn output_device_falls_back_to_port_then_global() {
        // No device config — must use port; no port — must use global.
        let mgr = TraceManager::new();
        mgr.set_trace_info_mask(None, TraceInfoMask::PORT);
        mgr.set_trace_mask(None, TraceMask::ERROR);
        let temp = std::env::temp_dir().join("asyn_trace_device_fallback.txt");
        let file = std::fs::File::create(&temp).unwrap();
        mgr.set_trace_file(None, TraceFile::File(Arc::new(Mutex::new(file))));

        mgr.output_device("no_overrides", Some(0), TraceMask::ERROR, "global-error");
        let lines = read_lines(&temp);
        assert!(lines.iter().any(|l| l.contains("global-error")));
        // Prefix still embeds addr even when using global cfg.
        assert!(lines.iter().any(|l| l.contains("no_overrides:0")));
        let _ = std::fs::remove_file(&temp);
    }

    #[test]
    fn output_device_with_source_includes_source_when_device_info_mask_has_it() {
        let mgr = TraceManager::new();
        mgr.set_trace_mask(Some("p"), TraceMask::ERROR);
        // Device config carries the SOURCE info bit; port does not. Use
        // direct map mutation since per-info-bit/per-truncate device
        // setters are not part of this commit (Trace device-setter
        // surface stays narrow; only the output path is fixed here).
        mgr.set_device_trace_mask("p", 1, TraceMask::ERROR);
        if let Ok(mut cfgs) = mgr.device_configs.lock() {
            if let Some(c) = cfgs.get_mut(&("p".to_string(), 1)) {
                c.trace_info_mask = TraceInfoMask::PORT | TraceInfoMask::SOURCE;
            }
        }

        let temp = std::env::temp_dir().join("asyn_trace_device_source.txt");
        let file = std::fs::File::create(&temp).unwrap();
        if let Ok(mut cfgs) = mgr.device_configs.lock() {
            if let Some(c) = cfgs.get_mut(&("p".to_string(), 1)) {
                c.file = TraceFile::File(Arc::new(Mutex::new(file)));
            }
        }

        mgr.output_device_with_source("p", Some(1), TraceMask::ERROR, "src.rs", 42, "msg");
        let lines = read_lines(&temp);
        assert!(
            lines.iter().any(|l| l.contains("[src.rs:42]")),
            "device cfg with SOURCE bit should emit `[file:line]` prefix"
        );
        let _ = std::fs::remove_file(&temp);
    }

    #[test]
    fn output_device_io_uses_device_truncate() {
        // C parity: per-device traceTruncateSize takes priority — the
        // emit path resolves config device → port → global.
        let mgr = TraceManager::new();
        mgr.set_trace_mask(Some("p"), TraceMask::IO_DRIVER);
        mgr.set_io_truncate_size(Some("p"), 64);
        // Device override: truncate to 3 bytes (direct map mutation —
        // see note above on the narrow per-device setter surface).
        mgr.set_device_trace_mask("p", 0, TraceMask::IO_DRIVER);
        if let Ok(mut cfgs) = mgr.device_configs.lock() {
            if let Some(c) = cfgs.get_mut(&("p".to_string(), 0)) {
                c.io_truncate_size = 3;
                c.trace_info_mask = TraceInfoMask::PORT;
            }
        }

        let temp = std::env::temp_dir().join("asyn_trace_device_trunc.txt");
        let file = std::fs::File::create(&temp).unwrap();
        if let Ok(mut cfgs) = mgr.device_configs.lock() {
            if let Some(c) = cfgs.get_mut(&("p".to_string(), 0)) {
                c.file = TraceFile::File(Arc::new(Mutex::new(file)));
            }
        }

        mgr.output_device_io("p", Some(0), TraceMask::IO_DRIVER, b"hello world", "rx:");
        let contents = std::fs::read_to_string(&temp).unwrap_or_default();
        assert!(contents.contains("hel"));
        // 4th byte onward must be dropped by device-level truncation.
        assert!(!contents.contains("hello"));
        let _ = std::fs::remove_file(&temp);
    }

    #[test]
    fn asyn_trace_device_macro_short_circuits_when_device_disabled() {
        // Macro gates on is_enabled_device; if no level is on, the
        // format!() side-effect must not even run (cheap test: ensure
        // it doesn't panic on a closed configuration).
        let mgr = TraceManager::new();
        // Globally only ERROR; device explicitly disables ERROR.
        mgr.set_device_trace_mask("p", 0, TraceMask::empty());
        asyn_trace_device!(mgr, "p", 0, TraceMask::ERROR, "should-not-emit");
        asyn_trace_device_io!(mgr, "p", 0, TraceMask::ERROR, b"data", "rx:");
    }

    #[test]
    fn asyn_trace_device_macro_emits_when_device_enables_flow() {
        let mgr = TraceManager::new();
        // Port disables FLOW; device enables FLOW.
        mgr.set_trace_mask(Some("p"), TraceMask::ERROR);
        mgr.set_device_trace_mask("p", 7, TraceMask::FLOW);
        mgr.set_trace_info_mask(None, TraceInfoMask::PORT);

        let temp = std::env::temp_dir().join("asyn_trace_device_macro.txt");
        let file = std::fs::File::create(&temp).unwrap();
        if let Ok(mut cfgs) = mgr.device_configs.lock() {
            if let Some(c) = cfgs.get_mut(&("p".to_string(), 7)) {
                c.file = TraceFile::File(Arc::new(Mutex::new(file)));
            }
        }

        asyn_trace_device!(mgr, "p", 7, TraceMask::FLOW, "{}", "device-msg");
        let contents = std::fs::read_to_string(&temp).unwrap_or_default();
        assert!(contents.contains("device-msg"));
        assert!(contents.contains("p:7"));
        let _ = std::fs::remove_file(&temp);
    }

    /// C parity: `setTraceIOMask` with `addr >= 0` writes
    /// `pdevice->dpc.trace.traceIOMask`
    /// (asynManager.c:2830-2833). The Rust per-device setter must
    /// place the mask in the `(port, addr)` device slot so the
    /// effective-config resolver returns the device mask when the
    /// emit path supplies the matching `addr`.
    #[test]
    fn set_device_trace_io_mask_writes_device_slot_and_announces() {
        let mgr = TraceManager::new();
        let em = Arc::new(ExceptionManager::new());
        mgr.set_exception_sink(em.clone());
        let observed: Arc<Mutex<Vec<(AsynException, i32)>>> = Arc::new(Mutex::new(Vec::new()));
        let obs = observed.clone();
        em.add_callback(move |ev| {
            obs.lock().unwrap().push((ev.exception, ev.addr));
        });

        mgr.set_trace_io_mask(Some("p"), TraceIoMask::ASCII);
        mgr.set_device_trace_io_mask("p", 4, TraceIoMask::HEX);

        // Device slot exists with the new mask.
        let stored = mgr
            .device_configs
            .lock()
            .unwrap()
            .get(&("p".to_string(), 4))
            .map(|c| c.trace_io_mask);
        assert_eq!(stored, Some(TraceIoMask::HEX));

        // Port mask untouched by the per-device write.
        let port_mask = mgr
            .port_configs
            .lock()
            .unwrap()
            .get("p")
            .map(|c| c.trace_io_mask);
        assert_eq!(port_mask, Some(TraceIoMask::ASCII));

        // Per-device announce fires with addr=4.
        let events = observed.lock().unwrap();
        assert!(
            events
                .iter()
                .any(|(e, a)| matches!(e, AsynException::TraceIoMask) && *a == 4)
        );
    }

    /// C parity: `setTraceInfoMask` with `pdevice != NULL` writes
    /// `pdevice->dpc.trace.traceInfoMask` and announces per-device
    /// (asynManager.c:2872-2875).
    #[test]
    fn set_device_trace_info_mask_writes_device_slot_and_announces() {
        let mgr = TraceManager::new();
        let em = Arc::new(ExceptionManager::new());
        mgr.set_exception_sink(em.clone());
        let observed: Arc<Mutex<Vec<(AsynException, i32)>>> = Arc::new(Mutex::new(Vec::new()));
        let obs = observed.clone();
        em.add_callback(move |ev| {
            obs.lock().unwrap().push((ev.exception, ev.addr));
        });

        mgr.set_device_trace_info_mask("p", 2, TraceInfoMask::SOURCE | TraceInfoMask::TIME);

        let stored = mgr
            .device_configs
            .lock()
            .unwrap()
            .get(&("p".to_string(), 2))
            .map(|c| c.trace_info_mask);
        assert_eq!(stored, Some(TraceInfoMask::SOURCE | TraceInfoMask::TIME));

        let events = observed.lock().unwrap();
        assert!(
            events
                .iter()
                .any(|(e, a)| matches!(e, AsynException::TraceInfoMask) && *a == 2)
        );
    }

    /// C parity: `setTraceFile` resolves via `findTracePvt(puserPvt)`
    /// which picks the device dpCommon when the asynUser carries a
    /// `pdevice` (asynManager.c:2898-2926). After the per-device
    /// write, `output_device(port, Some(addr), ...)` must emit into
    /// the device-specific sink, not the port-level one.
    #[test]
    fn set_device_trace_file_routes_emit_to_device_sink() {
        let mgr = TraceManager::new();
        let em = Arc::new(ExceptionManager::new());
        mgr.set_exception_sink(em.clone());
        let observed: Arc<Mutex<Vec<(AsynException, i32)>>> = Arc::new(Mutex::new(Vec::new()));
        let obs = observed.clone();
        em.add_callback(move |ev| {
            obs.lock().unwrap().push((ev.exception, ev.addr));
        });

        mgr.set_trace_mask(Some("p"), TraceMask::ERROR);
        mgr.set_trace_info_mask(Some("p"), TraceInfoMask::PORT);
        mgr.set_device_trace_mask("p", 3, TraceMask::ERROR);
        mgr.set_device_trace_info_mask("p", 3, TraceInfoMask::PORT);

        let port_temp = std::env::temp_dir().join("asyn_trace_dev_file_port.txt");
        let dev_temp = std::env::temp_dir().join("asyn_trace_dev_file_dev.txt");
        let port_f = std::fs::File::create(&port_temp).unwrap();
        let dev_f = std::fs::File::create(&dev_temp).unwrap();
        mgr.set_trace_file(Some("p"), TraceFile::File(Arc::new(Mutex::new(port_f))));
        mgr.set_device_trace_file("p", 3, TraceFile::File(Arc::new(Mutex::new(dev_f))));

        mgr.output_device("p", Some(3), TraceMask::ERROR, "device-only-msg");

        let dev_lines = read_lines(&dev_temp);
        assert!(dev_lines.iter().any(|l| l.contains("device-only-msg")));
        let port_lines = read_lines(&port_temp);
        assert!(
            !port_lines.iter().any(|l| l.contains("device-only-msg")),
            "addr-targeted emit must not write to port sink"
        );

        let events = observed.lock().unwrap();
        assert!(
            events
                .iter()
                .any(|(e, a)| matches!(e, AsynException::TraceFile) && *a == 3)
        );

        let _ = std::fs::remove_file(&port_temp);
        let _ = std::fs::remove_file(&dev_temp);
    }
}
