//! LLRP (Low Level Reader Protocol) reader abstraction.
//!
//! Wraps the Impinj LTKC (LLRP Tool Kit C) library behind a Zig-friendly
//! `Reader` struct.  All network I/O is blocking; timeouts are specified in
//! milliseconds and embedded in each call.

const std = @import("std");

// Re-export `c` so callers can reference LLRP types and printf without a
// second @cImport.
pub const c = @cImport({
    @cInclude("stdio.h");
    @cInclude("ltkc.h");
    @cInclude("impinj_ltkc.h");
});

pub const Error = error{
    LlrpFailed,
    /// Returned by `tryReceiveReport` when no message arrived within the
    /// timeout window.  This is not a fatal condition; callers can simply
    /// continue the loop.
    Timeout,
};

// ---------------------------------------------------------------------------
// Observation callback
// ---------------------------------------------------------------------------

/// One tag observation extracted from an RO_ACCESS_REPORT.
pub const TagObservation = struct {
    /// Zero-padded EPC bytes.  Valid bytes are `epc[0..epc_len]`.
    epc: [32]u8,
    epc_len: u8,
    antenna: u16,
    rssi_dbm: i8,
    /// UTC microseconds from the reader's first-seen timestamp.
    /// Falls back to the local wall clock if the reader omits the field.
    timestamp_us: u64,
};

/// Callback invoked once per tag in each RO_ACCESS_REPORT.
pub const ObserveCallback = struct {
    context: *anyopaque,
    func: *const fn (context: *anyopaque, obs: TagObservation) void,

    pub fn call(self: ObserveCallback, obs: TagObservation) void {
        self.func(self.context, obs);
    }
};

// ---------------------------------------------------------------------------
// GPI event callback
// ---------------------------------------------------------------------------

/// One GPI (General Purpose Input) state-change event from the reader.
pub const GpiEvent = struct {
    /// GPI port number (1-based, as reported by the reader).
    port: u16,
    /// `true`  = high / asserted;
    /// `false` = low  / released.
    state: bool,
    /// UTC microseconds (reader clock + NTP offset).
    timestamp_us: u64,
};

/// Callback invoked on each LLRP GPI state-change event.
/// Pass `null` to `tryReceiveReport` for a built-in log-only handler.
pub const GpiCallback = struct {
    context: *anyopaque,
    func: *const fn (context: *anyopaque, event: GpiEvent) void,

    pub fn call(self: GpiCallback, event: GpiEvent) void {
        self.func(self.context, event);
    }
};

// ---------------------------------------------------------------------------
// Reader info
// ---------------------------------------------------------------------------

/// Static information retrieved from the reader at startup.
pub const ReaderInfo = struct {
    /// IANA Private Enterprise Number (e.g. 25882 = Impinj).
    manufacturer_pen: u32,
    /// Vendor-specific model code.
    model: u32,
    /// Null-terminated firmware version string.
    firmware: [64:0]u8,
    /// 128-bit UUID derived from the reader's hardware identifier.
    /// Layout: bytes 0–7 = 0x00 (padding); bytes 8–15 = EUI-64.
    /// MAC addresses (6 bytes) are expanded to EUI-64 via the standard
    /// IEEE FF:FE insertion (bytes 2–3 become 0xFF 0xFE).
    /// Format as: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX.
    reader_id: [16]u8,
};

// ---------------------------------------------------------------------------
// Reader
// ---------------------------------------------------------------------------

/// An active connection to an LLRP reader.
///
/// Typical lifecycle:
///   1. `var reader = try Reader.init(hostname, verbose);`
///   2. `defer reader.deinit();`
///   3. `try reader.checkConnectionStatus();`
///   4. `try reader.scrubConfiguration();`
///   5. configure + run inventory loop
///   6. `try reader.scrubConfiguration();`  (best-effort)
///   …deinit() called by defer
pub const Reader = struct {
    pConn: [*c]c.LLRP_tSConnection,
    pTypeRegistry: [*c]c.LLRP_tSTypeRegistry,
    verbose: c_int,
    /// Offset (µs) added to all reader-reported timestamps to correct for
    /// drift between the reader's clock and NTP UTC.
    /// Set by the caller after `init`; defaults to 0 (no correction).
    clock_offset_us: i64 = 0,

    // -----------------------------------------------------------------------
    // Lifecycle
    // -----------------------------------------------------------------------

    /// Allocate a type registry, construct the connection object, and open a
    /// TCP socket to `hostname` (standard LLRP port 5084).
    /// Call `checkConnectionStatus` immediately after to verify the reader
    /// accepted the connection.
    pub fn init(hostname: [*:0]const u8, verbose: c_int) Error!Reader {
        const pTypeRegistry = c.LLRP_getTheTypeRegistry();
        if (pTypeRegistry == null) {
            _ = c.printf("ERROR: getTheTypeRegistry failed\n");
            return Error.LlrpFailed;
        }
        c.LLRP_enrollImpinjTypesIntoRegistry(pTypeRegistry);

        const pConn = c.LLRP_Conn_construct(pTypeRegistry, 32 * 1024);
        if (pConn == null) {
            _ = c.printf("ERROR: Conn_construct failed\n");
            c.LLRP_TypeRegistry_destruct(pTypeRegistry);
            return Error.LlrpFailed;
        }

        if (verbose != 0) _ = c.printf("INFO: Connecting to %s....\n", hostname);

        const rc = c.LLRP_Conn_openConnectionToReader(pConn, hostname);
        if (rc != 0) {
            _ = c.printf("ERROR: connect: %s (%d)\n", pConn[0].pConnectErrorStr, rc);
            c.LLRP_Conn_destruct(pConn);
            c.LLRP_TypeRegistry_destruct(pTypeRegistry);
            return Error.LlrpFailed;
        }

        if (verbose != 0) _ = c.printf("INFO: Connected, checking status....\n");

        return .{
            .pConn = pConn,
            .pTypeRegistry = pTypeRegistry,
            .verbose = verbose,
        };
    }

    /// Close the TCP connection and release all library resources.
    pub fn deinit(self: *Reader) void {
        _ = c.LLRP_Conn_closeConnectionToReader(self.pConn);
        c.LLRP_Conn_destruct(self.pConn);
        c.LLRP_TypeRegistry_destruct(self.pTypeRegistry);
    }

    // -----------------------------------------------------------------------
    // Setup / teardown
    // -----------------------------------------------------------------------

    /// Await and validate the initial READER_EVENT_NOTIFICATION the reader
    /// sends immediately upon accepting the connection.
    pub fn checkConnectionStatus(self: *Reader) Error!void {
        const pMessage = self.recvMessage(10000) orelse return Error.LlrpFailed;
        defer freeMessage(pMessage);

        if (&c.LLRP_tdREADER_EVENT_NOTIFICATION != pMessage.elementHdr.pType) {
            _ = c.printf("ERROR: checkConnectionStatus: unexpected message type\n");
            return Error.LlrpFailed;
        }

        const pNtf: *c.LLRP_tSREADER_EVENT_NOTIFICATION = @ptrCast(pMessage);
        const pNtfData = pNtf.pReaderEventNotificationData;
        if (pNtfData == null) {
            _ = c.printf("ERROR: checkConnectionStatus: missing notification data\n");
            return Error.LlrpFailed;
        }

        const pEvent = pNtfData[0].pConnectionAttemptEvent;
        if (pEvent == null) {
            _ = c.printf("ERROR: checkConnectionStatus: missing ConnectionAttemptEvent\n");
            return Error.LlrpFailed;
        }

        if (c.LLRP_ConnectionAttemptStatusType_Success != pEvent[0].eStatus) {
            _ = c.printf("ERROR: checkConnectionStatus: connection attempt failed\n");
            return Error.LlrpFailed;
        }

        if (self.verbose != 0) _ = c.printf("INFO: Connection status OK\n");
    }

    /// Query the reader's static identity: manufacturer PEN, model code,
    /// firmware version, and unique reader ID (typically a MAC address).
    /// Call after `checkConnectionStatus` and before `scrubConfiguration`.
    pub fn queryReaderInfo(self: *Reader) Error!ReaderInfo {
        var info = std.mem.zeroes(ReaderInfo);

        // --- GET_READER_CAPABILITIES (General_Device_Capabilities) ---
        var CapCmd = std.mem.zeroes(c.LLRP_tSGET_READER_CAPABILITIES);
        CapCmd.hdr.elementHdr.pType = &c.LLRP_tdGET_READER_CAPABILITIES;
        CapCmd.hdr.MessageID = 104;
        CapCmd.eRequestedData =
            c.LLRP_GetReaderCapabilitiesRequestedData_General_Device_Capabilities;

        const pCapRspMsg = self.transact(&CapCmd.hdr) orelse return Error.LlrpFailed;
        defer freeMessage(pCapRspMsg);

        const pCapRsp: *c.LLRP_tSGET_READER_CAPABILITIES_RESPONSE = @ptrCast(pCapRspMsg);
        try checkLLRPStatus(pCapRsp.pLLRPStatus, "queryReaderInfo(capabilities)");

        if (pCapRsp.pGeneralDeviceCapabilities == null) {
            _ = c.printf("ERROR: queryReaderInfo: missing GeneralDeviceCapabilities\n");
            return Error.LlrpFailed;
        }
        const pCap = pCapRsp.pGeneralDeviceCapabilities;
        info.manufacturer_pen = pCap[0].DeviceManufacturerName;
        info.model = pCap[0].ModelName;

        const fw = pCap[0].ReaderFirmwareVersion;
        const fw_len: u8 = @intCast(@min(fw.nValue, 63));
        if (fw.pValue != null)
            @memcpy(info.firmware[0..fw_len], @as([*]const u8, @ptrCast(fw.pValue))[0..fw_len]);
        info.firmware[fw_len] = 0;

        // --- GET_READER_CONFIG (Identification) ---
        var CfgCmd = std.mem.zeroes(c.LLRP_tSGET_READER_CONFIG);
        CfgCmd.hdr.elementHdr.pType = &c.LLRP_tdGET_READER_CONFIG;
        CfgCmd.hdr.MessageID = 105;
        CfgCmd.AntennaID = 0;
        CfgCmd.eRequestedData = c.LLRP_GetReaderConfigRequestedData_Identification;

        const pCfgRspMsg = self.transact(&CfgCmd.hdr) orelse return Error.LlrpFailed;
        defer freeMessage(pCfgRspMsg);

        const pCfgRsp: *c.LLRP_tSGET_READER_CONFIG_RESPONSE = @ptrCast(pCfgRspMsg);
        try checkLLRPStatus(pCfgRsp.pLLRPStatus, "queryReaderInfo(config)");

        if (pCfgRsp.pIdentification != null) {
            const pId = pCfgRsp.pIdentification;
            const rid = pId[0].ReaderID;
            const rid_len: usize = @min(rid.nValue, 8);
            if (rid.pValue != null) {
                const raw: [*]const u8 = @ptrCast(rid.pValue);
                if (rid_len == 6) {
                    // Expand MAC (EUI-48) to EUI-64 by inserting 0xFF 0xFE
                    // between bytes 2 and 3 (IEEE standard expansion).
                    info.reader_id[8]  = raw[0];
                    info.reader_id[9]  = raw[1];
                    info.reader_id[10] = raw[2];
                    info.reader_id[11] = 0xFF;
                    info.reader_id[12] = 0xFE;
                    info.reader_id[13] = raw[3];
                    info.reader_id[14] = raw[4];
                    info.reader_id[15] = raw[5];
                } else {
                    // EUI-64 or other: copy directly into the node field.
                    @memcpy(info.reader_id[8..8 + rid_len], raw[0..rid_len]);
                }
            }
        }

        return info;
    }

    /// Reset the reader to factory defaults and delete all ROSpecs.
    pub fn scrubConfiguration(self: *Reader) Error!void {
        try self.resetConfigurationToFactoryDefaults();
        try self.deleteAllROSpecs();
    }

    // -----------------------------------------------------------------------
    // Configuration
    // -----------------------------------------------------------------------

    /// Send IMPINJ_ENABLE_EXTENSIONS to unlock Impinj-specific parameters.
    pub fn enableImpinjExtensions(self: *Reader) Error!void {
        var Cmd = std.mem.zeroes(c.LLRP_tSIMPINJ_ENABLE_EXTENSIONS);
        Cmd.hdr.elementHdr.pType = &c.LLRP_tdIMPINJ_ENABLE_EXTENSIONS;
        Cmd.hdr.MessageID = 103;

        const pRspMsg = self.transact(&Cmd.hdr) orelse return Error.LlrpFailed;
        defer freeMessage(pRspMsg);

        const pRsp: *c.LLRP_tSIMPINJ_ENABLE_EXTENSIONS_RESPONSE = @ptrCast(pRspMsg);
        try checkLLRPStatus(pRsp.pLLRPStatus, "enableImpinjExtensions");

        if (self.verbose != 0) _ = c.printf("INFO: Impinj extensions enabled\n");
    }

    /// Add ROSpec ID 123:
    ///   - Command-triggered start (Null start trigger)
    ///   - Null ROSpec/AISpec stop triggers — inventories until `stopROSpec`
    ///   - C1G2 Session 0 on all antennas
    ///   - One RO_ACCESS_REPORT per tag, including AntennaID and PeakRSSI
    pub fn addROSpec(self: *Reader) Error!void {
        var ROSpecStartTrigger = std.mem.zeroes(c.LLRP_tSROSpecStartTrigger);
        ROSpecStartTrigger.hdr.elementHdr.pType = &c.LLRP_tdROSpecStartTrigger;
        ROSpecStartTrigger.eROSpecStartTriggerType = c.LLRP_ROSpecStartTriggerType_Null;

        var ROSpecStopTrigger = std.mem.zeroes(c.LLRP_tSROSpecStopTrigger);
        ROSpecStopTrigger.hdr.elementHdr.pType = &c.LLRP_tdROSpecStopTrigger;
        ROSpecStopTrigger.eROSpecStopTriggerType = c.LLRP_ROSpecStopTriggerType_Null;
        ROSpecStopTrigger.DurationTriggerValue = 0;

        var ROBoundarySpec = std.mem.zeroes(c.LLRP_tSROBoundarySpec);
        ROBoundarySpec.hdr.elementHdr.pType = &c.LLRP_tdROBoundarySpec;
        ROBoundarySpec.pROSpecStartTrigger = &ROSpecStartTrigger;
        ROBoundarySpec.pROSpecStopTrigger = &ROSpecStopTrigger;

        // AntennaID 0 means "all antennas".
        var AntennaIDs = [_]c.llrp_u16_t{0};

        // Null stop trigger keeps the AISpec running indefinitely; the reader
        // continuously inventories until explicitly stopped via STOP_ROSPEC.
        var AISpecStopTrigger = std.mem.zeroes(c.LLRP_tSAISpecStopTrigger);
        AISpecStopTrigger.hdr.elementHdr.pType = &c.LLRP_tdAISpecStopTrigger;
        AISpecStopTrigger.eAISpecStopTriggerType = c.LLRP_AISpecStopTriggerType_Null;
        AISpecStopTrigger.DurationTrigger = 0;

        // Session 0: tags respond on every inventory round (no state machine
        // advancement).  TagPopulation is a hint for the reader's Q algorithm.
        var C1G2SingulationControl = std.mem.zeroes(c.LLRP_tSC1G2SingulationControl);
        C1G2SingulationControl.hdr.elementHdr.pType = &c.LLRP_tdC1G2SingulationControl;
        C1G2SingulationControl.Session = 0;
        C1G2SingulationControl.TagPopulation = 32;
        C1G2SingulationControl.TagTransitTime = 0;

        var C1G2InventoryCommand = std.mem.zeroes(c.LLRP_tSC1G2InventoryCommand);
        C1G2InventoryCommand.hdr.elementHdr.pType = &c.LLRP_tdC1G2InventoryCommand;
        C1G2InventoryCommand.TagInventoryStateAware = 0;
        C1G2InventoryCommand.pC1G2SingulationControl = &C1G2SingulationControl;

        // AntennaID 0 = all antennas; attach C1G2InventoryCommand here.
        var AntennaConfiguration = std.mem.zeroes(c.LLRP_tSAntennaConfiguration);
        AntennaConfiguration.hdr.elementHdr.pType = &c.LLRP_tdAntennaConfiguration;
        AntennaConfiguration.AntennaID = 0;
        AntennaConfiguration.listAirProtocolInventoryCommandSettings = @ptrCast(&C1G2InventoryCommand.hdr);

        var InventoryParameterSpec = std.mem.zeroes(c.LLRP_tSInventoryParameterSpec);
        InventoryParameterSpec.hdr.elementHdr.pType = &c.LLRP_tdInventoryParameterSpec;
        InventoryParameterSpec.InventoryParameterSpecID = 1234;
        InventoryParameterSpec.eProtocolID = c.LLRP_AirProtocols_EPCGlobalClass1Gen2;
        InventoryParameterSpec.listAntennaConfiguration = &AntennaConfiguration;

        var AISpec = std.mem.zeroes(c.LLRP_tSAISpec);
        AISpec.hdr.elementHdr.pType = &c.LLRP_tdAISpec;
        AISpec.AntennaIDs.nValue = 1;
        AISpec.AntennaIDs.pValue = &AntennaIDs[0];
        AISpec.pAISpecStopTrigger = &AISpecStopTrigger;
        AISpec.listInventoryParameterSpec = &InventoryParameterSpec;

        var TagReportContentSelector = std.mem.zeroes(c.LLRP_tSTagReportContentSelector);
        TagReportContentSelector.hdr.elementHdr.pType = &c.LLRP_tdTagReportContentSelector;
        TagReportContentSelector.EnableAntennaID = 1;
        TagReportContentSelector.EnablePeakRSSI = 1;
        TagReportContentSelector.EnableFirstSeenTimestamp = 1;

        var ROReportSpec = std.mem.zeroes(c.LLRP_tSROReportSpec);
        ROReportSpec.hdr.elementHdr.pType = &c.LLRP_tdROReportSpec;
        // N=1: send a report after every single tag observation so callers
        // see reads in real time rather than in batches.
        ROReportSpec.eROReportTrigger = c.LLRP_ROReportTriggerType_Upon_N_Tags_Or_End_Of_ROSpec;
        ROReportSpec.N = 1;
        ROReportSpec.pTagReportContentSelector = &TagReportContentSelector;

        var ROSpec = std.mem.zeroes(c.LLRP_tSROSpec);
        ROSpec.hdr.elementHdr.pType = &c.LLRP_tdROSpec;
        ROSpec.ROSpecID = 123;
        ROSpec.Priority = 0;
        ROSpec.eCurrentState = c.LLRP_ROSpecState_Disabled;
        ROSpec.pROBoundarySpec = &ROBoundarySpec;
        ROSpec.listSpecParameter = @ptrCast(&AISpec.hdr);
        ROSpec.pROReportSpec = &ROReportSpec;

        var Cmd = std.mem.zeroes(c.LLRP_tSADD_ROSPEC);
        Cmd.hdr.elementHdr.pType = &c.LLRP_tdADD_ROSPEC;
        Cmd.hdr.MessageID = 201;
        Cmd.pROSpec = &ROSpec;

        const pRspMsg = self.transact(&Cmd.hdr) orelse return Error.LlrpFailed;
        defer freeMessage(pRspMsg);

        const pRsp: *c.LLRP_tSADD_ROSPEC_RESPONSE = @ptrCast(pRspMsg);
        try checkLLRPStatus(pRsp.pLLRPStatus, "addROSpec");

        if (self.verbose != 0) _ = c.printf("INFO: ROSpec added\n");
    }

    /// Send ENABLE_ROSPEC for ROSpecID 123, transitioning it to Enabled state.
    pub fn enableROSpec(self: *Reader) Error!void {
        var Cmd = std.mem.zeroes(c.LLRP_tSENABLE_ROSPEC);
        Cmd.hdr.elementHdr.pType = &c.LLRP_tdENABLE_ROSPEC;
        Cmd.hdr.MessageID = 202;
        Cmd.ROSpecID = 123;

        const pRspMsg = self.transact(&Cmd.hdr) orelse return Error.LlrpFailed;
        defer freeMessage(pRspMsg);

        const pRsp: *c.LLRP_tSENABLE_ROSPEC_RESPONSE = @ptrCast(pRspMsg);
        try checkLLRPStatus(pRsp.pLLRPStatus, "enableROSpec");

        if (self.verbose != 0) _ = c.printf("INFO: ROSpec enabled\n");
    }

    // -----------------------------------------------------------------------
    // Inventory
    // -----------------------------------------------------------------------

    /// Send START_ROSPEC for ROSpecID 123.  Because the AISpec has a Null
    /// stop trigger the reader inventories continuously until `stopROSpec` is
    /// called.  Each tag observation produces an independent RO_ACCESS_REPORT.
    pub fn startROSpec(self: *Reader) Error!void {
        var Cmd = std.mem.zeroes(c.LLRP_tSSTART_ROSPEC);
        Cmd.hdr.elementHdr.pType = &c.LLRP_tdSTART_ROSPEC;
        Cmd.hdr.MessageID = 203;
        Cmd.ROSpecID = 123;

        const pRspMsg = self.transact(&Cmd.hdr) orelse return Error.LlrpFailed;
        defer freeMessage(pRspMsg);

        const pRsp: *c.LLRP_tSSTART_ROSPEC_RESPONSE = @ptrCast(pRspMsg);
        try checkLLRPStatus(pRsp.pLLRPStatus, "startROSpec");

        if (self.verbose != 0) _ = c.printf("INFO: ROSpec started\n");
    }

    /// Send STOP_ROSPEC for ROSpecID 123 to halt the continuous inventory.
    pub fn stopROSpec(self: *Reader) Error!void {
        var Cmd = std.mem.zeroes(c.LLRP_tSSTOP_ROSPEC);
        Cmd.hdr.elementHdr.pType = &c.LLRP_tdSTOP_ROSPEC;
        Cmd.hdr.MessageID = 204;
        Cmd.ROSpecID = 123;

        const pRspMsg = self.transact(&Cmd.hdr) orelse return Error.LlrpFailed;
        defer freeMessage(pRspMsg);

        const pRsp: *c.LLRP_tSSTOP_ROSPEC_RESPONSE = @ptrCast(pRspMsg);
        try checkLLRPStatus(pRsp.pLLRPStatus, "stopROSpec");

        if (self.verbose != 0) _ = c.printf("INFO: ROSpec stopped\n");
    }

    /// Wait up to `nMaxMS` milliseconds for the next tag report.
    /// For each tag in the report `cb` is invoked with a `TagObservation`.
    /// Returns `error.Timeout` (non-fatal) if no message arrived in time.
    /// Interleaved READER_EVENT_NOTIFICATIONs are dispatched transparently.
    /// Pass `null` for `gpi_cb` to use the default log-only GPI handler.
    pub fn tryReceiveReport(self: *Reader, nMaxMS: c_int, cb: ObserveCallback, gpi_cb: ?GpiCallback) Error!void {
        while (true) {
            const pMsgC = c.LLRP_Conn_recvMessage(self.pConn, nMaxMS);
            if (pMsgC == null) {
                // Distinguish a normal timeout from a real socket error so
                // callers can keep looping when there are simply no tags.
                if (self.isTimeoutError()) return Error.Timeout;
                const pError = c.LLRP_Conn_getRecvError(self.pConn);
                _ = c.printf("ERROR: recvMessage failed, %s\n",
                    if (pError[0].pWhatStr != null) pError[0].pWhatStr else @as([*c]const u8, "no reason given"));
                return Error.LlrpFailed;
            }
            // Cast [*c]T → *T now that we know it is non-null.
            const pMessage: *c.LLRP_tSMessage = @ptrCast(pMsgC);
            const pType = pMessage.elementHdr.pType;

            if (self.verbose > 1) {
                _ = c.printf("\n===================================\n");
                _ = c.printf("INFO: Message received\n");
                printXMLMessage(@ptrCast(pMessage));
            }

            if (&c.LLRP_tdRO_ACCESS_REPORT == pType) {
                const pRpt: *c.LLRP_tSRO_ACCESS_REPORT = @ptrCast(pMessage);
                dispatchObservations(pRpt, self.clock_offset_us, cb);
                freeMessage(pMessage);
                return;
            } else if (&c.LLRP_tdREADER_EVENT_NOTIFICATION == pType) {
                const pNtf: *c.LLRP_tSREADER_EVENT_NOTIFICATION = @ptrCast(pMessage);
                const pNtfData = c.LLRP_READER_EVENT_NOTIFICATION_getReaderEventNotificationData(pNtf);
                if (pNtfData != null) {
                    handleReaderEventNotification(pNtfData.?, self.clock_offset_us, gpi_cb);
                } else {
                    _ = c.printf("WARNING: READER_EVENT_NOTIFICATION without data\n");
                }
                freeMessage(pMessage);
            } else {
                _ = c.printf("WARNING: Ignored unexpected message: %s\n", pType[0].pName);
                freeMessage(pMessage);
            }
        }
    }

    /// Returns true when the last `recvMessage` call failed due to a timeout
    /// rather than a genuine socket or protocol error.
    fn isTimeoutError(self: *Reader) bool {
        const pError = c.LLRP_Conn_getRecvError(self.pConn);
        if (pError == null or pError[0].pWhatStr == null) return false;
        const s = std.mem.span(pError[0].pWhatStr);
        // The LTKC library embeds the word "timeout" (case-insensitive) in
        // its timeout error description strings.
        return std.ascii.indexOfIgnoreCase(s, "timeout") != null;
    }

    fn resetConfigurationToFactoryDefaults(self: *Reader) Error!void {
        var Cmd = std.mem.zeroes(c.LLRP_tSSET_READER_CONFIG);
        Cmd.hdr.elementHdr.pType = &c.LLRP_tdSET_READER_CONFIG;
        Cmd.hdr.MessageID = 101;
        Cmd.ResetToFactoryDefault = 1;

        const pRspMsg = self.transact(&Cmd.hdr) orelse return Error.LlrpFailed;
        defer freeMessage(pRspMsg);

        const pRsp: *c.LLRP_tSSET_READER_CONFIG_RESPONSE = @ptrCast(pRspMsg);
        try checkLLRPStatus(pRsp.pLLRPStatus, "resetConfigurationToFactoryDefaults");

        if (self.verbose != 0) _ = c.printf("INFO: Configuration reset to factory defaults\n");
    }

    fn deleteAllROSpecs(self: *Reader) Error!void {
        const pCmd = c.LLRP_DELETE_ROSPEC_construct();
        if (pCmd == null) {
            _ = c.printf("ERROR: LLRP_DELETE_ROSPEC_construct failed\n");
            return Error.LlrpFailed;
        }
        const pCmdMsg: *c.LLRP_tSMessage = &pCmd[0].hdr;
        c.LLRP_Message_setMessageID(pCmdMsg, 102);
        _ = c.LLRP_DELETE_ROSPEC_setROSpecID(pCmd, 0); // 0 = all ROSpecs

        // Free the command after transact regardless of the result.
        const pRspMsg = self.transact(pCmdMsg);
        freeMessage(pCmdMsg);
        const pRspMsgNN = pRspMsg orelse return Error.LlrpFailed;
        defer freeMessage(pRspMsgNN);

        const pRsp: *c.LLRP_tSDELETE_ROSPEC_RESPONSE = @ptrCast(pRspMsgNN);
        try checkLLRPStatus(pRsp.pLLRPStatus, "deleteAllROSpecs");

        if (self.verbose != 0) _ = c.printf("INFO: All ROSpecs deleted\n");
    }

    /// Send `pSendMsg` and await the matching response (5 s timeout).
    /// Returns null and tattles on any error.
    fn transact(self: *Reader, pSendMsg: *c.LLRP_tSMessage) ?*c.LLRP_tSMessage {
        if (self.verbose > 1) {
            _ = c.printf("\n===================================\n");
            _ = c.printf("INFO: Transact sending\n");
            printXMLMessage(pSendMsg);
        }

        const pRspMsg = c.LLRP_Conn_transact(self.pConn, pSendMsg, 5000);
        if (pRspMsg == null) {
            const pError = c.LLRP_Conn_getTransactError(self.pConn);
            _ = c.printf("ERROR: %s transact failed, %s\n",
                pSendMsg.elementHdr.pType[0].pName,
                if (pError[0].pWhatStr != null) pError[0].pWhatStr else @as([*c]const u8, "no reason given"));
            if (pError[0].pRefType != null)
                _ = c.printf("ERROR: ... reference type %s\n", pError[0].pRefType[0].pName);
            if (pError[0].pRefField != null)
                _ = c.printf("ERROR: ... reference field %s\n", pError[0].pRefField[0].pName);
            return null;
        }

        if (self.verbose > 1) {
            _ = c.printf("\n- - - - - - - - - - - - - - - - - -\n");
            _ = c.printf("INFO: Transact received response\n");
            printXMLMessage(@ptrCast(pRspMsg));
        }

        if (&c.LLRP_tdERROR_MESSAGE == pRspMsg[0].elementHdr.pType) {
            const pResponseType = pSendMsg.elementHdr.pType[0].pResponseType;
            _ = c.printf("ERROR: Received ERROR_MESSAGE instead of %s\n", pResponseType[0].pName);
            freeMessage(@ptrCast(pRspMsg));
            return null;
        }

        return pRspMsg;
    }

    /// Receive one message with a timeout in milliseconds.
    /// Returns null and tattles on any error.
    fn recvMessage(self: *Reader, nMaxMS: c_int) ?*c.LLRP_tSMessage {
        const pMessage = c.LLRP_Conn_recvMessage(self.pConn, nMaxMS);
        if (pMessage == null) {
            const pError = c.LLRP_Conn_getRecvError(self.pConn);
            _ = c.printf("ERROR: recvMessage failed, %s\n",
                if (pError[0].pWhatStr != null) pError[0].pWhatStr else @as([*c]const u8, "no reason given"));
            if (pError[0].pRefType != null)
                _ = c.printf("ERROR: ... reference type %s\n", pError[0].pRefType[0].pName);
            if (pError[0].pRefField != null)
                _ = c.printf("ERROR: ... reference field %s\n", pError[0].pRefField[0].pName);
            return null;
        }

        if (self.verbose > 1) {
            _ = c.printf("\n===================================\n");
            _ = c.printf("INFO: Message received\n");
            printXMLMessage(@ptrCast(pMessage));
        }

        return pMessage;
    }
};

// ---------------------------------------------------------------------------
// Module-private helpers
// ---------------------------------------------------------------------------

fn checkLLRPStatus(pLLRPStatus: ?*c.LLRP_tSLLRPStatus, pWhatStr: [*:0]const u8) Error!void {
    if (pLLRPStatus == null) {
        _ = c.printf("ERROR: %s missing LLRP status\n", pWhatStr);
        return Error.LlrpFailed;
    }
    if (c.LLRP_StatusCode_M_Success != pLLRPStatus.?.eStatusCode) {
        if (pLLRPStatus.?.ErrorDescription.nValue == 0) {
            _ = c.printf("ERROR: %s failed, no error description given\n", pWhatStr);
        } else {
            _ = c.printf("ERROR: %s failed, %.*s\n",
                pWhatStr,
                @as(c_int, pLLRPStatus.?.ErrorDescription.nValue),
                pLLRPStatus.?.ErrorDescription.pValue);
        }
        return Error.LlrpFailed;
    }
}

fn freeMessage(pMessage: *c.LLRP_tSMessage) void {
    c.LLRP_Element_destruct(&pMessage.elementHdr);
}

fn printXMLMessage(pMessage: *c.LLRP_tSMessage) void {
    var aBuf: [100 * 1024]u8 = undefined;
    _ = c.LLRP_toXMLString(&pMessage.elementHdr, &aBuf[0], aBuf.len);
    _ = c.printf("%s", &aBuf[0]);
}

/// Build a `TagObservation` for each entry in an RO_ACCESS_REPORT and
/// invoke `cb` for each one.
fn dispatchObservations(pReport: *c.LLRP_tSRO_ACCESS_REPORT, clock_offset_us: i64, cb: ObserveCallback) void {
    var pEntry = pReport.listTagReportData;
    while (pEntry != null) : (pEntry = @ptrCast(pEntry[0].hdr.pNextSubParameter)) {
        const pTrd = @as(*c.LLRP_tSTagReportData, @ptrCast(pEntry));
        cb.call(buildObservation(pTrd, clock_offset_us));
    }
}

/// Extract fields from a single TagReportData into a flat `TagObservation`.
fn buildObservation(pTrd: *c.LLRP_tSTagReportData, clock_offset_us: i64) TagObservation {
    var obs = std.mem.zeroes(TagObservation);

    // --- EPC ---
    if (pTrd.pEPCParameter != null) {
        const pType = pTrd.pEPCParameter[0].elementHdr.pType;
        if (&c.LLRP_tdEPC_96 == pType) {
            const pE96: *c.LLRP_tSEPC_96 = @ptrCast(pTrd.pEPCParameter);
            const n = 12;
            @memcpy(obs.epc[0..n], @as([*]const u8, @ptrCast(&pE96.EPC.aValue[0]))[0..n]);
            obs.epc_len = n;
        } else if (&c.LLRP_tdEPCData == pType) {
            const pEPCData: *c.LLRP_tSEPCData = @ptrCast(pTrd.pEPCParameter);
            const n: u8 = @intCast(@min((pEPCData.EPC.nBit + 7) / 8, 32));
            if (pEPCData.EPC.pValue != null)
                @memcpy(obs.epc[0..n], @as([*]const u8, @ptrCast(pEPCData.EPC.pValue))[0..n]);
            obs.epc_len = n;
        }
    }

    // --- Antenna / RSSI ---
    if (pTrd.pAntennaID != null)
        obs.antenna = pTrd.pAntennaID[0].AntennaID;
    if (pTrd.pPeakRSSI != null)
        obs.rssi_dbm = pTrd.pPeakRSSI[0].PeakRSSI;

    // --- Timestamp (prefer LLRP UTC, fall back to local wall clock; apply NTP offset) ---
    const raw_us: i64 = if (pTrd.pFirstSeenTimestampUTC != null)
        @intCast(pTrd.pFirstSeenTimestampUTC[0].Microseconds)
    else
        std.time.microTimestamp();
    obs.timestamp_us = @intCast(raw_us + clock_offset_us);

    return obs;
}


fn handleReaderEventNotification(pNtfData: *c.LLRP_tSReaderEventNotificationData, clock_offset_us: i64, gpi_cb: ?GpiCallback) void {
    var nReported: c_int = 0;

    const pGpiEvent = c.LLRP_ReaderEventNotificationData_getGPIEvent(pNtfData);
    if (pGpiEvent != null) {
        const pTimestamp = c.LLRP_ReaderEventNotificationData_getTimestamp(pNtfData);
        handleGpiEvent(@ptrCast(pGpiEvent), pTimestamp, clock_offset_us, gpi_cb);
        nReported += 1;
    }

    const pAntennaEvent = c.LLRP_ReaderEventNotificationData_getAntennaEvent(pNtfData);
    if (pAntennaEvent != null) {
        handleAntennaEvent(@ptrCast(pAntennaEvent));
        nReported += 1;
    }

    const pReaderExceptionEvent = c.LLRP_ReaderEventNotificationData_getReaderExceptionEvent(pNtfData);
    if (pReaderExceptionEvent != null) {
        handleReaderExceptionEvent(@ptrCast(pReaderExceptionEvent));
        nReported += 1;
    }

    if (nReported == 0) _ = c.printf("NOTICE: Unexpected (unhandled) ReaderEvent\n");
}

fn handleGpiEvent(
    pEvt: *c.LLRP_tSGPIEvent,
    pTimestamp: ?*c.LLRP_tSParameter,
    clock_offset_us: i64,
    gpi_cb: ?GpiCallback,
) void {
    const port = c.LLRP_GPIEvent_getGPIPortNumber(pEvt);
    const state = c.LLRP_GPIEvent_getGPIEvent(pEvt) != 0;

    // Extract UTC timestamp from the notification's Timestamp sub-parameter.
    // The parameter is polymorphic: prefer UTCTimestamp; fall back to local
    // wall clock if it is absent or expressed as an Uptime (relative ticks).
    var raw_us: i64 = std.time.microTimestamp();
    if (pTimestamp) |pT| {
        if (&c.LLRP_tdUTCTimestamp == pT.elementHdr.pType) {
            const pUTC: *c.LLRP_tSUTCTimestamp = @ptrCast(pT);
            raw_us = @intCast(pUTC.Microseconds);
        }
    }
    const timestamp_us: u64 = @intCast(raw_us + clock_offset_us);

    if (gpi_cb) |cb| {
        cb.call(.{ .port = port, .state = state, .timestamp_us = timestamp_us });
    } else {
        _ = c.printf("NOTICE: GPI port %d %s  ts=%llu\n",
            port,
            if (state) @as([*:0]const u8, "high") else @as([*:0]const u8, "low"),
            @as(c_ulonglong, timestamp_us));
    }
}

fn handleAntennaEvent(pAntennaEvent: *c.LLRP_tSAntennaEvent) void {
    const eEventType = c.LLRP_AntennaEvent_getEventType(pAntennaEvent);
    const AntennaID = c.LLRP_AntennaEvent_getAntennaID(pAntennaEvent);
    const pStateStr: [*:0]const u8 = switch (eEventType) {
        c.LLRP_AntennaEventType_Antenna_Disconnected => "disconnected",
        c.LLRP_AntennaEventType_Antenna_Connected => "connected",
        else => "?unknown-event?",
    };
    _ = c.printf("NOTICE: Antenna %d is %s\n", AntennaID, pStateStr);
}

fn handleReaderExceptionEvent(pReaderExceptionEvent: *c.LLRP_tSReaderExceptionEvent) void {
    const Message = c.LLRP_ReaderExceptionEvent_getMessage(pReaderExceptionEvent);
    if (Message.nValue > 0 and Message.pValue != null) {
        _ = c.printf("NOTICE: ReaderException '%.*s'\n",
            @as(c_int, Message.nValue), Message.pValue);
    } else {
        _ = c.printf("NOTICE: ReaderException but no message\n");
    }
}
