using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.RegularExpressions;
using Aidlab.BLE;
using UnityEngine;
using UnityEngine.Events;
using Stopwatch = System.Diagnostics.Stopwatch;

namespace Aidlab
{
    public class AidlabSDK : MonoBehaviour
    {
        private static bool initAttempted;

        public event Action Connected;
        public event Action Disconnected;
        public event Action<AidlabError> ErrorReceived;
        /// <summary>Receives PID, raw stdout chunk, and device options. PID is 0 for system output or protocols without process IDs.</summary>
        public event Action<int, byte[], ulong> ProcessOutputReceived;
        public event Action<int, byte[], ulong> ProcessErrorReceived;
        /// <summary>
        /// Receives the PID of an exited process and its exit status (0 on success), or null when the firmware does
        /// not report it. All output and error events of the PID come before it.
        /// </summary>
        public event Action<int, int?> ProcessExited;

        public static void init()
        {
            init(null);
        }

	        /// <summary>Connects to the device that advertises <paramref name="deviceName"/>, ignoring case; null or empty
	        /// connects to any Aidlab, Aidlab 2 or Aidmed One.</summary>
	        public static void init(string deviceName)
	        {
            if (FindAnyObjectByType<AidlabSDK>() != null)
                return;

            if (initAttempted)
                return;

            initAttempted = true;

            var sdkObject = new GameObject("AidlabSDK");
            DontDestroyOnLoad(sdkObject);
            var instance = sdkObject.AddComponent<AidlabSDK>();
            if (!string.IsNullOrEmpty(deviceName))
            {
                instance.deviceNameToConnect = deviceName;
            }
	        }

	        [SerializeField] private string deviceNameToConnect = "";
	        [SerializeField] private Signal[] liveSignalsToCollect = { Signal.Ecg, Signal.Temperature, Signal.HeartRate, Signal.Rr };
	        [SerializeField] private Signal[] syncSignalsToStore = new Signal[0];
	        [SerializeField] private DeviceEventType[] eventsToCollect = new DeviceEventType[0];

        private readonly MainThreadWorker mainThreadWorker = new MainThreadWorker();

        // The names Aidlab devices advertise; an empty target name accepts any of them.
        private static readonly string[] AidlabDeviceNames = { "Aidlab", "Aidlab 2", "Aidmed One" };

        /// <summary>
        /// Whether a device advertising <paramref name="name"/> is the target: the target name exactly, ignoring case,
        /// or any Aidlab device when the target name is empty.
        /// </summary>
        internal static bool MatchesTargetName(string name, string target)
        {
            if (string.IsNullOrEmpty(name))
                return false;
            if (string.IsNullOrEmpty(target))
                return Array.Exists(AidlabDeviceNames, known => string.Equals(known, name, StringComparison.OrdinalIgnoreCase));
            return string.Equals(name, target, StringComparison.OrdinalIgnoreCase);
        }
        public static readonly AidlabDelegate aidlabDelegate = new AidlabDelegate();
        public static readonly AidlabSyncDelegate aidlabSyncDelegate = new AidlabSyncDelegate();

        private IBleConnector bleConnector;
#if UNITY_ANDROID && !UNITY_EDITOR
        private AndroidAidlabBackend androidBackend;
#endif
        /// <summary>Firmware revision of the connected device. Unity supports firmware 3.6.0 or newer.</summary>
        public string firmwareRevisionStr = "";
        public string hardwareRevisionStr = "";

        private IntPtr sdk = IntPtr.Zero;
        private GCHandle contextHandle;

        private readonly object txLock = new object();
        private readonly Queue<byte[]> pendingFrames = new Queue<byte[]>();
        private readonly object frameConfirmationLock = new object();
        private Timer frameConfirmationTimer;
        private long frameConfirmationGeneration;
        private bool awaitingFrameConfirmation;
        private TaskCompletionSource<bool> frameConfirmationCompletion;
        private int expectedFrameCallbackThreadId = -1;
        private byte[] trackedOutboundFrame;
        private readonly object commandLock = new object();
        private readonly SemaphoreSlim commandSemaphore = new SemaphoreSlim(1, 1);
        private sealed class PendingProcessCommand
        {
            public readonly TaskCompletionSource<int?> Response = new TaskCompletionSource<int?>();
            public readonly string Name;
            public readonly int StartedProcessId;
            public readonly int? SpawnedProcessId;
            public readonly TaskCompletionSource<int?> SpawnedProcess;
            public bool ResponseReceived;

            public PendingProcessCommand(string name, int startedProcessId, int? spawnedProcessId)
            {
                Name = name;
                StartedProcessId = startedProcessId;
                SpawnedProcessId = spawnedProcessId;
                SpawnedProcess = spawnedProcessId.HasValue ? new TaskCompletionSource<int?>() : null;
            }

            // Whether an accepted or rejected result answers this command rather than a raw command sent before it.
            public bool Answers(int status, int pid, int? processId)
            {
                return processId == StartedProcessId ||
                       // Older A1 firmware reports a full process table without the rejected type.
                       status == SystemRejected && pid == 0 && processId == 0;
            }
        }

        private PendingProcessCommand pendingCommand;
        private TaskCompletionSource<int?> pendingProcessExit;
        private int pendingExitPid = -1;
        private string pendingExitCommand;
        private readonly Dictionary<int, int> activeProcessPids = new Dictionary<int, int>();
        private const int SystemAccepted = 0;
        private const int SystemRejected = 1;
        private const int SystemExited = 2;
        private const int SystemKillFailure = 3;
        private const int SyncProcessId = 7;
        private const int CollectProcessId = 8;
        private const int FrameConfirmationTimeoutMs = 3000;
        private const string FastSyncMinimumFirmware = "3.7.83";
        private readonly object earlyRxLock = new object();
        private readonly Queue<byte[]> earlyRxChunks = new Queue<byte[]>();
        private int earlyRxBytesBuffered;
        private const int EarlyRxMaxBytes = 256 * 1024;

        private float lastDataReceivedTime = -1.0f;
        private bool receivedData = false;

        [Header("Diagnostics")]
        [SerializeField] private bool enablePacketTrace = false;

        private long debugRxPackets;
        private long debugRxBytes;
        private static long debugSignalUpdates;
        private long debugLastRxAtMs = -1;
        private long debugHrCallbacks;
        private long debugRrCallbacks;
        private long debugWearStateCallbacks;
        private long debugLastHrAtMs = -1;
        private long debugLastRrAtMs = -1;
        private long debugLastWearStateAtMs = -1;
        private int debugLastRxSize;
        private long debugCollectSentAtMs = -1;
        private long debugLastNoSignalLogAtMs = -1;

        public bool IsSdkInitialized
        {
            get
            {
#if UNITY_ANDROID && !UNITY_EDITOR
                return androidBackend?.IsConnected == true;
#else
                return sdk != IntPtr.Zero;
#endif
            }
        }
	        public float SecondsSinceLastPacket => lastDataReceivedTime < 0.0f ? -1.0f : (Time.time - lastDataReceivedTime);
	        public string TargetDeviceName => deviceNameToConnect ?? "";

	        public long DebugHrCallbacks => Interlocked.Read(ref debugHrCallbacks);
	        public long DebugRrCallbacks => Interlocked.Read(ref debugRrCallbacks);
	        public long DebugWearStateCallbacks => Interlocked.Read(ref debugWearStateCallbacks);
	        public long DebugLastHrAtMs => Interlocked.Read(ref debugLastHrAtMs);
	        public long DebugLastRrAtMs => Interlocked.Read(ref debugLastRrAtMs);
	        public long DebugLastWearStateAtMs => Interlocked.Read(ref debugLastWearStateAtMs);

	        public Signal[] LiveSignalsToCollect => liveSignalsToCollect;
	        public Signal[] SyncSignalsToStore => syncSignalsToStore;
	        public DeviceEventType[] EventsToCollect => eventsToCollect;

	        /// <summary>
	        /// Sets the data types to collect live and to store, and the events to report. Events are delivered live only
	        /// when listed in collect; events read through synchronization are always delivered. Activity adds activity to
	        /// the live and stored data, PressureWearState adds pressure and Exercise adds motion and orientation to the
	        /// live data, Ecg and Sleep run the detectors of Aidlab 2, and WearState and User need nothing. Exercises come
	        /// only from live data. A collect that fails keeps the events of the previous one.
	        /// </summary>
	        public void SetCollectSignals(Signal[] liveSignals, Signal[] syncSignals, DeviceEventType[] events = null)
	        {
	            liveSignalsToCollect = liveSignals ?? Array.Empty<Signal>();
	            syncSignalsToStore = syncSignals ?? Array.Empty<Signal>();
	            eventsToCollect = events ?? Array.Empty<DeviceEventType>();

	            if (IsSdkInitialized && !string.IsNullOrEmpty(firmwareRevisionStr))
	            {
	                StartCollectAll();
	            }
	        }

        public void SetTargetDeviceName(string value)
        {
            string next = value ?? "";
            if (next == deviceNameToConnect)
                return;

            if (enablePacketTrace)
                Debug.Log($"AidlabSDK: switching target device name '{deviceNameToConnect}' -> '{next}'");
            deviceNameToConnect = next;

#if UNITY_ANDROID && !UNITY_EDITOR
            androidBackend?.SetTargetDeviceName(next);
#else
            bleConnector?.Disconnect();
            bleConnector = null;
#endif

            if (sdk != IntPtr.Zero)
            {
                DestroySdk();
            }

            firmwareRevisionStr = "";
            hardwareRevisionStr = "";
            lastDataReceivedTime = -1.0f;
            receivedData = false;
            debugRxPackets = 0;
            debugRxBytes = 0;
            Interlocked.Exchange(ref debugSignalUpdates, 0);
            debugCollectSentAtMs = -1;
            debugLastNoSignalLogAtMs = -1;
            lock (earlyRxLock)
            {
                earlyRxChunks.Clear();
                earlyRxBytesBuffered = 0;
            }
        }

        public long DebugRxPackets => Interlocked.Read(ref debugRxPackets);
        public long DebugRxBytes => Interlocked.Read(ref debugRxBytes);
        public long DebugSignalUpdates => Interlocked.Read(ref debugSignalUpdates);
        public long DebugLastRxAtMs => Interlocked.Read(ref debugLastRxAtMs);
        public int DebugLastRxSize => Volatile.Read(ref debugLastRxSize);

        private static readonly double StopwatchTicksToMs = 1000.0 / Stopwatch.Frequency;

        public static long DebugNowMs()
        {
            // Unity can target older .NET profiles where Environment.TickCount64 is unavailable.
            // Stopwatch timestamp is monotonic and perfect for relative timing.
            return (long)(Stopwatch.GetTimestamp() * StopwatchTicksToMs);
        }

        public string TransportStatus
        {
            get
            {
#if UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN
                if (bleConnector is BLEConnector win)
                    return win.CurrentStatus.ToString();
                return "Unknown";
#elif UNITY_ANDROID && !UNITY_EDITOR
                return androidBackend?.Status ?? "Starting";
#elif UNITY_STANDALONE_OSX || UNITY_EDITOR_OSX
                if (bleConnector is MacBLEConnector mac)
                    return mac.IsConnected ? "Connected" : "Scanning";
                return "Unknown";
#else
                return "Unsupported";
#endif
            }
        }

        public string TransportLastError
        {
            get
            {
#if UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN
                if (bleConnector is BLEConnector win)
                    return win.LastError;
                return "";
#elif UNITY_ANDROID && !UNITY_EDITOR
                return androidBackend?.LastError ?? "";
#else
                return "";
#endif
            }
        }

        public List<BLEApi.DeviceUpdate> GetKnownDevicesSnapshot()
        {
#if UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN
            if (bleConnector is BLEConnector win)
                return win.GetKnownDevicesSnapshot();
#endif
            return new List<BLEApi.DeviceUpdate>();
        }

        private static readonly AidlabAPI.callbackSampleTime EcgCallback = OnEcg;
        private static readonly AidlabAPI.callbackSampleTime RespirationCallback = OnRespiration;
        private static readonly AidlabAPI.callbackSampleTime TemperatureCallback = OnTemperature;
        private static readonly AidlabAPI.callbackAccelerometer AccelerometerCallback = OnAccelerometer;
        private static readonly AidlabAPI.callbackGyroscope GyroscopeCallback = OnGyroscope;
        private static readonly AidlabAPI.callbackMagnetometer MagnetometerCallback = OnMagnetometer;
        private static readonly AidlabAPI.callbackBatteryLevel BatteryCallback = OnBattery;
        private static readonly AidlabAPI.callbackSteps StepsCallback = OnSteps;
        private static readonly AidlabAPI.callbackOrientation OrientationCallback = OnOrientation;
        private static readonly AidlabAPI.callbackQuaternion QuaternionCallback = OnQuaternion;
        private static readonly AidlabAPI.callbackRespirationRate RespirationRateCallback = OnRespirationRate;
        private static readonly AidlabAPI.callbackHeartRate HeartRateCallback = OnHeartRate;
        private static readonly AidlabAPI.callbackRr RrCallback = OnRr;
        private static readonly AidlabAPI.callbackSoundVolume SoundVolumeCallback = OnSoundVolume;
        private static readonly AidlabAPI.callbackPressure PressureCallback = OnPressure;
        private static readonly AidlabAPI.callbackBodyPosition BodyPositionCallback = OnBodyPosition;
        private static readonly AidlabAPI.callbackSignalQuality SignalQualityCallback = OnSignalQuality;
        private static readonly AidlabAPI.callbackEda EdaCallback = OnEda;
        private static readonly AidlabAPI.callbackGps GpsCallback = OnGps;
        private static readonly AidlabAPI.callbackEvent EventCallback = OnEvent;

        private static readonly AidlabAPI.callbackSyncState SyncStateCallback = OnSyncState;
        private static readonly AidlabAPI.callbackUnsynchronizedSize UnsynchronizedSizeCallback = OnUnsynchronizedSize;

        private static readonly AidlabAPI.callbackBleSend BleSendCallback = OnBleSend;
        private static readonly AidlabAPI.callbackBleReady BleReadyCallback = OnBleReady;
        private static readonly AidlabAPI.callbackBleFrameResult BleFrameResultCallback = OnBleFrameResult;

        private static readonly AidlabAPI.callbackPayload PayloadCallback = OnPayload;
        private static readonly AidlabAPI.callbackProcessError ProcessErrorCallback = OnProcessError;
        private static readonly AidlabAPI.callbackError ErrorCallback = OnSdkError;

        private void Awake()
        {
            lastDataReceivedTime = -1.0f;
            receivedData = false;
            debugRxPackets = 0;
            debugRxBytes = 0;
            Interlocked.Exchange(ref debugSignalUpdates, 0);
            Interlocked.Exchange(ref debugHrCallbacks, 0);
            Interlocked.Exchange(ref debugRrCallbacks, 0);
            Interlocked.Exchange(ref debugWearStateCallbacks, 0);
            Interlocked.Exchange(ref debugLastHrAtMs, -1);
            Interlocked.Exchange(ref debugLastRrAtMs, -1);
            Interlocked.Exchange(ref debugLastWearStateAtMs, -1);
            Volatile.Write(ref debugLastRxSize, 0);
            debugCollectSentAtMs = -1;
            debugLastNoSignalLogAtMs = -1;
            lock (earlyRxLock)
            {
                earlyRxChunks.Clear();
                earlyRxBytesBuffered = 0;
            }
            bleConnector = null;
        }

        private void EnsureConnector()
        {
            if (bleConnector != null)
                return;

#if UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN
            bleConnector = new BLEConnector(this, deviceNameToConnect);
#elif UNITY_STANDALONE_OSX || UNITY_EDITOR_OSX
            bleConnector = new MacBLEConnector(this, deviceNameToConnect);
#elif UNITY_IOS
            bleConnector = new IosBLEConnector(this, deviceNameToConnect);
#elif UNITY_ANDROID && !UNITY_EDITOR
            androidBackend = androidBackend ?? new AndroidAidlabBackend(this, deviceNameToConnect);
#else
            bleConnector = null;
#endif
        }

        private void Update()
        {
            EnsureConnector();
            UpdateLastPacketTime();
#if UNITY_ANDROID && !UNITY_EDITOR
            androidBackend?.Update();
#endif
            bleConnector?.ConnectionProcess();
            mainThreadWorker.Update();
            FlushPendingFrames();
            DebugMaybeLogNoSignals();
        }

	        private void OnDisable()
	        {
	            ShutdownTransport();
	        }

	        public void ShutdownTransport()
	        {
#if UNITY_ANDROID && !UNITY_EDITOR
                androidBackend?.Shutdown();
                androidBackend = null;
#endif
	            var connector = bleConnector;
	            bleConnector = null;
	            connector?.Disconnect();
	            DestroySdk();
	        }

        private void UpdateLastPacketTime()
        {
            if (receivedData)
            {
                lastDataReceivedTime = Time.time;
                receivedData = false;
            }
        }

        public void OnAidlabConnected()
        {
            if (sdk != IntPtr.Zero)
                return;

            debugRxPackets = 0;
            debugRxBytes = 0;
            Interlocked.Exchange(ref debugSignalUpdates, 0);
            Interlocked.Exchange(ref debugHrCallbacks, 0);
            Interlocked.Exchange(ref debugRrCallbacks, 0);
            Interlocked.Exchange(ref debugWearStateCallbacks, 0);
            Interlocked.Exchange(ref debugLastHrAtMs, -1);
            Interlocked.Exchange(ref debugLastRrAtMs, -1);
            Interlocked.Exchange(ref debugLastWearStateAtMs, -1);
            debugCollectSentAtMs = -1;
            debugLastNoSignalLogAtMs = -1;

            string normalizedFirmware = NormalizeFirmwareRevision(firmwareRevisionStr ?? "");
            if (!string.Equals(normalizedFirmware, firmwareRevisionStr ?? "", StringComparison.Ordinal))
            {
                if (enablePacketTrace)
                    Debug.Log($"AidlabSDK: normalized firmware '{firmwareRevisionStr}' -> '{normalizedFirmware}'");
                firmwareRevisionStr = normalizedFirmware;
            }

            byte[] firmware = Encoding.UTF8.GetBytes(firmwareRevisionStr ?? "");
            if (firmware.Length == 0)
            {
                Debug.LogError("AidlabSDK: firmwareRevisionStr is empty");
                return;
            }

            try
            {
                sdk = AidlabAPI.AidlabSDK_create(firmware, firmware.Length);
            }
            catch (Exception ex)
            {
                Debug.LogError($"AidlabSDK: AidlabSDK_create threw: {ex.GetType().Name}: {ex.Message}\n{ex.StackTrace}");
                return;
            }
            if (sdk == IntPtr.Zero)
            {
                Debug.LogError("AidlabSDK: AidlabSDK_create returned null");
                return;
            }

            contextHandle = GCHandle.Alloc(this);
            IntPtr contextPtr = GCHandle.ToIntPtr(contextHandle);
            AidlabAPI.AidlabSDK_set_context(contextPtr, sdk);

            AidlabAPI.AidlabSDK_set_error_callback(ErrorCallback, contextPtr, sdk);
            AidlabAPI.AidlabSDK_set_payload_callback(PayloadCallback, sdk);
            AidlabAPI.AidlabSDK_set_process_error_callback(ProcessErrorCallback, sdk);

            AidlabAPI.AidlabSDK_set_ble_send_callback(BleSendCallback, sdk);
            AidlabAPI.AidlabSDK_set_ble_ready_callback(BleReadyCallback, sdk);
            AidlabAPI.AidlabSDK_set_ble_frame_result_callback(BleFrameResultCallback, sdk);

            FlushEarlyRxChunks();

            AidlabAPI.AidlabSDK_init_callbacks(
                EcgCallback,
                RespirationCallback,
                TemperatureCallback,
                AccelerometerCallback,
                GyroscopeCallback,
                MagnetometerCallback,
                BatteryCallback,
                StepsCallback,
                OrientationCallback,
                QuaternionCallback,
                RespirationRateCallback,
                HeartRateCallback,
                RrCallback,
                SoundVolumeCallback,
                PressureCallback,
                BodyPositionCallback,
                SignalQualityCallback,
                EdaCallback,
                GpsCallback,
                EventCallback,
                sdk);

            AidlabAPI.AidlabSDK_init_synchronization_callbacks(SyncStateCallback, UnsynchronizedSizeCallback, sdk);

            StartCollectAll();
            EnqueueMainThreadEvent(Connected);
        }

        private void FlushEarlyRxChunks()
        {
            Queue<byte[]> chunksToProcess = null;
            lock (earlyRxLock)
            {
                if (earlyRxChunks.Count == 0)
                    return;

                chunksToProcess = new Queue<byte[]>(earlyRxChunks);
                earlyRxChunks.Clear();
                earlyRxBytesBuffered = 0;
            }

            int count = chunksToProcess.Count;
            if (enablePacketTrace)
                Debug.Log($"AidlabSDK: flushing {count} early RX chunks");
            while (chunksToProcess.Count > 0)
            {
                byte[] chunk = chunksToProcess.Dequeue();
                if (chunk == null || chunk.Length == 0)
                    continue;
                AidlabAPI.AidlabSDK_process_ble_chunk(chunk, chunk.Length, sdk);
            }
        }

        private static string NormalizeFirmwareRevision(string value)
        {
            if (string.IsNullOrEmpty(value))
                return "";

            string trimmed = value.Trim();
            var match = Regex.Match(trimmed, @"(\d+)\.(\d+)\.(\d+)");
            if (match.Success)
            {
                return $"{match.Groups[1].Value}.{match.Groups[2].Value}.{match.Groups[3].Value}";
            }
            return trimmed;
        }

        public void StartCollectAll()
        {
            _ = StartCollectAndReportFailure();
        }

        private async Task StartCollectAndReportFailure()
        {
            try
            {
                await StartCollectAllAsync();
            }
            catch (Exception exception)
            {
                ReportError(new AidlabError(
                    AidlabAPI.AidlabErrorCode.Sdk,
                    $"Collect failed: {exception.Message}"
                ));
            }
        }

        /// <summary>
        /// Sends collect with the data types and events of <see cref="SetCollectSignals"/>. Awaiting it gives the collect
        /// PID, or throws <see cref="ProcessRejectedException"/> with the reason when the device rejects the command.
        /// </summary>
        public Task<int?> StartCollectAllAsync()
        {
            debugCollectSentAtMs = DebugNowMs();
#if UNITY_ANDROID && !UNITY_EDITOR
            // The Android SDK rejects data types and events the firmware does not support.
            if (androidBackend == null)
                return Task.FromException<int?>(new InvalidOperationException("SDK is not initialized"));
            return androidBackend.Collect(liveSignalsToCollect, syncSignalsToStore, eventsToCollect);
#else
            if (sdk == IntPtr.Zero)
                return Task.FromException<int?>(new InvalidOperationException("SDK is not initialized"));

            string firmware = firmwareRevisionStr ?? "";
            ulong live = BuildFlags(liveSignalsToCollect);
            ulong store = BuildFlags(syncSignalsToStore);
            ulong events = EventFlags(eventsToCollect);
            ulong unsupportedDataTypes = AidlabAPI.AidlabSDK_unsupported_data_types(live | store, sdk);
            ulong unsupportedEvents = AidlabAPI.AidlabSDK_unsupported_events(events, sdk);
            if (unsupportedDataTypes != 0 || unsupportedEvents != 0)
            {
                return Task.FromException<int?>(new InvalidOperationException(
                    $"Firmware {firmware} does not support {UnsupportedNames(unsupportedDataTypes, unsupportedEvents)}"
                ));
            }

            // The core adds the data types the events need and keeps only the detectors of Aidlab 2 in the events
            // mask, which only the v4 path sends.
            ulong detectors = events;
            AidlabAPI.AidlabSDK_collect_flags(ref live, ref store, ref detectors, sdk);
            IntPtr collectSdk = sdk;
            ulong previous = AidlabAPI.AidlabSDK_set_events(events, collectSdk);

            Task<int?> collect;
            if (ParseMajorFirmware(firmware) >= 4)
                collect = StartCollectV4Flags((uint)live, (uint)store, (uint)detectors);
            else if (CompareFirmware(firmware, "3.7.80") >= 0)
                collect = StartCollectV3Flags((uint)live, (uint)store);
            else
                collect = StartCollectV3Binary((uint)live, (uint)store);
            return KeepPreviousEventsOnFailure(collect, collectSdk, previous);
#endif
        }

        // A collect that fails keeps the events of the previous one. A disconnect destroys the core, and its events.
        private async Task<int?> KeepPreviousEventsOnFailure(Task<int?> collect, IntPtr collectSdk, ulong previous)
        {
            try
            {
                return await collect;
            }
            catch
            {
                if (sdk == collectSdk)
                    AidlabAPI.AidlabSDK_set_events(previous, collectSdk);
                throw;
            }
        }

	        private Task<int?> StartCollectV3Flags(uint liveFlags, uint syncFlags)
	        {
            string liveHex = liveFlags.ToString("X8");
            string syncHex = syncFlags.ToString("X8");
            if (enablePacketTrace)
                Debug.Log($"AidlabSDK: collect flags {liveHex} {syncHex}");
            int? spawnedProcessId = HasCollectAutoSyncBug() ? (int?)SyncProcessId : null;
            int destinationPid = 0;
            if (SendsToServicePids())
            {
                lock (commandLock)
                    destinationPid = activeProcessPids.TryGetValue(CollectProcessId, out int pid) ? pid : 0;
            }
            return SendProcessCommandAsync(
                $"collect flags {liveHex} {syncHex}",
                CollectProcessId,
                spawnedProcessId,
                destinationPid
            );
        }

	        private Task<int?> StartCollectV3Binary(uint liveFlags, uint syncFlags)
	        {
            byte[] prefix = Encoding.UTF8.GetBytes("collect on ");
            byte[] payload = new byte[prefix.Length + 8];
            Buffer.BlockCopy(prefix, 0, payload, 0, prefix.Length);

            int offset = prefix.Length;
            payload[offset++] = (byte)((liveFlags >> 24) & 0xFF);
            payload[offset++] = (byte)((liveFlags >> 16) & 0xFF);
            payload[offset++] = (byte)((liveFlags >> 8) & 0xFF);
            payload[offset++] = (byte)(liveFlags & 0xFF);
            payload[offset++] = (byte)((syncFlags >> 24) & 0xFF);
            payload[offset++] = (byte)((syncFlags >> 16) & 0xFF);
            payload[offset++] = (byte)((syncFlags >> 8) & 0xFF);
            payload[offset] = (byte)(syncFlags & 0xFF);

            if (enablePacketTrace)
                Debug.Log($"AidlabSDK: collect on (binary), liveFlags=0x{liveFlags:X8} syncFlags=0x{syncFlags:X8}");
            int destinationPid;
            lock (commandLock)
                destinationPid = activeProcessPids.TryGetValue(CollectProcessId, out int pid) ? pid : 0;
            return SendProcessPayloadAsync(payload, CollectProcessId, destinationPid: destinationPid);
        }

		        private Task<int?> StartCollectV4Flags(uint liveFlags, uint syncFlags, uint detectors)
		        {
            string liveHex = liveFlags.ToString("X8");
            string syncHex = syncFlags.ToString("X8");
            // Sent also when empty: each collect replaces all lists, so an empty mask turns the detectors off.
            string eventsHex = detectors.ToString("X8");
            if (enablePacketTrace)
                Debug.Log($"AidlabSDK: collect flags {liveHex} {syncHex} {eventsHex} (v4)");
            return SendProcessCommandAsync($"collect flags {liveHex} {syncHex} {eventsHex}", CollectProcessId);
        }

	        private static uint BuildFlags(Signal[] signals)
	        {
	            // Collect flags set bit n for the data type with value n.
	            if (signals == null || signals.Length == 0)
	                return 0;

	            uint flags = 0;
	            foreach (Signal signal in signals)
	            {
	                int bit = (int)signal;
	                if (bit < 0 || bit > 31)
	                    continue;
	                flags |= (1u << bit);
	            }
	            return flags;
	        }

        // Bit n for the DeviceEventType with value n.
        private static uint EventFlags(DeviceEventType[] events)
        {
            uint flags = 0;
            if (events == null)
                return flags;
            foreach (DeviceEventType eventType in events)
            {
                int bit = (int)eventType;
                if (bit >= 0 && bit <= 31)
                    flags |= 1u << bit;
            }
            return flags;
        }

        private static string UnsupportedNames(ulong dataTypes, ulong events)
        {
            var names = new List<string>();
            for (int bit = 0; bit < 64; bit++)
            {
                if ((dataTypes & (1UL << bit)) != 0)
                    names.Add(((Signal)bit).ToString());
            }
            for (int bit = 0; bit < 64; bit++)
            {
                if ((events & (1UL << bit)) != 0)
                    names.Add(((DeviceEventType)bit).ToString());
            }
            return string.Join(", ", names);
        }

        private static int CompareFirmware(string firmware, string reference)
        {
            int[] a = ParseVersionParts(firmware);
            int[] b = ParseVersionParts(reference);

            for (int i = 0; i < 3; i++)
            {
                if (a[i] != b[i])
                    return a[i].CompareTo(b[i]);
            }
            return 0;
        }

        // Aidlab 1 up to 3.7.113 takes a new process slot for every collect sent to PID 0, and four exhaust its process
        // table, so commands for its running collect or sync go to their PIDs.
        private bool SendsToServicePids()
        {
            return CompareFirmware(firmwareRevisionStr ?? "", "3.7.113") <= 0;
        }

        private bool HasCollectAutoSyncBug()
        {
            return CompareFirmware(firmwareRevisionStr, "3.7.84") >= 0 &&
                   CompareFirmware(firmwareRevisionStr, "3.7.110") <= 0;
        }

        private static int[] ParseVersionParts(string value)
        {
            int[] parts = { 0, 0, 0 };
            if (string.IsNullOrEmpty(value))
                return parts;

            string[] raw = value.Trim().Split('.');
            for (int i = 0; i < 3 && i < raw.Length; i++)
            {
                int.TryParse(raw[i], out parts[i]);
            }
            return parts;
        }

        private static int ParseMajorFirmware(string firmware)
        {
            if (string.IsNullOrEmpty(firmware))
                return 0;

            int dot = firmware.IndexOf('.');
            string majorStr = dot >= 0 ? firmware.Substring(0, dot) : firmware;
            if (int.TryParse(majorStr, out int major))
                return major;
            return 0;
        }

        public void OnAidlabDataReceived(BLEApi.BLEData bleData)
        {
            receivedData = true;

            if (sdk == IntPtr.Zero)
                return;

            if (!AidlabCharacteristicsUUID.CmdUUID.Equals(bleData.serviceUuid, bleData.characteristicUuid))
                return;

            AidlabAPI.AidlabSDK_process_ble_chunk(bleData.buf, bleData.size, sdk);
        }

        public void OnAidlabDisconnected()
        {
            lastDataReceivedTime = -1.0f;
            receivedData = false;
            var connector = bleConnector;
            bleConnector = null;
            connector?.Disconnect();
            DestroySdk();
            firmwareRevisionStr = "";
            hardwareRevisionStr = "";
            EnqueueMainThreadEvent(Disconnected);
        }

	        internal static void EnqueueMainThreadEvent(Action action)
	        {
	            if (action == null)
	                return;

	            var evt = new UnityEvent();
	            evt.AddListener(() => action());
	            MainThreadWorker.Enqueue(evt);
	        }

        /// <summary>
        /// Sends a raw payload to a runtime destination PID; use 0 for shell commands. Payloads go out in call order,
        /// each after the command in flight. Completes once the device has the payload and fails when it does not
        /// reach the device. It waits for no process result: a shell command answers as PID 0 output of
        /// <see cref="ProcessOutputReceived"/>, so wait for that answer before sending the next shell command.
        /// An empty payload closes the process's stdin (end of file).
        /// </summary>
        public Task SendPayloadAsync(byte[] payload, int processId = 0)
        {
            if (processId < 0 || processId > 0xFFFF)
                return Task.FromException(new ArgumentOutOfRangeException(nameof(processId)));
            if (payload == null)
                return Task.FromException(new ArgumentNullException(nameof(payload)));
            // The shell takes only commands; an empty payload is end of file for a process.
            if (payload.Length == 0 && processId == 0)
                return Task.FromException(new ArgumentException("PID 0 takes no empty payload", nameof(payload)));
#if UNITY_ANDROID && !UNITY_EDITOR
            return androidBackend != null
                ? androidBackend.Send(payload, processId)
                : Task.FromException(new InvalidOperationException("SDK is not initialized"));
#else
            return SendRawPayloadAsync(payload, processId);
#endif
        }

        /// <summary>Sends a shell command to PID 0 as a NUL-terminated <see cref="SendPayloadAsync"/>.</summary>
        public Task SendCommandAsync(string command)
        {
            if (string.IsNullOrEmpty(command))
                return Task.FromException(new ArgumentException("Command must not be empty", nameof(command)));
            return SendPayloadAsync(Encoding.UTF8.GetBytes(command + "\0"));
        }

        private async Task SendRawPayloadAsync(byte[] payload, int processId)
        {
            await commandSemaphore.WaitAsync();
            try
            {
                if (sdk == IntPtr.Zero)
                    throw new InvalidOperationException("SDK is not initialized");
                if (!TrySendPayload(payload, processId, out Task frameConfirmation))
                    throw new InvalidOperationException("Previous BLE frame is not confirmed");
                await frameConfirmation;
            }
            finally
            {
                commandSemaphore.Release();
            }
        }

        private Task<int?> SendProcessCommandAsync(
            string command,
            int startedProcessId,
            int? spawnedProcessId = null,
            int destinationPid = 0
        )
        {
            byte[] payload = Encoding.UTF8.GetBytes(command + "\0");
            return SendProcessPayloadAsync(payload, startedProcessId, spawnedProcessId, destinationPid);
        }

        public Task<int?> StartSynchronization()
        {
#if UNITY_ANDROID && !UNITY_EDITOR
            return androidBackend != null
                ? androidBackend.StartSynchronization()
                : Task.FromException<int?>(new InvalidOperationException("SDK is not initialized"));
#else
            return SendProcessCommandAsync(SynchronizationStartCommand(), SyncProcessId);
#endif
        }

        private string SynchronizationStartCommand()
        {
            return CompareFirmware(firmwareRevisionStr ?? "", FastSyncMinimumFirmware) >= 0
                ? "sync fast"
                : "sync start";
        }

        public Task<int?> StopSynchronization()
        {
#if UNITY_ANDROID && !UNITY_EDITOR
            return androidBackend != null
                ? androidBackend.StopSynchronization()
                : Task.FromException<int?>(new InvalidOperationException("SDK is not initialized"));
#else
            int? activePid = null;
            lock (commandLock)
            {
                if (activeProcessPids.TryGetValue(SyncProcessId, out int pid))
                    activePid = pid;
            }
            if (!activePid.HasValue)
                return Task.FromResult<int?>(null);
            return SendsToServicePids()
                ? SendActiveProcessCommandAsync("sync stop", activePid.Value)
                : StopSynchronizationThroughShellAsync();
#endif
        }

        private async Task<int?> StopSynchronizationThroughShellAsync()
        {
            // Without a running sync the shell accepts the stop with PID 0, and no exited follows.
            int? pid = await SendProcessCommandAsync("sync stop", SyncProcessId);
            return pid == 0 ? null : pid;
        }

        public Task<int?> ClearSynchronization()
        {
#if UNITY_ANDROID && !UNITY_EDITOR
            return androidBackend != null
                ? androidBackend.ClearSynchronization()
                : Task.FromException<int?>(new InvalidOperationException("SDK is not initialized"));
#else
            return SendProcessCommandAsync("sync clear", SyncProcessId);
#endif
        }

        public Task<int?> StopCollect()
        {
#if UNITY_ANDROID && !UNITY_EDITOR
            return androidBackend != null
                ? androidBackend.StopCollect()
                : Task.FromException<int?>(new InvalidOperationException("SDK is not initialized"));
#else
            if (sdk == IntPtr.Zero)
                return Task.FromException<int?>(new InvalidOperationException("SDK is not initialized"));
            int destinationPid = 0;
            if (SendsToServicePids())
            {
                lock (commandLock)
                    destinationPid = activeProcessPids.TryGetValue(CollectProcessId, out int pid) ? pid : 0;
            }
            return SendProcessCommandAsync("collect off", CollectProcessId, destinationPid: destinationPid);
#endif
        }

        private async Task<int?> SendProcessPayloadAsync(
            byte[] payload,
            int startedProcessId,
            int? spawnedProcessId = null,
            int destinationPid = 0
        )
        {
            await commandSemaphore.WaitAsync();
            try
            {
                return await SendProcessPayloadLockedAsync(payload, startedProcessId, spawnedProcessId, destinationPid);
            }
            finally
            {
                commandSemaphore.Release();
            }
        }

        private async Task<int?> SendProcessPayloadLockedAsync(
            byte[] payload,
            int startedProcessId,
            int? spawnedProcessId,
            int destinationPid
        )
        {
            if (sdk == IntPtr.Zero)
                throw new InvalidOperationException("SDK is not initialized");

            var completion = new PendingProcessCommand(CommandName(payload), startedProcessId, spawnedProcessId);
            bool expectsShellResponse = destinationPid == 0;
            completion.ResponseReceived = !expectsShellResponse;
            if (expectsShellResponse || completion.SpawnedProcess != null)
            {
                lock (commandLock)
                {
                    if (pendingCommand != null)
                        throw new InvalidOperationException("Another process command is already pending");
                    pendingCommand = completion;
                }
            }

            try
            {
                if (!TrySendPayload(
                    payload,
                    destinationPid,
                    out Task frameConfirmation,
                    processCommand: !expectsShellResponse
                ))
                    throw new InvalidOperationException("Previous BLE frame is not confirmed");

                if (expectsShellResponse || completion.SpawnedProcess != null)
                {
                    Task.Delay(TimeSpan.FromSeconds(6)).ContinueWith(_ =>
                    {
                        CompletePendingCommand(
                            completion,
                            new TimeoutException("Timed out waiting for process command result")
                        );
                    });
                }
                await frameConfirmation;
                int? result = expectsShellResponse ? await completion.Response.Task : destinationPid;
                // Firmware 3.7.84-3.7.110 starts a sync with collect and refuses it while another sync runs; the
                // collect succeeded either way.
                if (completion.SpawnedProcess != null)
                    await completion.SpawnedProcess.Task;
                return result;
            }
            finally
            {
                lock (commandLock)
                {
                    if (ReferenceEquals(pendingCommand, completion))
                        pendingCommand = null;
                }
            }
        }

        private async Task<int?> SendActiveProcessCommandAsync(string command, int pid)
        {
            await commandSemaphore.WaitAsync();
            TaskCompletionSource<int?> completion = null;
            try
            {
                if (sdk == IntPtr.Zero)
                    throw new InvalidOperationException("SDK is not initialized");

                byte[] payload = Encoding.UTF8.GetBytes(command + "\0");
                completion = new TaskCompletionSource<int?>();
                lock (commandLock)
                {
                    if (pendingProcessExit != null)
                        throw new InvalidOperationException("Another process exit is already pending");
                    pendingProcessExit = completion;
                    pendingExitPid = pid;
                    pendingExitCommand = command.Split(' ')[0];
                }

                if (!TryBeginFrameConfirmation(out Task frameConfirmation))
                    throw new InvalidOperationException("Previous BLE frame is not confirmed");
                if (!EmitTrackedFrame(() =>
                    AidlabAPI.AidlabSDK_send_process_command(payload, payload.Length, pid, sdk)))
                {
                    OnTransportFailure("SDK rejected the BLE frame");
                }
                Task.Delay(TimeSpan.FromSeconds(6)).ContinueWith(_ =>
                {
                    CompletePendingProcessExit(
                        completion,
                        new TimeoutException("Timed out waiting for process exit")
                    );
                });
                await frameConfirmation;
                int? result = await completion.Task;
                return result;
            }
            finally
            {
                lock (commandLock)
                {
                    if (completion != null && ReferenceEquals(pendingProcessExit, completion))
                    {
                        pendingProcessExit = null;
                        pendingExitPid = -1;
                        pendingExitCommand = null;
                    }
                }
                commandSemaphore.Release();
            }
        }

        private void CompletePendingProcessExit(TaskCompletionSource<int?> expected, Exception exception)
        {
            lock (commandLock)
            {
                if (!ReferenceEquals(pendingProcessExit, expected))
                    return;
                pendingProcessExit = null;
                pendingExitPid = -1;
                pendingExitCommand = null;
            }
            expected.TrySetException(exception);
        }

        private void CompletePendingCommand(PendingProcessCommand expected, Exception exception)
        {
            lock (commandLock)
            {
                if (!ReferenceEquals(pendingCommand, expected))
                    return;
                pendingCommand = null;
            }
            expected.Response.TrySetException(exception);
            expected.SpawnedProcess?.TrySetException(exception);
        }

        private void HandleProcessLifecycle(int status, int pid, int? processId, int? detail)
        {
            TaskCompletionSource<int?> commandCompletion = null;
            TaskCompletionSource<int?> spawnedCompletion = null;
            TaskCompletionSource<int?> exitCompletion = null;
            string commandName = null;
            string exitCommand = null;
            RejectionReason reason = Rejection(status, detail);
            lock (commandLock)
            {
                // An accepted with PID 0 names no process, e.g. sync stop without a sync.
                if (status == SystemAccepted && pid != 0 && processId.HasValue)
                    activeProcessPids[processId.Value] = pid;
                // Aidlab 1 up to 3.7.110 reports an ended collect process with process ID 1; the PID is unique.
                else if (status == SystemExited)
                {
                    foreach (int type in new List<int>(activeProcessPids.Keys))
                    {
                        if (activeProcessPids[type] == pid)
                            activeProcessPids.Remove(type);
                    }
                }

                if (status == SystemAccepted || status == SystemRejected)
                {
                    if (pendingCommand != null && !pendingCommand.ResponseReceived)
                    {
                        if (pendingCommand.Answers(status, pid, processId))
                        {
                            pendingCommand.ResponseReceived = true;
                            commandCompletion = pendingCommand.Response;
                            commandName = pendingCommand.Name;
                        }
                    }
                    else if (pendingCommand?.SpawnedProcess != null &&
                             (status == SystemRejected || processId == pendingCommand.SpawnedProcessId))
                    {
                        spawnedCompletion = pendingCommand.SpawnedProcess;
                    }
                }
                // A stop through the sync PID waits for exited; accepted keeps it waiting, and rejected or kill_failure
                // fails it.
                if (status != SystemAccepted && pendingProcessExit != null && pendingExitPid == pid)
                {
                    exitCompletion = pendingProcessExit;
                    exitCommand = pendingExitCommand;
                    pendingProcessExit = null;
                    pendingExitPid = -1;
                    pendingExitCommand = null;
                }
            }

            if (status == SystemAccepted)
                commandCompletion?.TrySetResult(pid);
            else
                commandCompletion?.TrySetException(
                    new ProcessRejectedException(reason, $"{commandName}: {RejectionText(reason)}"));
            spawnedCompletion?.TrySetResult(status == SystemAccepted ? (int?)pid : null);
            if (status == SystemExited)
            {
                exitCompletion?.TrySetResult(pid);
                EnqueueMainThreadEvent(() => ProcessExited?.Invoke(pid, detail));
            }
            else
            {
                exitCompletion?.TrySetException(
                    new ProcessRejectedException(reason, $"{exitCommand}: {RejectionText(reason)}"));
            }
        }

        // The first word of a command, e.g. "collect", which names it in a rejection.
        private static string CommandName(byte[] command)
        {
            int length = 0;
            while (length < command.Length && command[length] != (byte)' ' && command[length] != 0)
                length++;
            return Encoding.UTF8.GetString(command, 0, length);
        }

        // The cause as a rejection message reads it, e.g. "collect: invalid arguments".
        private static string RejectionText(RejectionReason reason)
        {
            switch (reason)
            {
                case RejectionReason.UnknownCommand: return "unknown command";
                case RejectionReason.NoFreeSlot: return "no free process slot";
                case RejectionReason.OutOfMemory: return "out of memory";
                case RejectionReason.CommandTooLong: return "command too long";
                case RejectionReason.InvalidArgument: return "invalid arguments";
                case RejectionReason.Busy: return "busy";
                case RejectionReason.NoSuchProcess: return "no such process";
                case RejectionReason.NotPermitted: return "not permitted";
                case RejectionReason.NotConnected: return "not connected";
                case RejectionReason.ShuttingDown: return "shutting down";
                default: return "unknown reason";
            }
        }

        private static RejectionReason Rejection(int status, int? detail)
        {
            // Aidlab 1 keeps kill_failure (status 3) for released SDKs; it is a rejection with ESRCH.
            if (status == SystemKillFailure)
                return RejectionReason.NoSuchProcess;
            return detail.HasValue && Enum.IsDefined(typeof(RejectionReason), detail.Value)
                ? (RejectionReason)detail.Value
                : RejectionReason.Unknown;
        }

        private bool TrySendPayload(
            byte[] payload,
            int processId,
            out Task frameConfirmation,
            bool processCommand = false
        )
        {
            if (!TryBeginFrameConfirmation(out frameConfirmation))
                return false;

            if (!EmitTrackedFrame(() =>
            {
                if (processCommand)
                    AidlabAPI.AidlabSDK_send_process_command(payload, payload.Length, processId, sdk);
                else
                    AidlabAPI.AidlabSDK_send(payload, payload.Length, processId, sdk);
            }))
            {
                OnTransportFailure("SDK rejected the BLE frame");
            }
            return true;
        }

        private void DestroySdk()
        {
            CompleteFrameConfirmation(new InvalidOperationException("SDK was destroyed"));
            PendingProcessCommand completion;
            TaskCompletionSource<int?> processExit;
            lock (commandLock)
            {
                completion = pendingCommand;
                pendingCommand = null;
                processExit = pendingProcessExit;
                pendingProcessExit = null;
                pendingExitPid = -1;
                pendingExitCommand = null;
                activeProcessPids.Clear();
            }
            completion?.Response.TrySetException(new InvalidOperationException("SDK was destroyed"));
            completion?.SpawnedProcess?.TrySetException(new InvalidOperationException("SDK was destroyed"));
            processExit?.TrySetException(new InvalidOperationException("SDK was destroyed"));

            if (sdk != IntPtr.Zero)
            {
                AidlabAPI.AidlabSDK_destroy(sdk);
                sdk = IntPtr.Zero;
            }

            if (contextHandle.IsAllocated)
                contextHandle.Free();

            lock (txLock)
            {
                pendingFrames.Clear();
            }
        }

        private void FlushPendingFrames()
        {
            byte[] frame = null;
            lock (txLock)
            {
                if (pendingFrames.Count > 0)
                    frame = pendingFrames.Dequeue();
            }

            if (frame == null)
                return;

            if (frame.Length > 0)
            {
                if (enablePacketTrace)
                {
                    string hex = BuildHexPreview(frame, frame.Length, 24);
                    Debug.Log($"AidlabSDK TX frame {frame.Length}B: {hex}");
                }
            }
            bleConnector?.Write(frame);
        }

        internal void OnTransportFailure(string message)
        {
            EnqueueMainThreadEvent(() =>
            {
                CompleteFrameConfirmation(new InvalidOperationException(message));
                ReportError(new AidlabError(AidlabAPI.AidlabErrorCode.Transport, message));
                OnAidlabDisconnected();
            });
        }

        internal void OnTransportFrameWritten(byte[] payload)
        {
            lock (frameConfirmationLock)
            {
                if (!ReferenceEquals(payload, trackedOutboundFrame))
                    return;
                trackedOutboundFrame = null;
            }

            if (!UsesV4Protocol())
            {
                CompleteFrameConfirmation();
                return;
            }

            Timer previousTimer;
            long generation;
            lock (frameConfirmationLock)
            {
                if (!awaitingFrameConfirmation)
                    return;

                generation = ++frameConfirmationGeneration;
                previousTimer = frameConfirmationTimer;
                frameConfirmationTimer = new Timer(
                    _ => EnqueueMainThreadEvent(() => HandleFrameConfirmationTimeout(generation)),
                    null,
                    FrameConfirmationTimeoutMs,
                    Timeout.Infinite
                );
            }
            previousTimer?.Dispose();
        }

        private bool UsesV4Protocol()
        {
            return ParseMajorFirmware(firmwareRevisionStr ?? "") >= 4;
        }

        private bool TryBeginFrameConfirmation(out Task confirmation)
        {
            Timer previousTimer = null;
            lock (frameConfirmationLock)
            {
                if (awaitingFrameConfirmation)
                {
                    confirmation = null;
                    return false;
                }

                awaitingFrameConfirmation = true;
                frameConfirmationCompletion = new TaskCompletionSource<bool>();
                confirmation = frameConfirmationCompletion.Task;
                ++frameConfirmationGeneration;
                previousTimer = frameConfirmationTimer;
                frameConfirmationTimer = null;
            }
            previousTimer?.Dispose();
            return true;
        }

        private bool EmitTrackedFrame(Action action)
        {
            int threadId = Thread.CurrentThread.ManagedThreadId;
            lock (frameConfirmationLock)
            {
                expectedFrameCallbackThreadId = threadId;
            }

            action();

            lock (frameConfirmationLock)
            {
                bool emitted = expectedFrameCallbackThreadId != threadId;
                if (!emitted)
                    expectedFrameCallbackThreadId = -1;
                return emitted;
            }
        }

        private void CompleteFrameConfirmation(Exception error = null)
        {
            Timer deadline;
            TaskCompletionSource<bool> completion;
            lock (frameConfirmationLock)
            {
                awaitingFrameConfirmation = false;
                ++frameConfirmationGeneration;
                deadline = frameConfirmationTimer;
                completion = frameConfirmationCompletion;
                frameConfirmationTimer = null;
                frameConfirmationCompletion = null;
                expectedFrameCallbackThreadId = -1;
                trackedOutboundFrame = null;
            }
            deadline?.Dispose();
            if (error == null)
                completion?.TrySetResult(true);
            else
                completion?.TrySetException(error);
        }

        private void HandleFrameConfirmationTimeout(long generation)
        {
            Timer deadline;
            TaskCompletionSource<bool> completion;
            lock (frameConfirmationLock)
            {
                if (!awaitingFrameConfirmation || frameConfirmationGeneration != generation)
                    return;

                awaitingFrameConfirmation = false;
                ++frameConfirmationGeneration;
                deadline = frameConfirmationTimer;
                completion = frameConfirmationCompletion;
                frameConfirmationTimer = null;
                frameConfirmationCompletion = null;
                expectedFrameCallbackThreadId = -1;
                trackedOutboundFrame = null;
            }
            deadline?.Dispose();

            var timeout = new TimeoutException("BLE frame confirmation timed out");
            completion?.TrySetException(timeout);
            ReportError(new AidlabError(
                AidlabAPI.AidlabErrorCode.Transport,
                "BLE frame confirmation timed out"
            ));
            OnAidlabDisconnected();
        }

        public void OnAidlabDataReceived(byte[] data, int size)
        {
            receivedData = true;

            if (data == null || size <= 0)
                return;

            long packetIndex = Interlocked.Increment(ref debugRxPackets);
            Interlocked.Add(ref debugRxBytes, size);
            Interlocked.Exchange(ref debugLastRxAtMs, DebugNowMs());
            Volatile.Write(ref debugLastRxSize, size);

            if (enablePacketTrace && (packetIndex <= 5 || (packetIndex % 200) == 0))
            {
                string hex = BuildHexPreview(data, size, 32);
                if (packetIndex <= 5)
                    Debug.Log($"AidlabSDK RX[{packetIndex}] {size}B: {hex}");
            }

            if (sdk == IntPtr.Zero)
            {
                lock (earlyRxLock)
                {
                    if (earlyRxBytesBuffered + size <= EarlyRxMaxBytes)
                    {
                        var copy = new byte[size];
                        Buffer.BlockCopy(data, 0, copy, 0, size);
                        earlyRxChunks.Enqueue(copy);
                        earlyRxBytesBuffered += size;
                    }
                }
                return;
            }

            AidlabAPI.AidlabSDK_process_ble_chunk(data, size, sdk);
        }

        private static string BuildHexPreview(byte[] data, int size, int maxBytes)
        {
            if (data == null || size <= 0)
                return "";

            int len = Math.Min(size, Math.Max(0, maxBytes));
            var sb = new StringBuilder(len * 2 + 4);
            for (int i = 0; i < len; i++)
            {
                sb.Append(data[i].ToString("X2"));
            }
            if (len < size)
                sb.Append("…");
            return sb.ToString();
        }

        private void DebugMaybeLogNoSignals()
        {
            if (sdk == IntPtr.Zero)
                return;

            long collectAt = Interlocked.Read(ref debugCollectSentAtMs);
            if (collectAt <= 0)
                return;

            long now = DebugNowMs();
            if (now - collectAt < 2500)
                return;

            long rxPacketsSnapshot = Interlocked.Read(ref debugRxPackets);
            if (rxPacketsSnapshot <= 0)
                return;

            long updatesSnapshot = Interlocked.Read(ref debugSignalUpdates);
            if (updatesSnapshot > 0)
                return;

            long lastLogAt = Interlocked.Read(ref debugLastNoSignalLogAtMs);
            if (lastLogAt > 0 && now - lastLogAt < 5000)
                return;

            int lastSize = Volatile.Read(ref debugLastRxSize);
            long lastRxAt = Interlocked.Read(ref debugLastRxAtMs);
            long ageMs = lastRxAt > 0 ? (now - lastRxAt) : -1;

            Debug.LogWarning(
                $"AidlabSDK: RX={rxPacketsSnapshot} packets ({Interlocked.Read(ref debugRxBytes)}B) but no signal callbacks after collect. " +
                $"LastRX={lastSize}B ageMs={ageMs}"
            );
            Interlocked.Exchange(ref debugLastNoSignalLogAtMs, now);
        }

        internal static void DebugNotifySignalUpdated()
        {
            Interlocked.Increment(ref debugSignalUpdates);
        }

        internal void ReportError(AidlabError error)
        {
            if (error == null)
                return;
            aidlabDelegate.DidReceiveError(error);
            EnqueueMainThreadEvent(() => ErrorReceived?.Invoke(error));
            Debug.LogError(error.ToString());
        }

#if UNITY_ANDROID && !UNITY_EDITOR
        internal void AndroidDidConnect(string firmwareRevision, string hardwareRevision)
        {
            firmwareRevisionStr = NormalizeFirmwareRevision(firmwareRevision ?? "");
            hardwareRevisionStr = hardwareRevision ?? "";
            lastDataReceivedTime = -1.0f;
            receivedData = false;
            debugRxPackets = 0;
            debugRxBytes = 0;
            Interlocked.Exchange(ref debugSignalUpdates, 0);
            StartCollectAll();
            EnqueueMainThreadEvent(Connected);
        }

        internal void AndroidDidDisconnect()
        {
            firmwareRevisionStr = "";
            hardwareRevisionStr = "";
            lastDataReceivedTime = -1.0f;
            receivedData = false;
            EnqueueMainThreadEvent(Disconnected);
        }

        internal void AndroidDidReceiveData()
        {
            receivedData = true;
            Interlocked.Increment(ref debugRxPackets);
            Interlocked.Exchange(ref debugLastRxAtMs, DebugNowMs());
        }

        internal void AndroidDidReceiveProcessOutput(int pid, byte[] payload, ulong options)
        {
            byte[] data = payload ?? Array.Empty<byte>();
            EnqueueMainThreadEvent(() => ProcessOutputReceived?.Invoke(pid, data, options));
        }

        internal void AndroidDidReceiveProcessError(int pid, byte[] payload, ulong options)
        {
            byte[] data = payload ?? Array.Empty<byte>();
            EnqueueMainThreadEvent(() => ProcessErrorReceived?.Invoke(pid, data, options));
        }

        internal void AndroidProcessDidExit(int pid, int? exitStatus)
        {
            EnqueueMainThreadEvent(() => ProcessExited?.Invoke(pid, exitStatus));
        }
#endif

        private static AidlabSDK GetInstance(IntPtr context)
        {
            if (context == IntPtr.Zero)
                return null;

            GCHandle handle = GCHandle.FromIntPtr(context);
            return handle.Target as AidlabSDK;
        }

        private static void OnBleSend(IntPtr context, IntPtr data, int size)
        {
            AidlabSDK instance = GetInstance(context);
            if (instance == null)
                return;

            if (data == IntPtr.Zero || size <= 0)
                return;

            byte[] frame = new byte[size];
            Marshal.Copy(data, frame, 0, size);

            lock (instance.frameConfirmationLock)
            {
                if (instance.expectedFrameCallbackThreadId == Thread.CurrentThread.ManagedThreadId)
                {
                    instance.expectedFrameCallbackThreadId = -1;
                    instance.trackedOutboundFrame = frame;
                }
            }

            if (instance.enablePacketTrace)
            {
                string hex = BuildHexPreview(frame, size, 32);
                Debug.Log($"AidlabSDK: OnBleSend {size}B: {hex}");
            }

            lock (instance.txLock)
            {
                instance.pendingFrames.Enqueue(frame);
            }
        }

        private static void OnBleReady(IntPtr context)
        {
            AidlabSDK instance = GetInstance(context);
            if (instance == null)
                return;

            instance.CompleteFrameConfirmation();
            if (instance.enablePacketTrace)
                Debug.Log("AidlabSDK: BLE ready");
        }

        private static void OnBleFrameResult(IntPtr context, byte result)
        {
            AidlabSDK instance = GetInstance(context);
            if (instance == null || result == 0)
                return;

            // A NAK rejects the frame in flight; the ready callback that follows finds no frame left to confirm.
            instance.CompleteFrameConfirmation(
                new InvalidOperationException($"Device rejected the frame with NAK code {result}"));
        }

        private static void OnPayload(IntPtr context, ushort pid, IntPtr payload, UIntPtr payloadLength, ulong options)
        {
            AidlabSDK instance = GetInstance(context);
            if (instance == null)
                return;

            int length = checked((int)payloadLength.ToUInt64());
            byte[] bytes = new byte[length];
            if (payload != IntPtr.Zero && length > 0)
                Marshal.Copy(payload, bytes, 0, length);

            if (pid == 0 && bytes.Length > 0)
            {
                int status = bytes[0];
                if (status >= SystemAccepted && status <= SystemKillFailure)
                {
                    int lifecyclePid = bytes.Length >= 3 ? bytes[1] | (bytes[2] << 8) : 0;
                    int? processId = bytes.Length >= 4 ? bytes[3] : (int?)null;
                    // Aidlab 1 and released firmware don't send the reason of rejected or the exit status of exited.
                    int? detail = bytes.Length >= 5 ? bytes[4] : (int?)null;
                    instance.HandleProcessLifecycle(status, lifecyclePid, processId, detail);
                }
            }

            EnqueueMainThreadEvent(() => instance.ProcessOutputReceived?.Invoke(pid, bytes, options));
        }

        private static void OnProcessError(IntPtr context, ushort pid, IntPtr payload, UIntPtr payloadLength, ulong options)
        {
            AidlabSDK instance = GetInstance(context);
            if (instance == null)
                return;

            int length = checked((int)payloadLength.ToUInt64());
            byte[] bytes = new byte[length];
            if (payload != IntPtr.Zero && length > 0)
                Marshal.Copy(payload, bytes, 0, length);
            EnqueueMainThreadEvent(() => instance.ProcessErrorReceived?.Invoke(pid, bytes, options));
        }

        private static void OnSdkError(IntPtr context, AidlabAPI.AidlabErrorCode code, IntPtr message)
        {
            if (message == IntPtr.Zero)
                return;

            AidlabSDK instance = GetInstance(context);
            if (instance == null)
                return;

            string text = Marshal.PtrToStringAnsi(message);
            instance.CompleteFrameConfirmation(new InvalidOperationException(text));
            instance.ReportError(new AidlabError(code, text));
        }

        // The core reports live and synchronized data through the same callback; `past` routes synchronized data to
        // aidlabSyncDelegate.
        private static void OnEcg(IntPtr context, bool past, ulong timestamp, float value)
        {
            if (past) aidlabSyncDelegate.DidReceivePastEcg(timestamp, value);
            else aidlabDelegate.DidReceiveECG(timestamp, value);
        }

        private static void OnRespiration(IntPtr context, bool past, ulong timestamp, float value)
        {
            if (past) aidlabSyncDelegate.DidReceivePastRespiration(timestamp, value);
            else aidlabDelegate.DidReceiveRespiration(timestamp, value);
        }

        private static void OnTemperature(IntPtr context, bool past, ulong timestamp, float value)
        {
            if (past) aidlabSyncDelegate.DidReceivePastTemperature(timestamp, value);
            else aidlabDelegate.DidReceiveSkinTemperature(timestamp, value);
        }

        private static void OnAccelerometer(IntPtr context, bool past, ulong timestamp, float ax, float ay, float az)
        {
            if (past) aidlabSyncDelegate.DidReceivePastAccelerometer(timestamp, ax, ay, az);
            else aidlabDelegate.DidReceiveAccelerometer(timestamp, ax, ay, az);
        }

        private static void OnGyroscope(IntPtr context, bool past, ulong timestamp, float gx, float gy, float gz)
        {
            if (past) aidlabSyncDelegate.DidReceivePastGyroscope(timestamp, gx, gy, gz);
            else aidlabDelegate.DidReceiveGyroscope(timestamp, gx, gy, gz);
        }

        private static void OnMagnetometer(IntPtr context, bool past, ulong timestamp, float mx, float my, float mz)
        {
            if (past) aidlabSyncDelegate.DidReceivePastMagnetometer(timestamp, mx, my, mz);
            else aidlabDelegate.DidReceiveMagnetometer(timestamp, mx, my, mz);
        }

        private static void OnBattery(IntPtr context, byte stateOfCharge) { _ = context; aidlabDelegate.DidReceiveBatteryLevel(stateOfCharge); }

        private static void OnSteps(IntPtr context, bool past, ulong timestamp, ulong steps)
        {
            if (past) aidlabSyncDelegate.DidReceivePastSteps(timestamp, steps);
            else aidlabDelegate.DidReceiveSteps(timestamp, steps);
        }

        private static void OnOrientation(IntPtr context, bool past, ulong timestamp, float roll, float pitch, float yaw)
        {
            if (past) aidlabSyncDelegate.DidReceivePastOrientation(timestamp, roll, pitch, yaw);
            else aidlabDelegate.DidReceiveOrientation(timestamp, roll, pitch, yaw);
        }

        private static void OnQuaternion(IntPtr context, bool past, ulong timestamp, float qw, float qx, float qy, float qz)
        {
            if (past) aidlabSyncDelegate.DidReceivePastQuaternion(timestamp, qw, qx, qy, qz);
            else aidlabDelegate.DidReceiveQuaternion(timestamp, qw, qx, qy, qz);
        }

        private static void OnRespirationRate(IntPtr context, bool past, ulong timestamp, uint value)
        {
            if (past) aidlabSyncDelegate.DidReceivePastRespirationRate(timestamp, value);
            else aidlabDelegate.DidReceiveRespirationRate(timestamp, value);
        }

        private static void OnHeartRate(IntPtr context, bool past, ulong timestamp, int heartRate)
        {
            if (past)
            {
                aidlabSyncDelegate.DidReceivePastHeartRate(timestamp, heartRate);
                return;
            }

            AidlabSDK instance = GetInstance(context);
            if (instance != null)
            {
                Interlocked.Increment(ref instance.debugHrCallbacks);
                Interlocked.Exchange(ref instance.debugLastHrAtMs, DebugNowMs());
            }
            aidlabDelegate.DidReceiveHeartRate(timestamp, heartRate);
        }

        private static void OnRr(IntPtr context, bool past, ulong timestamp, int rr)
        {
            if (past)
            {
                aidlabSyncDelegate.DidReceivePastRr(timestamp, rr);
                return;
            }

            AidlabSDK instance = GetInstance(context);
            if (instance != null)
            {
                Interlocked.Increment(ref instance.debugRrCallbacks);
                Interlocked.Exchange(ref instance.debugLastRrAtMs, DebugNowMs());
            }
            aidlabDelegate.DidReceiveRr(timestamp, rr);
        }

        private static void OnSoundVolume(IntPtr context, bool past, ulong timestamp, ushort value)
        {
            if (past) aidlabSyncDelegate.DidReceivePastSoundVolume(timestamp, value);
            else aidlabDelegate.DidReceiveSoundVolume(timestamp, value);
        }

        private static void OnPressure(IntPtr context, bool past, ulong timestamp, int value)
        {
            if (past) aidlabSyncDelegate.DidReceivePastPressure(timestamp, value);
            else aidlabDelegate.DidReceivePressure(timestamp, value);
        }

        private static void OnBodyPosition(IntPtr context, bool past, ulong timestamp, BodyPosition value)
        {
            if (past) aidlabSyncDelegate.DidReceivePastBodyPosition(timestamp, value);
            else aidlabDelegate.DidReceiveBodyPosition(timestamp, value);
        }

        private static void OnSignalQuality(IntPtr context, bool past, ulong timestamp, byte value)
        {
            if (past) aidlabSyncDelegate.DidReceivePastSignalQuality(timestamp, value);
            else aidlabDelegate.DidReceiveSignalQuality(timestamp, value);
        }

        private static void OnEda(IntPtr context, bool past, ulong timestamp, float conductance)
        {
            if (past) aidlabSyncDelegate.DidReceivePastEda(timestamp, conductance);
            else aidlabDelegate.DidReceiveEda(timestamp, conductance);
        }

        private static void OnGps(IntPtr context, bool past, ulong timestamp, float lat, float lon, float alt, float speed, float heading, float hdop)
        {
            if (past) aidlabSyncDelegate.DidReceivePastGps(timestamp, lat, lon, alt, speed, heading, hdop);
            else aidlabDelegate.DidReceiveGps(timestamp, lat, lon, alt, speed, heading, hdop);
        }

        // Unlike data, live and synchronized events share aidlabDelegate.deviceEvent.
        private static void OnEvent(IntPtr context, bool past, IntPtr eventPointer)
        {
            AidlabAPI.AidlabEvent nativeEvent = Marshal.PtrToStructure<AidlabAPI.AidlabEvent>(eventPointer);
            ulong timestamp = nativeEvent.timestamp;
            // The core reports 0 while a period lasts and a negative confidence when the detector reports none.
            ulong? end = nativeEvent.endTimestamp == 0 ? (ulong?)null : nativeEvent.endTimestamp;
            float? confidence = nativeEvent.confidence < 0 ? (float?)null : nativeEvent.confidence;
            int code = nativeEvent.code;
            string aux = Marshal.PtrToStringAnsi(nativeEvent.aux);
            string modelVersion = ModelVersion(nativeEvent.modelVersion);
            DeviceEvent deviceEvent;
            switch ((AidlabAPI.EventType)nativeEvent.type)
            {
                case AidlabAPI.EventType.WearState:
                {
                    AidlabSDK instance = GetInstance(context);
                    if (!past && instance != null)
                    {
                        Interlocked.Increment(ref instance.debugWearStateCallbacks);
                        Interlocked.Exchange(ref instance.debugLastWearStateAtMs, DebugNowMs());
                    }
                    deviceEvent = new WearStateEvent(timestamp, end, confidence, past, (WearState)code);
                    break;
                }
                case AidlabAPI.EventType.PressureWearState:
                    deviceEvent = new PressureWearStateEvent(timestamp, end, confidence, past, (WearState)code);
                    break;
                case AidlabAPI.EventType.User:
                    deviceEvent = new UserEvent(timestamp, end, confidence, past);
                    break;
                case AidlabAPI.EventType.Exercise:
                    deviceEvent = new ExerciseEvent(timestamp, end, confidence, past, (Exercise)code);
                    break;
                case AidlabAPI.EventType.Activity:
                    deviceEvent = new ActivityEvent(timestamp, end, confidence, past, (ActivityType)code);
                    break;
                case AidlabAPI.EventType.Ecg:
                    deviceEvent = new EcgEvent(
                        timestamp, end, confidence, past, code, nativeEvent.subtyp, aux,
                        EcgRhythmOf(code, nativeEvent.subtyp, aux), modelVersion);
                    break;
                case AidlabAPI.EventType.Sleep:
                {
                    SleepPeriod period = Enum.IsDefined(typeof(SleepPeriod), code) ? (SleepPeriod)code : SleepPeriod.Unknown;
                    deviceEvent = new SleepEvent(timestamp, end, confidence, past, period, modelVersion);
                    break;
                }
                default:
                    // Event types this plugin does not know are not delivered.
                    return;
            }
            aidlabDelegate.DidReceiveEvent(deviceEvent);
        }

        // major << 24 | minor << 16 | revision, 0 for none.
        private static string ModelVersion(uint version)
        {
            return version == 0 ? null : $"{version >> 24}.{(version >> 16) & 0xFF}.{version & 0xFFFF}";
        }

        // WFDB rhythm (28) and noise (14) annotations, as eventTypeEcg in shared.h describes them; null for others.
        private static EcgRhythm? EcgRhythmOf(int anntyp, sbyte subtyp, string aux)
        {
            if (anntyp == 14)
                return subtyp != 0 ? EcgRhythm.Noise : EcgRhythm.Unknown;
            if (anntyp != 28)
                return null;
            switch (aux)
            {
                case "(AFIB": return EcgRhythm.AtrialFibrillation;
                case "(SBR": return EcgRhythm.SinusBradycardia;
                case "(N": return EcgRhythm.NormalSinus;
                default: return EcgRhythm.Unknown;
            }
        }

        private static void OnSyncState(IntPtr context, SyncState syncState) { _ = context; aidlabSyncDelegate.SyncStateDidChange(syncState); }
        private static void OnUnsynchronizedSize(IntPtr context, uint size, float bytesPerSecond) { _ = context; aidlabSyncDelegate.DidReceiveUnsynchronizedSize(size, bytesPerSecond); }
    }
}
