# (c) 2026 Applied Brain Research
#
# All information contained herein is and remains the property of Applied Brain
# Research. The intellectual and technical concepts contained herein are
# proprietary to Applied Brain Research and may be covered by U.S. and Foreign
# Patents, patents in process, and are protected by trade secret or copyright
# law. Dissemination of this information or reproduction of this material is
# strictly forbidden unless prior written permission is obtained from Applied
# Brain Research. Access to the source code contained herein is hereby
# forbidden to anyone except current Applied Brain Research employees,
# contractors or other outside parties that have executed Confidentiality
# and/or Non-disclosure agreements explicitly covering such access.
#
# The copyright notice above does not evidence any actual or intended
# publication or disclosure of this source code, which includes information
# that is confidential and/or proprietary, and is a trade secret, of Applied
# Brain Research. ANY REPRODUCTION, MODIFICATION, DISTRIBUTION, PUBLIC
# PERFORMANCE, OR PUBLIC DISPLAY OF OR THROUGH USE OF THIS SOURCE CODE WITHOUT
# THE EXPRESS WRITTEN CONSENT OF APPLIED BRAIN RESEARCH IS STRICTLY PROHIBITED,
# AND IN VIOLATION OF APPLICABLE LAWS AND INTERNATIONAL TREATIES. THE RECEIPT
# OR POSSESSION OF THIS SOURCE CODE AND/OR RELATED INFORMATION DOES NOT CONVEY
# OR IMPLY ANY RIGHTS TO REPRODUCE, DISCLOSE OR DISTRIBUTE ITS CONTENTS, OR TO
# MANUFACTURE, USE, OR SELL ANYTHING THAT IT MAY DESCRIBE, IN WHOLE OR IN PART.

"""
Wrapper classes around the object hierarchy exposed by the ABR C ABI.

Author: Andreas Stöckel (Applied Brain Research)
"""

import contextlib
import ctypes
import dataclasses
import enum
import logging
import os
import typing
from pathlib import Path

from abr_sdk import cabi
from abr_sdk.cabi import Status, VariantType
from abr_sdk.exceptions import AbrSdkError

default_logger = logging.getLogger()


def _emit_log(logger: logging.Logger, level: int, msg: bytes | None) -> None:
    """Log a raw C-side *msg* at *level*, clamped to at least DEBUG."""
    logger.log(max(level, logging.DEBUG), msg.decode() if msg else "")


# Device-bound license + integrity files. XDG state (not share): they persist
# but are machine-specific and must not be copied to another device.
DEFAULT_LICENSE_DIR = Path.home() / ".local" / "state" / "abr-sdk" / "license"


##############################################################################
# Private helpers                                                            #
##############################################################################


def _variant_to_python(v: cabi.Variant) -> bool | int | str | None:
    t = v.type
    if t == VariantType.BOOLEAN:
        return bool(v.value.boolean)
    if t == VariantType.UINT:
        return v.value.uint
    if t == VariantType.SINT:
        return v.value.sint
    if t == VariantType.STR:
        return v.value.str.decode() if v.value.str else None
    if t == VariantType.PTR:
        return v.value.ptr
    return None


##############################################################################
# Data types                                                                 #
##############################################################################


class BufferDirection(enum.Enum):
    """Enum describing how a buffer is used."""

    Input = enum.auto()
    """Buffer receives data from the Python application."""

    Output = enum.auto()
    """Buffer provides data to the Python application."""


@dataclasses.dataclass
class EventFlags:
    """
    Flags that influence how `Event` instances behave.
    """

    auto_disable: bool = False
    """
    If true, then automatically disables events that are triggered during a call
    to "poll" or while waiting for the platform event handle.

    This is useful if the calling code needs to wait for multiple events to be
    triggered before some action can be performed. Without auto-disabling,
    events must be manually disabled to avoid "poll" constantly returning, which
    complicates the code.
    """

    disabled: bool = False
    """
    If `true`, then the event is created in the "disabled" state.

    That means that the event is not included in the set of events that can
    cause `poll` to return.
    """

    @property
    def bitset(self) -> int:
        """
        Return a bitset that encodes the flags stored in this class.

        The result value may be directly fed into C ABI functions that take an
        "event flags" parameter.
        """
        res = 0
        if self.auto_disable:
            res |= cabi.EventFlags.AUTO_DISABLE
        if self.disabled:
            res |= cabi.EventFlags.DISABLED
        assert 0 <= res <= 0xFF, "event flags bitset must fit in a uint8"
        return res


VariantDict = dict[str, bool | int | str | Path | None]

###############################################################################
# Class Handle                                                                #
###############################################################################


class Handle:
    """
    Base class for all objects exposed by the ABR SDK C ABI.

    Provides access to the properties attached to objects, the parent object,
    and helper functions for lifetime management.
    """

    def __init__(
        self,
        cabi: cabi.CAbi,
        handle: ctypes.c_void_p | None,
        *,
        handle_may_be_none: bool = False,
        class_: str | None = None,
    ) -> None:
        """
        Create a new object instance wrapping the given handle and ABI instance.

        Arguments
        ---------

        cabi
            CABI instance providing access to the low-level C functions. A C
            ABI object may be obtained by loading an ABR SDK shared library.

        handle
            Pointer at the ABR SDK object that should be wrapped by the new
            ``Handle`` instance. Must be non-``None`` unless
            *handle_may_be_none* is ``True``.

        handle_may_be_none
            If ``True``, *handle* may be ``None``. Used for access to the
            library metadata.

        class_
            String containing the expected object class. If not set to ``None``,
            then the given string is compared to the "class" property of the
            handle.
        """
        self._cabi = cabi
        # A NULL pointer wrapped in c_void_p is not None; normalize it.
        if (handle is not None) and (handle.value is None):
            handle = None
        if (handle is None) and (not handle_may_be_none):
            raise AbrSdkError(
                f"Attempted to create {self.__class__.__name__} with a `None` handle."
            )
        self._handle = handle
        self._closed = False

        # Ensure that the object class matches what we expect.
        if class_ is not None:
            try:
                actual_class = self.get_property("class")
            except KeyError:
                raise AbrSdkError(
                    f"Handle has no `class` property, expected {class_!r}"
                ) from None
            if actual_class != class_:
                raise AbrSdkError(
                    f"Handle is of class {actual_class!r}, expected {class_!r}"
                )

    def _ensure_alive(self) -> None:
        if self._closed:
            raise AbrSdkError("Object is closed")

    @property
    def properties(self) -> VariantDict:
        """All properties as a ``{name: value}`` dict."""
        self._ensure_alive()
        result: VariantDict = {}
        for i in range(self._cabi.abr_get_property_count(self._handle)):
            name = self._cabi.abr_get_property_name(self._handle, i)
            if name is not None:
                v = self._cabi.abr_get_property_value_by_idx(self._handle, i)
                result[name.decode()] = _variant_to_python(v)
        return result

    def get_parent(self) -> typing.Optional["Handle"]:
        """Return the parent object, or ``None`` if this is a root object."""
        self._ensure_alive()
        parent = self._cabi.abr_get_parent(self._handle)
        if parent.value is None:
            return None
        return Handle(self._cabi, parent)

    def get_property(self, name: str | bytes) -> typing.Any:
        """Return a property value by name. Raises ``KeyError`` if not found."""
        self._ensure_alive()
        key = name.encode() if isinstance(name, str) else name
        v = self._cabi.abr_get_property_value_by_name(self._handle, key)
        if v.type == VariantType.UNDEFINED:
            raise KeyError(name)
        return _variant_to_python(v)


###############################################################################
# Class Library                                                               #
###############################################################################


class Library(Handle):
    """
    Loaded shared library with access to metadata.
    """

    def __init__(
        self,
        lib_path: str | Path,
        *,
        lib_search_paths: typing.Iterable[str | Path] | None = None,
        use_default_lib_search_paths: bool = True,
        app_type: str | None = None,
    ) -> None:
        """
        Attempt to load the library at `lib_path`.

        Parameters
        ----------
        lib_path
            Absolute or relative path pointing at the library to load. If a
            relative path is given, then this function searches in the provided
            search paths.

        lib_search_paths:
            Search paths to use for the library. Only used if `lib_path` is
            not absolute.

        use_default_lib_search_paths:
            If set to `True`, use a set of default search paths from the
            environment. Set this to `False` if the environment is not trusted
            or pass an absolute path to `lib_path`.

        app_type:
            Expected application type, or
        """
        # Collect a log of things that were tried to open the library. This is
        # used to print information about what was going on in case loading
        # fails.
        log = []

        path: Path | None = None
        for found_path in self.find(
            lib_path,
            lib_search_paths=lib_search_paths,
            use_default_lib_search_paths=use_default_lib_search_paths,
        ):
            try:
                # Attempt to load the library, resulting in a `cabi` object.
                log.append(f"Loading `{found_path}`")
                path = None
                super().__init__(
                    cabi.CAbi(found_path),
                    None,
                    handle_may_be_none=True,
                    class_="abr_lib_t",
                )

                # If that succeeds, ensure that the ABI version matches.
                lib_major, lib_minor = self.abi_version
                if lib_major != cabi.ABR_ABI_MAJOR:
                    log.append(
                        "ABI version mismatch. Library has version "
                        f"`{lib_major}.{lib_minor}`, "
                        "Python SDK is on version "
                        f"`{cabi.ABR_ABI_MAJOR}.{cabi.ABR_ABI_MINOR}`."
                    )
                    continue

                # Ensure that the application type matches what we expect.
                # Libraries that do not export an "app_type" property are
                # accepted; only an explicit mismatch rejects the library.
                if app_type is not None:
                    try:
                        lib_app_type = self.get_property("app_type")
                    except KeyError:
                        lib_app_type = None
                    if lib_app_type is not None and lib_app_type != app_type:
                        log.append(
                            "Application type mismatch. Library has "
                            f"application type `{lib_app_type}`, expected "
                            f"`{app_type}`."
                        )
                        continue

                # If we get here, then loading succeeded.
                path = found_path
                break
            except (OSError, AbrSdkError) as e:
                log.append(f"Library `{found_path}` could not be opened: {e}")

        # Error out with a (hopefully) helpful error message detailing why
        # we weren't able to open the library.
        if path is None:
            if not log:
                raise AbrSdkError(f"Could not find library `{lib_path}`")
            eol = "\n\t"
            raise AbrSdkError(f"Loading `{lib_path}` failed:{eol}{eol.join(log)}")

        self._path: Path = path

    @staticmethod
    def get_default_lib_search_paths() -> list[Path]:
        """
        Return default library search paths from the environment.
        """
        paths: list[Path] = [Path.cwd()]
        for var in ("LD_LIBRARY_PATH", "DYLD_LIBRARY_PATH"):
            value = os.environ.get(var)
            if value:
                for value_part in value.split(os.pathsep):
                    path = Path(value_part)
                    if not path.is_dir():
                        continue
                    path = path.absolute()
                    if path in paths:
                        continue
                    paths.append(path)
        return paths

    @staticmethod
    def find(
        lib_path: str | Path,
        *,
        lib_search_paths: typing.Iterable[str | Path] | None = None,
        use_default_lib_search_paths: bool = True,
    ) -> typing.Iterable[Path]:
        """
        Resolve `lib_path` to a list of candidates.

        See __init__ for a detailed description of the parameters.
        """
        # If an absolute library path was given, then use that directly.
        lib_path = Path(lib_path)
        if lib_path.is_absolute():
            yield lib_path

        # Otherwise, search in the search paths.
        lib_search_paths = [] if lib_search_paths is None else list(lib_search_paths)
        if use_default_lib_search_paths:
            lib_search_paths.extend(Library.get_default_lib_search_paths())
        for lib_search_path_or_str in lib_search_paths:
            lib_search_path = Path(lib_search_path_or_str)
            if not lib_search_path.is_dir():
                continue
            potential_lib_path = lib_search_path / lib_path
            if potential_lib_path.is_file():
                yield potential_lib_path

    @property
    def path(self) -> Path:
        """Location of the loaded library."""
        return self._path

    @property
    def abi_version(self) -> tuple[int, int]:
        """ABI version as a ``(major, minor)`` tuple."""
        v = self._cabi.abr_abi_version()
        return ((v >> 16) & 0xFFFF, v & 0xFFFF)


###############################################################################
# Class NeuralNetwork                                                         #
###############################################################################


class NeuralNetwork(Handle):
    """
    A neural network within a ABR instance.

    Exposes its I/O :attr:`buffers` as a tuple of :class:`Buffer` objects.
    """

    def __init__(self, cabi: cabi.CAbi, handle: ctypes.c_void_p) -> None:
        super().__init__(cabi, handle)
        n = self._cabi.abr_nn_get_buffer_count(handle)
        self.buffers: tuple[Buffer, ...] = tuple(
            Buffer(self._cabi, self._cabi.abr_nn_get_buffer(handle, i))
            for i in range(n)
        )

    @property
    def is_idle(self) -> bool:
        """``True`` if the network is not currently processing data."""
        self._ensure_alive()
        return self._cabi.abr_nn_is_idle(self._handle)


###############################################################################
# Class Buffer                                                                #
###############################################################################


class Buffer(Handle):
    """
    FIFO buffer for I/O.

    Write data into input buffers via `push`, read data from output buffers via
    `pull`.
    """

    def __init__(self, cabi: cabi.CAbi, handle: ctypes.c_void_p):
        super().__init__(cabi, handle, class_="abr_buf_t")

        # Read some buffer metadata. Start with the size. This doesn't change
        # over the lifetime of a buffer.
        self._size = self._cabi.abr_buf_get_size(handle)

        # Similarly, determine the buffer direction.
        dir = self.get_property("dir")
        if dir == "in":
            self._dir = BufferDirection.Input
        elif dir == "out":
            self._dir = BufferDirection.Output
        else:
            raise RuntimeError("Cannot determine buffer direction")

    @property
    def dir(self) -> BufferDirection:
        """Buffer input/output direction."""
        return self._dir

    @property
    def size(self) -> int:
        """Total buffer capacity in bytes."""
        return self._size

    @property
    def level(self) -> int:
        """Current fill level in bytes."""
        self._ensure_alive()
        return self._cabi.abr_buf_get_level(self._handle)

    def push(self, data: bytes | bytearray | memoryview) -> int:
        """
        Push data into the buffer.

        Arguments
        ---------

        data
            Data to push onto the buffer.

        flush
            Instructs the application to flush the processing pipeline once
            all bytes in `data` have been processed. Note that the "flush"
            flag only takes effect if there is enough space for all data in
            "data" in the buffer.

        Return
        ------

        Returns the number of bytes actually written. This may be less than
        ``len(data)`` if the buffer is full.
        """
        self._ensure_alive()
        buf = (ctypes.c_uint8 * len(data)).from_buffer_copy(data)
        res = self._cabi.abr_buf_push(self._handle, buf, len(data))
        if res < 0:
            raise cabi.AbrSdkCAbiError(res)
        return res

    def pull(self, max_n_bytes: int) -> bytes:
        """
        Pull data from the buffer.

        Arguments
        ---------

        max_n_bytes
            Maximum number of bytes to read from the buffer at once. Fewer
            bytes than this may be returned if the buffer is empty or a "flush"
            boundary is reached.

        Returns
        -------
        data
            Data pulled from the buffer.
        """
        self._ensure_alive()
        buf = (ctypes.c_uint8 * max_n_bytes)()
        res = self._cabi.abr_buf_pull(self._handle, buf, max_n_bytes)
        if res < 0:
            raise cabi.AbrSdkCAbiError(res)
        return ctypes.string_at(buf, res)

    def clear(self) -> None:
        """Discard all data in the buffer."""
        self._ensure_alive()
        self._cabi.abr_buf_clear(self._handle)


###############################################################################
# Class EventSet                                                              #
###############################################################################


class EventSet(Handle):
    """
    A set of events that can be polled together.

    Create events with :meth:`create_buffer_level_event` or
    :meth:`create_nn_idle_event`, then call :meth:`poll` to wait.
    """

    def __init__(
        self, cabi: cabi.CAbi, handle: ctypes.c_void_p, parent: "Application"
    ) -> None:
        super().__init__(cabi, handle)
        self._parent: Application | None = parent
        self._events: list[Event] = []

    @property
    def platform_handle(self) -> int:
        """Platform-specific handle (e.g. a file descriptor on POSIX)."""
        self._ensure_alive()
        handle: int = self._cabi.abr_evset_get_platform_handle(self._handle)
        return handle

    def poll(self, timeout_ms: int) -> bool:
        """
        Block until an event fires or *timeout_ms* elapses.

        A negative timeout waits forever; zero returns immediately.
        Returns ``True`` if at least one event triggered.
        Raises ``InterruptedError`` on signal interruption.
        """
        self._ensure_alive()
        try:
            self._cabi.abr_evset_poll(self._handle, timeout_ms)
        except cabi.AbrSdkCAbiError as e:
            if e.status == Status.ERR_TIMEOUT:
                return False
            if e.status == Status.ERR_INTERRUPT:
                raise InterruptedError() from e
            raise
        else:
            return True

    def interrupt(self) -> None:
        """Interrupts a blocking call to `poll`."""
        self._ensure_alive()
        self._cabi.abr_evset_interrupt(self._handle)

    def create_buffer_level_event(
        self, buf: Buffer, threshold: int, flags: EventFlags | None = None
    ) -> "Event":
        """
        Register a buffer level event for the given buffer.

        The I/O event is triggered if there are `threshold` bytes available for
        reading from or writing to the buffer.
        """
        self._ensure_alive()
        bitset = 0 if flags is None else flags.bitset
        h = self._cabi.abr_event_create_buf_level_event(
            self._handle, buf._handle, threshold, bitset
        )
        ev = Event(self._cabi, h, self)
        self._events.append(ev)
        return ev

    def create_application_idle_event(
        self,
        flags: EventFlags | None = None,
    ) -> "Event":
        """
        Create an application-level idle event.

        This event is triggered whenever the following conditions are met:

        - All neural networks in the application are idle.

        - All pre- and post-processing processes are idle.

        As with the neural network idle event, "idle" means that the
        corresponding component cannot make any progress without more input
        being fed into the pipeline. If a component could still emit data, but
        is blocked by doing so because of full output buffers, then this event
        is not triggered.

        Note that there may still be data in the output buffers once this event
        triggers; "idle" just means that no more data will be written to the
        application output buffers as long as no new data is being fed in.
        """
        self._ensure_alive()
        flags_as_int = 0 if flags is None else flags.bitset
        h = self._cabi.abr_event_create_app_idle_event(self._handle, flags_as_int)
        ev = Event(self._cabi, h, self)
        self._events.append(ev)
        return ev

    def close(self) -> None:
        """Release this event set and all its events."""
        if self._closed:
            return
        for ev in list(self._events):
            ev.close()
        assert self._parent is not None
        self._parent._evsets.remove(self)
        self._cabi.abr_evset_free(self._handle)
        self._parent = None
        self._closed = True

    def __enter__(self) -> "EventSet":
        return self

    def __exit__(self, *exc: typing.Any) -> None:
        self.close()


###############################################################################
# Class Event                                                                 #
###############################################################################


class Event(Handle):
    """
    An event that can be polled for a condition.

    Created by :class:`EventSet` factory methods.
    """

    def __init__(self, cabi: cabi.CAbi, handle: typing.Any, parent: EventSet) -> None:
        super().__init__(cabi, handle)
        self._parent: EventSet | None = parent

    @property
    def is_triggered(self) -> bool:
        """``True`` if this event's condition currently holds."""
        self._ensure_alive()
        triggered: bool = self._cabi.abr_event_is_triggered(self._handle)
        return triggered

    def enable(self) -> None:
        """Enable this event so it can cause :meth:`EventSet.poll` to return."""
        self._ensure_alive()
        self._cabi.abr_event_enable(self._handle)

    def disable(self) -> None:
        """Ignore this event in :meth:`EventSet.poll`."""
        self._ensure_alive()
        self._cabi.abr_event_disable(self._handle)

    def close(self) -> None:
        """Release this event."""
        if self._closed:
            return
        if self._parent is not None:
            self._parent._events.remove(self)
        self._cabi.abr_event_free(self._handle)
        self._parent = None
        self._closed = True

    def __enter__(self) -> "Event":
        return self

    def __exit__(self, *exc: typing.Any) -> None:
        self.close()


###############################################################################
# Class Application                                                           #
###############################################################################


class Application(Handle):
    """
    A time-series application instance.

    Can be constructed directly with keyword arguments (auto-discovers and
    loads a suitable library).
    """

    def __init__(
        self,
        lib_or_path: str | Path | Library,
        *,
        lib_search_paths: list[str | Path] | None = None,
        use_default_lib_search_paths: bool = True,
        app_type: str | None = None,
        resources_dir: str | Path | None = None,
        config: VariantDict | None = None,
        logger: logging.Logger | None = None,
    ) -> None:
        # If we're given a library path, then first attempt to open the library.
        if isinstance(lib_or_path, Library):
            lib = lib_or_path
        else:
            lib = Library(
                lib_or_path,
                lib_search_paths=lib_search_paths,
                use_default_lib_search_paths=use_default_lib_search_paths,
                app_type=app_type,
            )

        # On some platforms, resources are located next to the shared library.
        # The shared library itself doesn't know where it is located, so help
        # it by setting the directory here.
        if resources_dir is None:
            resources_dir = lib.path.parent

        # Assemble the final configuration.
        merged_config: VariantDict = {}
        if config is not None:
            merged_config.update(config)
        if "resources_dir" not in merged_config:
            merged_config["resources_dir"] = resources_dir
        if "license_dir" not in merged_config:
            merged_config["license_dir"] = DEFAULT_LICENSE_DIR

        # Create the native handle. The log callback must stay referenced on
        # `self`; the C side holds the raw pointer for the app's lifetime.
        handle = ctypes.c_void_p()
        cfg, n_cfg = Application.make_config(merged_config)
        self._log_cb = Application.make_log_callback(logger)
        lib._cabi.abr_app_create(ctypes.pointer(handle), cfg, n_cfg, self._log_cb, None)

        # Free the native handle if the remaining initialisation fails;
        # there is no __del__, so nothing else would release it.
        with contextlib.ExitStack() as free_handle_on_error:
            free_handle_on_error.callback(lib._cabi.abr_app_free, handle)

            # Initialise Handle and Application-level state
            super().__init__(lib._cabi, handle, class_="abr_app_t")

            # Instantiate all neural networks
            n = lib._cabi.abr_app_get_nn_count(handle)
            self.neural_networks: tuple[NeuralNetwork, ...] = tuple(
                NeuralNetwork(lib._cabi, lib._cabi.abr_app_get_nn(handle, i))
                for i in range(n)
            )

            # List of event sets.
            self._evsets: list[EventSet] = []

            free_handle_on_error.pop_all()

    @staticmethod
    def make_log_callback(logger: logging.Logger | None) -> typing.Any:
        """Return a log callback that may be called from C."""
        if logger is None:
            logger = default_logger

        @cabi.LogCallback
        def callback(user_data: typing.Any, level: int, msg: bytes | None) -> None:
            _emit_log(logger, level, msg)

        return callback

    @staticmethod
    def make_config(
        config: VariantDict,
    ) -> tuple[typing.Any, int]:
        """Convert a Python dictionary into a C-compatible config object."""
        n = len(config)
        arr = (cabi.Config * n)()
        for i, (key, value) in enumerate(config.items()):
            arr[i].key = key.encode() if isinstance(key, str) else key
            if isinstance(value, bool):
                arr[i].value.type = VariantType.BOOLEAN
                arr[i].value.value.boolean = value
            elif isinstance(value, int):
                if value < 0:
                    arr[i].value.type = VariantType.SINT
                    arr[i].value.value.sint = value
                else:
                    arr[i].value.type = VariantType.UINT
                    arr[i].value.value.uint = value
            elif isinstance(value, (str, Path)):
                arr[i].value.type = VariantType.STR
                arr[i].value.value.str = str(value).encode()
            else:
                raise TypeError(f"Unsupported config value type: {type(value)}")
        return arr, n

    def create_event_set(self) -> EventSet:
        """Create a new :class:`EventSet` for polling buffer/network events."""
        self._ensure_alive()
        h = self._cabi.abr_evset_create(self._handle)
        evset = EventSet(self._cabi, h, self)
        self._evsets.append(evset)
        return evset

    def reset(self) -> None:
        """Reset the application to its initial state."""
        self._ensure_alive()
        self._cabi.abr_app_reset(self._handle)

    def close(self) -> None:
        """Release all resources held by this instance."""
        if self._closed:
            return
        for evset in list(self._evsets):
            evset.close()
        self._cabi.abr_app_free(self._handle)
        self._closed = True

    def __enter__(self) -> "Application":
        return self

    def __exit__(self, *exc: typing.Any) -> None:
        self.close()


def install_license(
    library: Library,
    envelope: str,
    *,
    license_dir: str | Path,
    logger: logging.Logger | None = None,
) -> None:
    """
    Verify and install a signed license *envelope* for this device.

    Parameters
    ----------
    library
        A loaded SDK :class:`Library`. A native application handle is created
        from it purely to reach ``abr_app_activate_license``, which verifies the
        envelope's Ed25519 signature against the library's built-in key and
        writes the device-bound license + integrity files under *license_dir*.
    envelope
        The signed machine-file envelope (the ``{enc, sig, alg}`` JSON string).
    license_dir
        Directory the SDK writes the license and integrity files into.
    logger
        Optional logger to receive the SDK's native log messages.

    Notes
    -----
    No model or resources are required: activation runs against the unlicensed
    application, so the ``ABR_ERR_LICENSE`` that ``abr_app_create`` reports is
    expected and tolerated -- the handle it writes is valid regardless. Any
    other creation failure, and any activation failure, raises
    :class:`AbrSdkError`.

    The backend's log output for the throwaway application is buffered and
    replayed (to *logger*, or the default logger) only if activation fails. On
    success it is discarded, since the expected ``ABR_ERR_LICENSE`` -- or, on a
    device that is already licensed, a full model load -- is just noise.
    """
    # `resources_dir` is required even for an activation-only app; default it to
    # the library's directory exactly as Application's constructor does.
    cfg, n_cfg = Application.make_config(
        {"resources_dir": library.path.parent, "license_dir": license_dir}
    )

    # Buffer the backend's log messages; replay them only on failure (below).
    buffered: list[tuple[int, bytes | None]] = []

    @cabi.LogCallback
    def log_cb(user_data: typing.Any, level: int, msg: bytes | None) -> None:
        buffered.append((level, msg))

    handle = ctypes.c_void_p()
    try:
        with contextlib.ExitStack() as free_handle:
            try:
                library._cabi.abr_app_create(
                    ctypes.pointer(handle), cfg, n_cfg, log_cb, None
                )
            except cabi.AbrSdkCAbiError as exc:
                if exc.status != Status.ERR_LICENSE:
                    raise
            # Both success and the tolerated ERR_LICENSE write a valid handle;
            # register the free only now that we know one exists, so a non-license
            # create failure never frees a NULL handle.
            free_handle.callback(library._cabi.abr_app_free, handle)
            library._cabi.abr_app_activate_license(handle, envelope.encode())
    except AbrSdkError:
        sink = default_logger if logger is None else logger
        for level, msg in buffered:
            _emit_log(sink, level, msg)
        raise
