// daasiot/src/pydaasiot_bindings.cpp
/**
 * @file pydaasiot_bindings.cpp
 * @brief Python bindings for the DaaS-IoT SDK (libdaas.a) using pybind11.
 *
 * @details
 * This translation unit exposes the native DaaS-IoT SDK (libdaas.a) to Python
 * via pybind11. It provides:
 *  - a thin binding around the native enums and the DDO structure;
 *  - a trampoline class @ref PyIDaasApiEvent to forward asynchronous events
 *    from C++ to Python callbacks;
 *  - a high-level Python wrapper class @c DaasWrapper mirroring
 *    @ref daas::api::DaasWrapper.
 *
 * The resulting extension module is named <tt>_core</tt> and is meant to be
 * imported by a higher-level Python package (e.g. @c daasiot ).
 *
 * © 2025 Sebyone Srl. All rights reserved.
 */

#include <pybind11/pybind11.h>
#include <pybind11/stl.h>
#include <pybind11/numpy.h>      // buffer protocol / numpy
#include "daaswrapper.h"         // includes daas.hpp -> daas_types.hpp

namespace py = pybind11;
using namespace daas::api;

/**
 * @class PyIDaasApiEvent
 * @brief Trampoline class to forward asynchronous events from libdaas to Python.
 *
 * @details
 * This class derives from the native ::IDaasApiEvent interface and uses the
 * pybind11 trampoline mechanism (@c PYBIND11_OVERRIDE_PURE) to route virtual
 * method calls to Python implementations.
 *
 * It enables Python code to implement event handlers for:
 *  - DIN acceptance;
 *  - DDO reception;
 *  - frisbee and node-state notifications;
 *  - ATS synchronization completion;
 *  - DPERF frisbee completion.
 */
class PyIDaasApiEvent : public IDaasApiEvent {
public:
    using IDaasApiEvent::IDaasApiEvent;

    /// Called when a DIN is accepted by the local node.
    void dinAcceptedEvent(din_t din) override {
        PYBIND11_OVERRIDE_PURE(void, IDaasApiEvent, dinAcceptedEvent, din);
    }

    /// Called when a DDO is received from a remote node.
    void ddoReceivedEvent(int payload_size, typeset_t t, din_t din) override {
        PYBIND11_OVERRIDE_PURE(void, IDaasApiEvent, ddoReceivedEvent, payload_size, t, din);
    }

    /// Called when a frisbee (fire-and-forget ping) is received.
    void frisbeeReceivedEvent(din_t din) override {
        PYBIND11_OVERRIDE_PURE(void, IDaasApiEvent, frisbeeReceivedEvent, din);
    }

    /// Called when a node-state notification is received.
    void nodeStateReceivedEvent(din_t din) override {
        PYBIND11_OVERRIDE_PURE(void, IDaasApiEvent, nodeStateReceivedEvent, din);
    }

    /// Called when ATS synchronization completes for a given DIN.
    void atsSyncCompleted(din_t din) override {
        PYBIND11_OVERRIDE_PURE(void, IDaasApiEvent, atsSyncCompleted, din);
    }

    /// Called when a DPERF frisbee test completes.
    void frisbeeDperfCompleted(din_t din, uint32_t packets_sent, uint32_t block_size) override {
        PYBIND11_OVERRIDE_PURE(void, IDaasApiEvent, frisbeeDperfCompleted, din, packets_sent, block_size);
    }
};

/**
 * @brief Python module exposing libdaas (DaaS-IoT SDK) to Python.
 *
 * @details
 * This macro defines the pybind11 extension module named <tt>_core</tt>.
 * The module is intended to be imported from a higher-level Python package
 * (e.g. <tt>import daasiot._core as _core</tt>).
 *
 * Exposed entities include:
 *  - @c daas_error_t, @c link_t and @c performs_mode_t enums;
 *  - the native @c DDO structure with payload helpers;
 *  - the abstract interface @c IDaasApiEvent for event callbacks;
 *  - the high-level wrapper @c DaasWrapper, mirroring
 *    @ref daas::api::DaasWrapper.
 */
PYBIND11_MODULE(_core, m) {
    /// Module-level docstring.
    m.doc() = "Python bindings for libdaas.a (DaaS-IoT SDK)";

    // ---------------- Enums ----------------

    /**
     * @enum daas_error_t
     * @brief Error codes returned by libdaas and DaasWrapper methods.
     *
     * These values are mirrored from the native C++ enumeration ::daas_error_t
     * and represent common error conditions (initialization failures, mapping
     * issues, channel errors, etc.).
     */
    py::enum_<daas_error_t>(m, "daas_error_t")
        .value("ERROR_NONE", ERROR_NONE)
        .value("ERROR_CORE_ALREADY_INITIALIZED", ERROR_CORE_ALREADY_INITIALIZED)
        .value("ERROR_CORE_STOPPED", ERROR_CORE_STOPPED)
        .value("ERROR_CANNOT_INITIALIZE", ERROR_CANNOT_INITIALIZE)
        .value("ERROR_CANNOT_CREATE_NODE", ERROR_CANNOT_CREATE_NODE)
        .value("ERROR_DIN_ALREADY_EXIST", ERROR_DIN_ALREADY_EXIST)
        .value("ERROR_CANNOT_MAP_NODE", ERROR_CANNOT_MAP_NODE)
        .value("ERROR_INVALID_USER_TYPESET", ERROR_INVALID_USER_TYPESET)
        .value("ERROR_SEND_DDO", ERROR_SEND_DDO)
        .value("ERROR_NO_DDO_PRESENT", ERROR_NO_DDO_PRESENT)
        .value("ERROR_DIN_UNKNOWN", ERROR_DIN_UNKNOWN)
        .value("ERROR_CHANNEL_FAILURE", ERROR_CHANNEL_FAILURE)
        .value("ERROR_ATS_NOT_SYNCED", ERROR_ATS_NOT_SYNCED)
        .value("ERROR_INVALID_DME", ERROR_INVALID_DME)
        .value("ERROR_THREADS_ALREADY_STARTED", ERROR_THREADS_ALREADY_STARTED)
        .value("ERROR_NOT_IMPLEMENTED", ERROR_NOT_IMPLEMENTED)
        .value("ERROR_UNKNOWN", ERROR_UNKNOWN)
        .export_values();

    /**
     * @enum link_t
     * @brief Link/driver types supported by the DaaS-IoT SDK.
     *
     * These values identify specific communication backends (INET4, Bluetooth,
     * UART, MQTT5, etc.) used when enabling drivers and mapping nodes.
     */
    py::enum_<link_t>(m, "link_t")
        .value("_LINK_NONE", _LINK_NONE)
        .value("_LINK_DAAS", _LINK_DAAS)
        .value("_LINK_INET4", _LINK_INET4)
        .value("_LINK_BT", _LINK_BT)
        .value("_LINK_MQTT5", _LINK_MQTT5)
        .value("_LINK_UART", _LINK_UART)
        .export_values();

    /**
     * @enum performs_mode_t
     * @brief Execution mode for the internal DaaS core.
     *
     * These values control whether the core runs in its own thread or in the
     * caller's thread.
     */
    py::enum_<performs_mode_t>(m, "performs_mode_t")
        .value("PERFORM_CORE_THREAD", PERFORM_CORE_THREAD)
        .value("PERFORM_CORE_NO_THREAD", PERFORM_CORE_NO_THREAD)
        .export_values();

    // ---------------- DDO ----------------

    /**
     * @class DDO
     * @brief Python binding of the native data descriptor object.
     *
     * @details
     * A @c DDO (Data Descriptor Object) encapsulates a typed payload exchanged
     * between DaaS nodes. It includes:
     *  - an origin DIN;
     *  - a typeset identifier;
     *  - a timestamp;
     *  - a binary payload buffer.
     *
     * The binding exposes:
     *  - constructors to set @c typeset and @c timestamp;
     *  - setters/getters for origin, typeset and timestamp;
     *  - methods to allocate and fill the payload via bytes, memoryview or numpy;
     *  - safe helpers to view the payload as text or hexadecimal string.
     */
    py::class_<DDO>(m, "DDO")
        /// Default constructor (empty DDO).
        .def(py::init<>(), "Default constructor")

        /// Constructor taking a typeset identifier.
        .def(py::init<typeset_t>(), "Constructor with a typeset")

        /// Constructor taking typeset and timestamp.
        .def(py::init<typeset_t, stime_t>(), "Constructor with typeset and timestamp")

        /// Clear the payload buffer and reset its size to zero.
        .def("clearPayload", &DDO::clearPayload, "Clear the payload buffer")

        /// Set the origin device ID (DIN) for this DDO.
        .def("setOrigin", &DDO::setOrigin, "Set the origin device ID")

        /// Set the typeset field of the DDO.
        .def("setTypeset", &DDO::setTypeset, "Set the typeset field")

        /// Set the timestamp value associated with this DDO.
        .def("setTimestamp", &DDO::setTimestamp, "Set the timestamp value")

        /// Get the origin device ID (DIN).
        .def("getOrigin", &DDO::getOrigin, "Get the origin device ID")

        /// Get the typeset value of the DDO.
        .def("getTypeset", &DDO::getTypeset, "Get the typeset")

        /// Get the timestamp value.
        .def("getTimestamp", &DDO::getTimestamp, "Get the timestamp")

        /// Allocate a payload buffer of the given size (bytes).
        .def("allocatePayload", &DDO::allocatePayload, "Allocate a payload buffer")

        /**
         * @brief Append payload data from Python bytes/bytearray.
         *
         * This overload reads the content of a @c bytes or @c bytearray object
         * and appends it to the existing payload.
         */
        .def("appendPayloadData",
             [](DDO &self, py::bytes data) {
                 std::string buffer = data; // copy from Python bytes
                 return self.appendPayloadData(buffer.data(), static_cast<uint32_t>(buffer.size()));
             },
             "Append binary data (bytes/bytearray) to the payload")

        /**
         * @brief Append payload data from a 1-D buffer (memoryview/numpy).
         *
         * This overload accepts any Python object supporting the buffer protocol
         * (e.g., @c memoryview or 1-D numpy arrays) and appends its raw bytes
         * to the payload.
         *
         * @throws py::value_error if the buffer is not 1-D.
         */
        .def("appendPayloadData",
             [](DDO &self, py::buffer b) {
                 py::buffer_info info = b.request();
                 if (info.ndim != 1) {
                     throw py::value_error("appendPayloadData expects a 1-D contiguous buffer");
                 }
                 const uint32_t nbytes = static_cast<uint32_t>(info.size * info.itemsize);
                 return self.appendPayloadData(info.ptr, nbytes);
             },
             "Append data from a 1-D buffer (memoryview/numpy) to the payload")

        /**
         * @brief Replace the payload with Python bytes/bytearray.
         *
         * Any existing payload is overwritten by the given @c bytes content.
         */
        .def("setPayload",
             [](DDO &self, py::bytes data) {
                 std::string buffer = data; // copy from Python bytes
                 return self.setPayload(buffer.data(), static_cast<uint32_t>(buffer.size()));
             },
             py::arg("data"),
             "Set the payload from Python bytes/bytearray (overwrites existing payload)")

        /**
         * @brief Replace the payload with data from a 1-D buffer.
         *
         * Accepts any 1-D contiguous buffer (e.g., @c memoryview or numpy array)
         * and overwrites any existing payload.
         *
         * @throws py::value_error if the buffer is not 1-D.
         */
        .def("setPayload",
             [](DDO &self, py::buffer b) {
                 py::buffer_info info = b.request();
                 if (info.ndim != 1) {
                     throw py::value_error("setPayload expects a 1-D contiguous buffer");
                 }
                 const uint32_t nbytes = static_cast<uint32_t>(info.size * info.itemsize);
                 return self.setPayload(info.ptr, nbytes);
             },
             py::arg("data"),
             "Set the payload from a 1-D buffer (memoryview/numpy), overwriting any existing payload")

        /// Return the payload size in bytes.
        .def("getPayloadSize", &DDO::getPayloadSize, "Return the payload size in bytes")

        /**
         * @brief Return the payload as a Python @c bytes object.
         *
         * A copy of the internal payload buffer is made and wrapped into
         * a @c bytes instance.
         */
        .def("getPayloadAsBinary",
             [](DDO &self) {
                 uint32_t size = self.getPayloadSize();
                 std::vector<uint8_t> buf(size);
                 if (size) {
                     self.getPayloadAsBinary(buf.data(), 0, size);
                 }
                 return py::bytes(reinterpret_cast<char*>(buf.data()), size);
             },
             "Return the payload as a Python bytes object")

        /**
         * @brief Decode the payload as text using the given encoding.
         *
         * @param encoding Text encoding to use (default: "utf-8").
         * @param errors   Error handling strategy (default: "replace").
         *
         * @return A Python @c str with the decoded payload.
         */
        .def("payload_text",
             [](DDO &self, const std::string& encoding, const std::string& errors) {
                 uint32_t size = self.getPayloadSize();
                 std::vector<uint8_t> buf(size);
                 if (size) self.getPayloadAsBinary(buf.data(), 0, size);
                 py::object codecs = py::module::import("codecs");
                 py::object dec = codecs.attr("decode")(
                     py::bytes(reinterpret_cast<char*>(buf.data()), size),
                     encoding, errors
                 );
                 return dec;
             },
             py::arg("encoding") = "utf-8",
             py::arg("errors")   = "replace",
             "Return the payload decoded as text (default utf-8, errors='replace').")

        /**
         * @brief Return a hexadecimal representation of the payload.
         *
         * @param max_bytes Optional limit on the number of bytes to convert.
         *                  If 0, the entire payload is converted.
         *
         * @return A lowercase hex string, optionally truncated with "..." if
         *         @p max_bytes is smaller than the payload size.
         */
        .def("payload_hex",
             [](DDO &self, size_t max_bytes) {
                 uint32_t size = self.getPayloadSize();
                 std::vector<uint8_t> buf(size);
                 if (size) self.getPayloadAsBinary(buf.data(), 0, size);
                 size_t limit = (max_bytes && max_bytes < buf.size()) ? max_bytes : buf.size();
                 static const char* hex = "0123456789abcdef";
                 std::string s; s.reserve(limit * 2 + (limit < buf.size()) ? 3 : 0);
                 for (size_t i = 0; i < limit; ++i) {
                     s.push_back(hex[(buf[i] >> 4) & 0xF]);
                     s.push_back(hex[buf[i] & 0xF]);
                 }
                 if (limit < buf.size()) s += "...";
                 return py::str(s);
             },
             py::arg("max_bytes") = 0,
             "Return a hexadecimal string representation of the payload (optional max_bytes).")

        /**
         * @brief Developer-friendly representation with size and hex preview.
         *
         * Example:
         * @code
         * <DDO size=42 preview_hex=0102a3...>
         * @endcode
         */
        .def("__repr__", [](DDO &self) {
            uint32_t n = self.getPayloadSize();
            size_t preview = std::min<size_t>(n, 32);
            std::vector<uint8_t> buf(preview);
            if (preview) self.getPayloadAsBinary(buf.data(), 0, (uint32_t)preview);
            std::ostringstream oss;
            static const char* hex = "0123456789abcdef";
            oss << "<DDO size=" << n << " preview_hex=";
            for (size_t i = 0; i < preview; ++i) {
                oss << hex[(buf[i] >> 4) & 0xF] << hex[buf[i] & 0xF];
            }
            if (preview < n) oss << "...";
            oss << ">";
            return oss.str();
        });

    // ---------------- IDaasApiEvent ----------------

    /**
     * @class IDaasApiEvent
     * @brief Python-visible base class for DaaS event handlers.
     *
     * @details
     * This is the abstract interface that Python users subclass to receive
     * asynchronous notifications from the DaaS core. The actual virtual
     * dispatch is implemented by @ref PyIDaasApiEvent.
     */
    py::class_<IDaasApiEvent, PyIDaasApiEvent>(m, "IDaasApiEvent")
        .def(py::init<>(), "Base class for DaaS event handlers");

    // ---------------- DaasWrapper ----------------

    /**
     * @class DaasWrapper
     * @brief Python binding of the high-level DaaS wrapper.
     *
     * @details
     * This class mirrors @ref daas::api::DaasWrapper and exposes a high-level,
     * Python-friendly API for:
     *  - node initialization and lifecycle control;
     *  - driver configuration and node mapping;
     *  - sending/receiving DDOs;
     *  - querying network status and node lists.
     *
     * It is typically the main entry point for Python applications using
     * the DaaS-IoT SDK.
     */
    py::class_<DaasWrapper>(m, "DaasWrapper")
        /**
         * @brief Default constructor with no configuration and no event handler.
         *
         * Creates a wrapper instance without loading any configuration file and
         * without registering an event handler. Configuration can be applied
         * later via @c setupNode or explicit calls to the underlying API.
         */
        .def(py::init([]() {
            return new DaasWrapper(nullptr, nullptr);
        }), "Default constructor with no configuration and no event handler")

        /**
         * @brief Construct a wrapper using a configuration path and optional event handler.
         *
         * @param config       Optional path to a configuration file (may be @c None).
         * @param eventHandler Optional @c IDaasApiEvent instance used for callbacks.
         */
        .def(py::init<const char*, IDaasApiEvent*>(),
             py::arg("config") = nullptr,
             py::arg("eventHandler") = nullptr,
             "Construct a DaaS wrapper with configuration and optional event handler")

        /**
         * @brief Initialize the DaaS node with SID and DIN.
         *
         * @param sid System Identifier (network ID).
         * @param din Node Identifier (DIN).
         * @return ::daas_error_t error code.
         */
        .def("doInit", &DaasWrapper::doInit, py::arg("sid"), py::arg("din"),
             "Initialize the DaaS node with SID and DIN")

        /**
         * @brief Start the internal execution loop of the DaaS core.
         *
         * @param mode Execution mode (e.g., @c PERFORM_CORE_THREAD).
         *
         * @note The GIL is released for the duration of this call to avoid
         *       blocking other Python threads.
         */
        .def("doPerform", &DaasWrapper::doPerform, py::arg("mode"),
             "Start internal execution loop",
             py::call_guard<py::gil_scoped_release>())

        /// Terminate node activities and stop the core.
        .def("doEnd", &DaasWrapper::doEnd, "Terminate node activities")

        /// Reset the node state to a clean condition.
        .def("doReset", &DaasWrapper::doReset, "Reset the node")

        /// Return the version string of the underlying libdaas library.
        .def("getVersion", &DaasWrapper::getVersion, "Return the library version")

        /// Return a short information string (version/build and other details).
        .def("getInfos", &DaasWrapper::getInfos, "Return version and build information")

        /// List the drivers compiled into the underlying libdaas.
        .def("listAvailableDrivers", &DaasWrapper::listAvailableDrivers, "List available drivers")

        /**
         * @brief Enable a driver on the local node.
         *
         * @param link   Link/driver type (e.g., @c _LINK_INET4).
         * @param driver Driver URI or configuration string.
         */
        .def("enableDriver", &DaasWrapper::enableDriver,
             py::arg("link"), py::arg("driver"),
             "Enable a driver on this node")

        /**
         * @brief Configure and start a node using direct parameters.
         *
         * @param sid  System ID of the local node.
         * @param din  Node ID of the local node.
         * @param link Link technology (e.g., @c _LINK_INET4).
         * @param uri  URI for the local driver (e.g., \"127.0.0.1:3000\").
         */
        .def("setupNode",
             py::overload_cast<din_t, din_t, link_t, const char*>(&DaasWrapper::setupNode),
             py::arg("sid"), py::arg("din"), py::arg("link"), py::arg("uri"),
             "Configure and start a node (direct parameters)")

        /**
         * @brief Configure and start a node from a JSON configuration file.
         *
         * @param configFilePath Path to the JSON setup file.
         */
        .def("setupNode",
             py::overload_cast<const char*>(&DaasWrapper::setupNode),
             py::arg("configFilePath"),
             "Configure and start a node using a JSON config file")

        /// Return the status of the local node.
        .def("getStatus", &DaasWrapper::getStatus, "Return local node status")

        /// Return the status of a remote node identified by DIN.
        .def("status", &DaasWrapper::status, py::arg("din"), "Return status of a remote node")

        /**
         * @brief Fetch state from a remote node with optional flags.
         *
         * @param din  Remote node DIN.
         * @param opts Fetch options (implementation-defined).
         */
        .def("fetch", &DaasWrapper::fetch, py::arg("din"), py::arg("opts"),
             "Fetch state from a remote node")

        /// List all known nodes as a DIN list.
        .def("listNodes", &DaasWrapper::listNodes, "List known nodes")

        /**
         * @brief Store the current configuration using a C++ ::IDepot interface.
         *
         * @param storage_interface C++ object implementing the storage logic.
         */
        .def("storeConfiguration", &DaasWrapper::storeConfiguration, py::arg("storage_interface"),
             "Store configuration (requires C++ IDepot)")

        /**
         * @brief Load configuration from a C++ ::IDepot interface.
         *
         * @param storage_interface C++ object implementing the retrieval logic.
         */
        .def("loadConfiguration", &DaasWrapper::loadConfiguration, py::arg("storage_interface"),
             "Load configuration (requires C++ IDepot)")

        /// Map a remote node using only its DIN (preconfigured mapping).
        .def("map", py::overload_cast<din_t>(&DaasWrapper::map), py::arg("din"),
             "Map a known node")

        /**
         * @brief Map a remote node specifying link and URI.
         *
         * @param din  Remote node DIN.
         * @param link Link technology.
         * @param uri  Remote endpoint URI.
         */
        .def("map", py::overload_cast<din_t, link_t, const char*>(&DaasWrapper::map),
             py::arg("din"), py::arg("link"), py::arg("uri"),
             "Map a node with link and URI")

        /**
         * @brief Map a remote node specifying link, URI and security key.
         *
         * @param din         Remote node DIN.
         * @param link        Link technology.
         * @param uri         Remote endpoint URI.
         * @param securityKey Optional security key for the mapping.
         */
        .def("map", py::overload_cast<din_t, link_t, const char*, const char*>(&DaasWrapper::map),
             py::arg("din"), py::arg("link"), py::arg("uri"), py::arg("securityKey"),
             "Map a node with link, URI and security key")

        /// Remove a previously mapped node.
        .def("remove", &DaasWrapper::remove, py::arg("din"), "Remove a mapped node")

        /// Trigger a locate operation for the given DIN.
        .def("locate", &DaasWrapper::locate, py::arg("din"), "Locate a node in the network")

        /**
         * @brief Query the number of pending DDOs for a given DIN.
         *
         * @param din   Remote node DIN.
         * @param count Output parameter with the number of available packets.
         */
        .def("availablesPull", &DaasWrapper::availablesPull,
             py::arg("din"), py::arg("count"),
             "Return the number of available packets from a remote node")

        /**
         * @brief Pull a DDO from a remote node.
         *
         * @param din Remote node DIN.
         *
         * @return A tuple @c (err, ddo) where:
         *  - @c err is a ::daas_error_t;
         *  - @c ddo is a @c DDO instance on success, or @c None on error.
         *
         * @warning The returned DDO is a reference to an internal object owned
         *          by the C++ layer; copy its payload if it must outlive the
         *          call.
         */
        .def("pull",
             [](DaasWrapper &self, din_t din) {
                 DDO* ddo_ptr = nullptr;
                 daas_error_t err = self.pull(din, &ddo_ptr);
                 if (err != ERROR_NONE || ddo_ptr == nullptr) {
                     return py::make_tuple(err, py::none());
                 }
                 return py::make_tuple(err, py::cast(ddo_ptr, py::return_value_policy::reference));
             },
             py::arg("din"),
             "Pull a DDO from a remote node; returns (err, ddo)")

        /**
         * @brief Push a DDO to a remote node.
         *
         * @param din         Destination DIN.
         * @param outboundDDO DDO instance containing the payload to send.
         *
         * @note The @c keep_alive policy ensures that the outbound DDO instance
         *       is kept alive for the duration of the call.
         */
        .def("push",
             [](DaasWrapper &self, din_t din, DDO* outboundDDO) {
                 return self.push(din, outboundDDO);
             },
             py::arg("din"), py::arg("outboundDDO"),
             "Push a DDO to a remote node",
             py::keep_alive<1, 3>())

        /**
         * @brief Send a frisbee (fire-and-forget) ping to a node.
         *
         * @param din Destination DIN.
         */
        .def("frisbee", &DaasWrapper::frisbee, py::arg("din"),
             "Send a frisbee ping to a node")

        /**
         * @brief Get the list of active network interfaces on the host system.
         *
         * @return A list of interface names.
         */
        .def("getActiveInterfaces", &DaasWrapper::getActiveInterfaces,
             "Get active network interfaces")

        /**
         * @brief List all network interfaces on the system with extra metadata.
         *
         * @return A list of @ref InterfaceInfo-like dictionaries with name, type and status.
         */
        .def("listSystemInterfaces", &DaasWrapper::listSystemInterfaces,
             "List all network interfaces on the system")

        // ------- Wrapper SAFE helpers -------

        /**
         * @brief Return the DDO payload as a size-aware Python string.
         *
         * The underlying C++ implementation uses the exact payload size and does not
         * stop at the first NUL character; the result may contain embedded NULs.
         */
        .def("ddoPayloadAsString", &DaasWrapper::ddoPayloadAsString, py::arg("ddo"),
             "Return the DDO payload as string (size-aware, no extra '\\0').")

        /**
         * @brief Return the DDO payload as a NUL-terminated C-like string.
         *
         * A copy of the payload is made and a trailing '\\0' is appended. This can be
         * useful when interoperating with legacy C APIs.
         */
        .def("ddoPayloadAsCString", &DaasWrapper::ddoPayloadAsCString, py::arg("ddo"),
             "Return the DDO payload as NUL-terminated string (copy).")

        /**
         * @brief Return a hexadecimal representation of the DDO payload.
         *
         * @param max_bytes Optional limit on the number of bytes to convert (0 = no limit).
         */
        .def("ddoPayloadAsHex", &DaasWrapper::ddoPayloadAsHex,
             py::arg("ddo"), py::arg("max_bytes") = 0,
             "Return a hexadecimal representation of the payload (optional max_bytes).");
}
