package com.grigowashere.aismap.ble.hub;

import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothGatt;
import android.bluetooth.BluetoothGattCallback;
import android.bluetooth.BluetoothGattCharacteristic;
import android.bluetooth.BluetoothGattDescriptor;
import android.bluetooth.BluetoothGattService;
import android.bluetooth.BluetoothManager;
import android.bluetooth.BluetoothProfile;
import android.content.Context;
import android.content.pm.PackageInfo;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;

import com.grigowashere.aismap.BuildConfig;
import com.grigowashere.aismap.utils.LogSender;

import org.json.JSONObject;

import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

/**
 * GATT client for AIS Hub protocol v2: CONTROL writes (JSON), DATA binary frames, optional battery/RSSI.
 */
public class AisHubGattClient {

    private static final String TAG = "AisHubGattClient";
    private static final boolean BLE_LOG = BuildConfig.DEBUG;
    private static final UUID CCCD = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb");
    private static final UUID BATTERY_SERVICE = UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb");
    private static final UUID BATTERY_LEVEL = UUID.fromString("00002a19-0000-1000-8000-00805f9b34fb");

    public interface SessionCallback {
        void onState(@NonNull String state);
        void onError(@NonNull String message);
        void onRssi(int rssi);
        void onBatteryPercent(int percent);
        /** Reassembled JSON from DATA, after chunk merge (per msg_type in protocol). */
        void onDataJson(int msgType, @NonNull JSONObject json);
    }

    private final Context appContext;
    private final Handler mainHandler = new Handler(Looper.getMainLooper());
    // executor: used ONLY for short-lived, non-blocking tasks (attemptConnect body).
    private final ExecutorService executor = Executors.newSingleThreadExecutor();
    // dataExecutor: dedicated to processDataRaw so that BLE notifications
    // are never blocked behind long-running loops (RSSI, reconnect, watchdog).
    // Using single-thread to preserve in-order chunk assembly semantics.
    private final ExecutorService dataExecutor = Executors.newSingleThreadExecutor();
    // scheduler: replaces Thread.sleep-based loops (RSSI, watchdog, reconnect backoff)
    // so they don't hog the general executor and starve other tasks.
    private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
    private final AtomicBoolean running = new AtomicBoolean(false);
    private final AisHubChunkAssembler assembler = new AisHubChunkAssembler();
    private final AtomicBoolean gattBusy = new AtomicBoolean(false);
    private final AtomicBoolean isConnecting = new AtomicBoolean(false);
    private final AtomicBoolean rssiLoop = new AtomicBoolean(false);
    private final AtomicBoolean batteryLoop = new AtomicBoolean(false);
    private final AtomicBoolean reconnectLoop = new AtomicBoolean(false);
    private final AtomicBoolean notifReady = new AtomicBoolean(false);
    private final AtomicBoolean mtuRequested = new AtomicBoolean(false);
    private final AtomicBoolean snapshotRequested = new AtomicBoolean(false);
    private final AtomicBoolean subscribeRequested = new AtomicBoolean(false);
    private final ArrayBlockingQueue<byte[]> controlQueue = new ArrayBlockingQueue<>(32);

    private BluetoothAdapter adapter;
    private volatile BluetoothGatt gatt;
    private volatile String deviceMac;
    private volatile boolean connected;
    private volatile long connectionStartTimeMs;
    private volatile long lastDataAtMs;
    private volatile boolean lastErrorWasDbFull;

    private volatile BluetoothGattCharacteristic controlChar;
    private volatile BluetoothGattCharacteristic dataChar;

    private SessionCallback callback;
    private volatile UUID cachedBatterySvc;
    private volatile UUID cachedBatteryChar;

    private final ScheduledExecutorService batteryScheduler = Executors.newSingleThreadScheduledExecutor();
    private volatile ScheduledFuture<?> batteryTask;

    private static final long CONNECTION_TIMEOUT_MS = 30_000L;
    private static final long RECONNECT_DELAY_MS = 5_000L;
    private static final long RECONNECT_DELAY_DB_FULL_MS = 15_000L;
    private static final long SNAPSHOT_SUBSCRIBE_RECOVERY_TIMEOUT_MS = 300_000L;
    private static final long SNAPSHOT_RECOVERY_IDLE_MS = 10_000L;

    public AisHubGattClient(@NonNull Context context) {
        this.appContext = context.getApplicationContext();
        BluetoothManager bm = (BluetoothManager) appContext.getSystemService(Context.BLUETOOTH_SERVICE);
        this.adapter = bm != null ? bm.getAdapter() : null;
    }

    public void setCallback(@Nullable SessionCallback callback) {
        this.callback = callback;
    }

    public void setDeviceMac(String mac) {
        this.deviceMac = mac;
    }

    public boolean isRunning() {
        return running.get();
    }

    public void start() {
        if (running.get()) {
            Log.w(TAG, "AIS Hub GATT already running");
            return;
        }
        if (adapter == null || !adapter.isEnabled()) {
            postError("Bluetooth is off or unavailable");
            return;
        }
        if (deviceMac == null || deviceMac.isEmpty()) {
            postError("BLE device MAC not set");
            return;
        }
        running.set(true);
        assembler.clear();
        if (BLE_LOG) Log.d(TAG, "start(): mac=" + deviceMac);
        startReconnectLoop();
    }

    public void stop() {
        if (BLE_LOG) Log.d(TAG, "stop()");
        running.set(false);
        reconnectLoop.set(false);
        rssiLoop.set(false);
        batteryLoop.set(false);
        if (batteryTask != null) {
            try { batteryTask.cancel(true); } catch (Throwable ignore) {}
        }
        if (rssiTask != null) {
            try { rssiTask.cancel(true); } catch (Throwable ignore) {}
        }
        notifReady.set(false);
        controlChar = null;
        dataChar = null;
        mainHandler.removeCallbacksAndMessages(null);
        try {
            if (gatt != null) {
                gatt.disconnect();
                gatt.close();
            }
        } catch (Throwable ignore) {}
        gatt = null;
        connected = false;
        postState("stopped");
    }

    // --- GATT callback ---

    private final BluetoothGattCallback gattCallback = new BluetoothGattCallback() {
        @Override
        public void onConnectionStateChange(BluetoothGatt g, int status, int newState) {
            if (!running.get()) return;
            if (BLE_LOG) Log.d(TAG, "onConnectionStateChange: status=" + status + " newState=" + newState);
            if (status != BluetoothGatt.GATT_SUCCESS && status != 4 && status != 133) {
                postError("BLE connect status: " + status);
            } else if (status == 133) {
                lastErrorWasDbFull = true;
                isConnecting.set(false);
                try {
                    if (g != null) {
                        g.disconnect();
                        g.close();
                    }
                } catch (Throwable ignore) {}
                gatt = null;
                connectionStartTimeMs = 0L;
            } else if (status == 4) {
                isConnecting.set(false);
            }
            if (newState == BluetoothProfile.STATE_CONNECTED) {
                postState("connected");
                connected = true;
                connectionStartTimeMs = 0L;
                isConnecting.set(false);
                lastErrorWasDbFull = false;
                reconnectLoop.set(false);
                notifReady.set(false);
                mtuRequested.set(false);
                snapshotRequested.set(false);
                subscribeRequested.set(false);
                controlChar = null;
                dataChar = null;
                // NOTE: BluetoothGatt.refresh() is hidden API and frequently destabilizes connections
                // on some vendor stacks. Prefer stability over stale cache here.
                scheduler.schedule(() -> {
                    if (g != null && running.get() && gatt == g) {
                        // Помогаем линк-слою: выше приоритет соединения.
                        try { g.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH); } catch (Throwable ignore) {}
                        boolean ok = false;
                        try { ok = g.discoverServices(); } catch (Throwable ignore) {}
                        if (BLE_LOG) Log.d(TAG, "discoverServices(): " + ok);
                    }
                }, 200, TimeUnit.MILLISECONDS);
                // На некоторых стеках (особенно после refresh/MTU) service discovery может "зависнуть"
                // без callback'а. Если так — мягко перезапускаем discovery и, при необходимости, reconnect.
                scheduler.schedule(() -> {
                    if (!running.get()) return;
                    if (g == null || gatt != g) return;
                    if (!connected) return;
                    if (controlChar != null && dataChar != null) return;
                    if (BLE_LOG) Log.w(TAG, "Services discovery watchdog: no hub chars yet, retry discoverServices()");
                    boolean ok = false;
                    try { ok = g.discoverServices(); } catch (Throwable ignore) {}
                    if (BLE_LOG) Log.d(TAG, "discoverServices() retry: " + ok);
                }, 6, TimeUnit.SECONDS);
                scheduler.schedule(() -> {
                    if (!running.get()) return;
                    if (g == null || gatt != g) return;
                    if (!connected) return;
                    if (controlChar != null && dataChar != null) return;
                    postError("Service discovery timeout (no hub chars)");
                    try { g.disconnect(); } catch (Throwable ignore) {}
                    try { g.close(); } catch (Throwable ignore) {}
                    gatt = null;
                    connected = false;
                    isConnecting.set(false);
                    startReconnectLoop();
                }, 12, TimeUnit.SECONDS);
                startRssiLoop();
            } else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
                postState("disconnected");
                notifReady.set(false);
                controlChar = null;
                dataChar = null;
                mtuRequested.set(false);
                snapshotRequested.set(false);
                subscribeRequested.set(false);
                rssiLoop.set(false);
                batteryLoop.set(false);
                if (rssiTask != null) {
                    try { rssiTask.cancel(false); } catch (Throwable ignore) {}
                    rssiTask = null;
                }
                try { if (gatt != null) gatt.close(); } catch (Throwable ignore) {}
                gatt = null;
                connected = false;
                isConnecting.set(false);
                if (running.get()) {
                    scheduler.schedule(() -> {
                        if (running.get() && !connected) startReconnectLoop();
                    }, 1, TimeUnit.SECONDS);
                }
            }
        }

        @Override
        public void onServicesDiscovered(BluetoothGatt g, int status) {
            if (BLE_LOG) Log.d(TAG, "onServicesDiscovered: status=" + status);
            if (status != BluetoothGatt.GATT_SUCCESS) {
                postError("Service discovery failed: " + status);
                return;
            }
            if (mtuRequested.compareAndSet(false, true)) {
                boolean ok = false;
                try { ok = g.requestMtu(512); } catch (Throwable ignore) {}
                if (BLE_LOG) Log.d(TAG, "requestMtu(512): " + ok);
            }
            BluetoothGattService hub = g.getService(AisHubConstants.SERVICE_UUID);
            if (hub == null) {
                postError("AIS Hub service not found");
                return;
            }
            controlChar = hub.getCharacteristic(AisHubConstants.CONTROL_UUID);
            dataChar = hub.getCharacteristic(AisHubConstants.DATA_UUID);
            if (controlChar == null || dataChar == null) {
                postError("CONTROL or DATA characteristic missing");
                return;
            }
            if (BLE_LOG) {
                Log.d(TAG, "Hub chars ok: CONTROL=" + controlChar.getUuid() + " DATA=" + dataChar.getUuid());
            }
            boolean ok = g.setCharacteristicNotification(dataChar, true);
            if (!ok) {
                postError("Failed to enable DATA notification");
                return;
            }
            BluetoothGattDescriptor cccd = dataChar.getDescriptor(CCCD);
            if (cccd == null) {
                postError("CCCD not found for DATA");
                return;
            }
            cccd.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE);
            gattBusy.set(true);
            if (BLE_LOG) Log.d(TAG, "writeDescriptor(CCCD ENABLE_NOTIFICATION)");
            g.writeDescriptor(cccd);
            postState("subscribing");
        }

        @Override
        public void onDescriptorWrite(BluetoothGatt g, BluetoothGattDescriptor descriptor, int st) {
            gattBusy.set(false);
            if (BLE_LOG) Log.d(TAG, "onDescriptorWrite: uuid=" + descriptor.getUuid() + " status=" + st);
            if (st == BluetoothGatt.GATT_SUCCESS && CCCD.equals(descriptor.getUuid())) {
                notifReady.set(true);
                lastDataAtMs = System.currentTimeMillis();
                postState("notifying");
                try { resolveBatteryAndSchedule(g); } catch (Throwable ignore) {}
                readBatteryOnce(g);
                startBatteryLoop(g);
                enqueueControlJson(buildHello());

                // Snapshot триггерим ТОЛЬКО на HELLO_ACK (см. processDataRaw),
                // чтобы не получить snapshot_busy от двойного запроса (постDelayed
                // здесь и обработчик HELLO_ACK раньше успевали оба).
                // Fallback: если HELLO_ACK не пришёл за 2с — всё равно дёргаем snapshot.
                mainHandler.postDelayed(() -> {
                    if (!running.get() || !connected) return;
                    if (!notifReady.get() || controlChar == null) return;
                    if (snapshotRequested.compareAndSet(false, true)) {
                        if (BLE_LOG) Log.d(TAG, "HELLO_ACK timeout fallback -> enqueue get_snapshot");
                        enqueueGetSnapshot();
                    }
                }, 2000);
            }
        }

        @Override
        public void onCharacteristicChanged(BluetoothGatt g, BluetoothGattCharacteristic ch) {
            if (dataChar != null && ch.getUuid().equals(dataChar.getUuid())) {
                // ВАЖНО: не парсим/не JSON-парсим в BLE callback потоке.
                // Иначе при потоке EVENT'ов легко перегрузить стек и получить disconnect/status=5.
                // Используем ВЫДЕЛЕННЫЙ dataExecutor, чтобы парсинг не стоял за RSSI/reconnect-петлями.
                final byte[] raw = ch.getValue();
                if (raw == null) return;
                final byte[] copy = java.util.Arrays.copyOf(raw, raw.length);
                dataExecutor.execute(() -> processDataRaw(copy));
            }
        }

        @Override
        public void onMtuChanged(BluetoothGatt g, int mtu, int status) {
            if (BLE_LOG) Log.d(TAG, "onMtuChanged: status=" + status + " mtu=" + mtu);
        }

        @Override
        public void onCharacteristicRead(BluetoothGatt g, BluetoothGattCharacteristic ch, int status) {
            if (status == BluetoothGatt.GATT_SUCCESS) {
                if (BATTERY_LEVEL.equals(ch.getUuid()) || (toShort(ch.getUuid()) != null && toShort(ch.getUuid()) == 0x2A19)) {
                    byte[] v = ch.getValue();
                    if (v != null && v.length > 0 && callback != null) {
                        callback.onBatteryPercent(v[0] & 0xFF);
                    }
                }
            }
            gattBusy.set(false);
        }

        @Override
        public void onCharacteristicWrite(BluetoothGatt g, BluetoothGattCharacteristic ch, int status) {
            if (controlChar != null && ch.getUuid().equals(controlChar.getUuid())) {
                gattBusy.set(false);
                if (status != BluetoothGatt.GATT_SUCCESS) {
                    Log.w(TAG, "CONTROL write failed: " + status);
                } else if (BLE_LOG) {
                    Log.d(TAG, "CONTROL write ok (" + (ch.getValue() != null ? ch.getValue().length : -1) + " bytes)");
                }
                mainHandler.post(AisHubGattClient.this::drainControlQueue);
            }
        }

        @Override
        public void onReadRemoteRssi(BluetoothGatt g, int rssi, int status) {
            if (status == BluetoothGatt.GATT_SUCCESS && callback != null) {
                callback.onRssi(rssi);
            }
        }
    };

    private void processDataRaw(@Nullable byte[] raw) {
        if (raw == null) return;
        if (raw.length < AisHubConstants.HEADER_SIZE) {
            if (BLE_LOG) Log.w(TAG, "DATA notify too short: len=" + raw.length);
            return;
        }
        lastDataAtMs = System.currentTimeMillis();
        // Decode header fields even if payload is truncated, for diagnostics.
        int pv = raw[0] & 0xFF;
        int mt = raw[1] & 0xFF;
        int sid = ((raw[2] & 0xFF) | ((raw[3] & 0xFF) << 8));
        int cidx = ((raw[4] & 0xFF) | ((raw[5] & 0xFF) << 8));
        int ccnt = ((raw[6] & 0xFF) | ((raw[7] & 0xFF) << 8));
        int plen = ((raw[8] & 0xFF) | ((raw[9] & 0xFF) << 8));

        if (BLE_LOG) {
            Log.d(TAG, "DATA notify: len=" + raw.length +
                    " pv=" + pv +
                    " msgType=" + mt + "(" + msgTypeName(mt) + ")" +
                    " sid=" + sid +
                    " chunk=" + cidx + "/" + ccnt +
                    " plen=" + plen);
        }

        AisHubFrame frame = AisHubFrame.parse(raw);
        if (frame == null) {
            if (BLE_LOG) Log.w(TAG, "DATA parse failed/truncated: rawLen=" + raw.length + " expected>=" + (AisHubConstants.HEADER_SIZE + plen));
            return;
        }

        AisHubChunkAssembler.FeedStatus st = assembler.feedStatus(frame);
        if (st != null && BLE_LOG) {
            if (st.wasReset) {
                Log.w(TAG, "Assembler reset: sid=" + frame.sessionMsgId + " msgType=" + frame.msgType + " newChunkCount=" + st.chunkCount);
            }
            if (st.payload == null) {
                Log.d(TAG, "Assembler progress: sid=" + frame.sessionMsgId + " msgType=" + frame.msgType +
                        " got=" + st.received + "/" + st.chunkCount);
            }
        }
        if (st == null || st.payload == null) return;
        try {
            JSONObject root;
            if (st.protocolVersion == AisHubConstants.PROTO_VERSION_MSGPACK) {
                root = AisHubPayloadCodec.decodeToJsonObject(st.payload, st.protocolVersion);
                if (root == null) {
                    Log.w(TAG, "msgpack decode: empty/non-object payload msgType=" + frame.msgType);
                    return;
                }
                if (BLE_LOG) {
                    Log.d(TAG, "DATA msgpack complete: sid=" + frame.sessionMsgId + " msgType=" + frame.msgType +
                            " bytes=" + st.payload.length + " obj=" + abbreviate(root.toString(), 800));
                }
            } else {
                // JSON path keeps the original behavior (zero-copy via st.json).
                String jsonStr = st.json != null
                        ? st.json
                        : new String(st.payload, StandardCharsets.UTF_8);
                if (BLE_LOG) Log.d(TAG, "DATA json complete: sid=" + frame.sessionMsgId + " msgType=" + frame.msgType +
                        " bytes=" + jsonStr.getBytes(StandardCharsets.UTF_8).length +
                        " json=" + abbreviate(jsonStr, 800));
                root = new JSONObject(jsonStr);
            }
            // Keep our clock offset with the hub fresh from every frame that
            // carries a server timestamp. This makes stale-data math on the
            // client work correctly regardless of hub clock drift.
            double envTs = root.optDouble("ts", Double.NaN);
            if (!Double.isNaN(envTs)) {
                HubTimeSync.updateFromServerSeconds(envTs);
            } else {
                double srvTime = root.optDouble("server_time", Double.NaN);
                if (!Double.isNaN(srvTime)) HubTimeSync.updateFromServerSeconds(srvTime);
            }
            if (BLE_LOG && frame.msgType == AisHubConstants.MSG_EVENT) {
                String type = root.optString("type", "");
                JSONObject data = root.optJSONObject("data");
                // target.update/vessel-snapshot nest lat/lon inside "dynamic"; ownship.update keeps them at root.
                JSONObject src = data;
                if (data != null) {
                    JSONObject d = data.optJSONObject("dynamic");
                    if (d != null) src = d;
                }
                double lat = src != null ? src.optDouble("lat", Double.NaN) : Double.NaN;
                double lon = src != null ? src.optDouble("lon", Double.NaN) : Double.NaN;
                Log.d(TAG, "EVENT received: type=" + type + " lat=" + lat + " lon=" + lon);
            }

            // Авто-старт сессии: после HELLO_ACK сразу запрашиваем snapshot (1 раз на соединение).
            if (frame.msgType == AisHubConstants.MSG_HELLO_ACK && snapshotRequested.compareAndSet(false, true)) {
                if (BLE_LOG) Log.d(TAG, "HELLO_ACK received -> enqueue get_snapshot");
                mainHandler.post(this::enqueueGetSnapshot);
            }

            // После окончания snapshot включаем live подписки (1 раз на соединение).
            if (frame.msgType == AisHubConstants.MSG_SNAPSHOT_END && subscribeRequested.compareAndSet(false, true)) {
                boolean ok = root.optBoolean("ok", true);
                if (BLE_LOG) Log.d(TAG, "SNAPSHOT_END(ok=" + ok + ") -> enqueue subscribe");
                if (ok) {
                    mainHandler.post(this::enqueueSubscribe);
                }
            }

            if (callback != null) {
                mainHandler.post(() -> callback.onDataJson(frame.msgType, root));
            }
        } catch (Exception e) {
            Log.w(TAG, "JSON parse: " + e.getMessage());
        }
    }

    // --- control writes ---

    @NonNull
    private JSONObject buildHello() {
        JSONObject c = new JSONObject();
        try {
            c.put("cmd", "hello");
            c.put("client", "android");
            String v = "1.0";
            try {
                PackageInfo pi = appContext.getPackageManager().getPackageInfo(appContext.getPackageName(), 0);
                if (pi != null) v = pi.versionName != null ? pi.versionName : "1.0";
            } catch (Exception ignore) {}
            c.put("app_version", v);
            c.put("proto", 1);
            // Advertise encoding preferences. Server picks the first one it
            // supports; we fall back to JSON automatically if msgpack isn't
            // available server-side. msgpack is ~30-40% smaller on the wire
            // which matters for snapshot transfers over BLE.
            // Note: this only affects the AcquireNotify (per-fd) path. For the
            // CCCD broadcast path Android clients share, the server uses the
            // global AIS_BLE_BROADCAST_ENCODING env var — but our decoder
            // handles both encodings per-frame via the proto-version byte, so
            // switching the server's env has zero client-side impact.
            org.json.JSONArray encs = new org.json.JSONArray();
            encs.put("msgpack");
            encs.put("json");
            c.put("encodings", encs);
        } catch (Exception ignore) {}
        return c;
    }

    public void enqueueGetSnapshot() {
        JSONObject c = new JSONObject();
        try {
            c.put("cmd", "get_snapshot");
            org.json.JSONArray inc = new org.json.JSONArray();
            inc.put("ownship");
            inc.put("vessels");
            inc.put("base_stations");
            inc.put("atons");
            inc.put("stats");
            c.put("include", inc);
            // Серверный хард-кап ограничивает дальше (см. AIS_BLE_SNAPSHOT_MAX_VESSELS).
            // 5000 покрывает любые реалистичные сценарии (обычно в AOI < 2000 целей).
            c.put("max_vessels", 5000);
        } catch (Exception ignore) {}
        enqueueControl(c);

        // Recovery only: штатно live-подписка включается строго после SNAPSHOT_END.
        // Короткий таймер здесь интерливил EVENT'ы в длинный snapshot по CCCD.
        mainHandler.postDelayed(() -> {
            if (!running.get() || !connected) return;
            if (!notifReady.get() || controlChar == null) return;
            long idleMs = System.currentTimeMillis() - lastDataAtMs;
            if (idleMs < SNAPSHOT_RECOVERY_IDLE_MS) return;
            if (subscribeRequested.compareAndSet(false, true)) {
                if (BLE_LOG) Log.d(TAG, "snapshot recovery timeout -> enqueue subscribe");
                enqueueSubscribe();
            }
        }, SNAPSHOT_SUBSCRIBE_RECOVERY_TIMEOUT_MS);
    }

    public void enqueueSubscribe() {
        JSONObject c = new JSONObject();
        try {
            c.put("cmd", "subscribe");
            org.json.JSONArray ev = new org.json.JSONArray();
            ev.put("ownship.update");
            ev.put("target.update");
            ev.put("base_station.update");
            ev.put("aton.update");
            ev.put("stats.update");
            c.put("events", ev);
        } catch (Exception ignore) {}
        enqueueControl(c);
    }

    private void enqueueControlJson(@NonNull JSONObject cmd) {
        try {
            enqueueControl(cmd);
        } catch (Exception e) {
            Log.w(TAG, "enqueue: " + e.getMessage());
        }
    }

    private void enqueueControl(@NonNull JSONObject json) {
        if (!notifReady.get() || controlChar == null) return;
        String s = json.toString();
        if (BLE_LOG) Log.d(TAG, "CONTROL enqueue: " + abbreviate(s, 600));
        byte[] u = s.getBytes(StandardCharsets.UTF_8);
        if (!controlQueue.offer(u)) {
            Log.w(TAG, "Control queue full");
        }
        mainHandler.post(this::drainControlQueue);
    }

    private void drainControlQueue() {
        if (!running.get() || !notifReady.get() || gatt == null || controlChar == null) return;
        if (gattBusy.get()) return;
        byte[] next = controlQueue.poll();
        if (next == null) return;
        gattBusy.set(true);
        controlChar.setValue(next);
        controlChar.setWriteType(BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT);
        boolean ok = gatt.writeCharacteristic(controlChar);
        if (!ok) {
            if (BLE_LOG) Log.w(TAG, "CONTROL writeCharacteristic returned false, retrying");
            gattBusy.set(false);
            mainHandler.post(this::drainControlQueue);
        }
    }

    // --- connect / reconnect ---

    private void startReconnectLoop() {
        if (reconnectLoop.getAndSet(true)) return;
        executor.execute(() -> {
            while (running.get() && reconnectLoop.get() && !connected) {
                try {
                    if (connectionStartTimeMs > 0) {
                        long el = System.currentTimeMillis() - connectionStartTimeMs;
                        if (el > CONNECTION_TIMEOUT_MS) {
                            isConnecting.set(false);
                            try { if (gatt != null) { gatt.disconnect(); gatt.close(); } } catch (Throwable ignore) {}
                            gatt = null;
                            connectionStartTimeMs = 0L;
                        } else {
                            try { Thread.sleep(1000); } catch (InterruptedException ignored) {}
                            continue;
                        }
                    }
                    if (!isConnecting.compareAndSet(false, true)) {
                        try { Thread.sleep(1000); } catch (InterruptedException ignored) {}
                        continue;
                    }
                    attemptConnect();
                } catch (Throwable t) {
                    Log.w(TAG, "reconnect: " + t.getMessage());
                    isConnecting.set(false);
                }
                long delay = lastErrorWasDbFull ? RECONNECT_DELAY_DB_FULL_MS : RECONNECT_DELAY_MS;
                try { Thread.sleep(delay); } catch (InterruptedException ignored) {}
            }
        });
    }

    private void attemptConnect() {
        if (adapter == null || deviceMac == null || deviceMac.isEmpty()) {
            isConnecting.set(false);
            return;
        }
        if (gatt != null) {
            try {
                gatt.disconnect();
                try { Thread.sleep(100); } catch (InterruptedException ignored) {}
                gatt.close();
                try { Thread.sleep(100); } catch (InterruptedException ignored) {}
            } catch (Throwable ignore) {}
            gatt = null;
        }
        BluetoothDevice d = adapter.getRemoteDevice(deviceMac);
        if (d == null) {
            postError("BLE device not found: " + deviceMac);
            isConnecting.set(false);
            return;
        }
        if (BLE_LOG) Log.d(TAG, "attemptConnect(): " + deviceMac);
        postState("connecting");
        connectionStartTimeMs = System.currentTimeMillis();
        try {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                gatt = d.connectGatt(appContext, false, gattCallback, BluetoothDevice.TRANSPORT_LE);
            } else {
                gatt = d.connectGatt(appContext, false, gattCallback);
            }
            if (gatt == null) isConnecting.set(false);
        } catch (Throwable t) {
            isConnecting.set(false);
            connectionStartTimeMs = 0L;
        }
    }

    private volatile ScheduledFuture<?> rssiTask;

    private void startRssiLoop() {
        if (gatt == null) return;
        if (rssiLoop.getAndSet(true)) return;
        if (rssiTask != null) try { rssiTask.cancel(true); } catch (Throwable ignore) {}
        rssiTask = scheduler.scheduleAtFixedRate(() -> {
            if (!running.get() || !rssiLoop.get() || gatt == null) return;
            try { gatt.readRemoteRssi(); } catch (Throwable ignore) {}
        }, 500, 2000, TimeUnit.MILLISECONDS);
    }

    // --- battery (same heuristics as old listener) ---

    private void resolveBatteryAndSchedule(BluetoothGatt g) {
        BluetoothGattCharacteristic bl = findBatteryChar(g);
        if (bl != null) {
            cachedBatterySvc = bl.getService().getUuid();
            cachedBatteryChar = bl.getUuid();
        }
    }

    @Nullable
    private BluetoothGattCharacteristic findBatteryChar(BluetoothGatt g) {
        if (g == null) return null;
        if (cachedBatterySvc != null && cachedBatteryChar != null) {
            BluetoothGattService s = g.getService(cachedBatterySvc);
            if (s != null) {
                BluetoothGattCharacteristic c = s.getCharacteristic(cachedBatteryChar);
                if (c != null) return c;
            }
        }
        BluetoothGattService s = g.getService(BATTERY_SERVICE);
        if (s != null) {
            BluetoothGattCharacteristic c = s.getCharacteristic(BATTERY_LEVEL);
            if (c != null) {
                cachedBatterySvc = BATTERY_SERVICE;
                cachedBatteryChar = BATTERY_LEVEL;
                return c;
            }
        }
        List<BluetoothGattService> list = g.getServices();
        if (list == null) return null;
        for (BluetoothGattService sv : list) {
            for (BluetoothGattCharacteristic ch : sv.getCharacteristics()) {
                Integer sh = toShort(ch.getUuid());
                if (sh != null && sh == 0x2A19) {
                    return ch;
                }
            }
        }
        return null;
    }

    private static Integer toShort(java.util.UUID uuid) {
        if (uuid == null) return null;
        String s = uuid.toString().toLowerCase();
        if (s.startsWith("0000") && s.endsWith("-0000-1000-8000-00805f9b34fb")) {
            try {
                return Integer.parseInt(s.substring(4, 8), 16);
            } catch (Throwable ignore) {}
        }
        return null;
    }

    private void readBatteryOnce(BluetoothGatt g) {
        if (g == null) return;
        BluetoothGattCharacteristic bl = findBatteryChar(g);
        if (bl == null) return;
        if (gattBusy.compareAndSet(false, true)) {
            boolean ok = g.readCharacteristic(bl);
            if (!ok) gattBusy.set(false);
        }
    }

    private void startBatteryLoop(BluetoothGatt gRef) {
        if (gRef == null) return;
        if (!batteryLoop.compareAndSet(false, true)) return;
        if (batteryTask != null) try { batteryTask.cancel(true); } catch (Throwable ignore) {}
        batteryTask = batteryScheduler.scheduleAtFixedRate(() -> {
            if (!running.get() || !batteryLoop.get() || gatt == null) {
                batteryLoop.set(false);
                return;
            }
            try {
                if (gattBusy.get()) return;
                BluetoothGattCharacteristic bl = findBatteryChar(gRef);
                if (bl != null && gattBusy.compareAndSet(false, true)) {
                    boolean ok = gRef.readCharacteristic(bl);
                    if (!ok) gattBusy.set(false);
                }
            } catch (Throwable ignore) {}
        }, 2000, 10_000, TimeUnit.MILLISECONDS);
    }

    private void postState(String s) {
        if (callback != null) {
            mainHandler.post(() -> callback.onState(s));
        }
    }

    private void postError(String s) {
        Log.e(TAG, s);
        LogSender.logBLEError(s, deviceMac, "AisHub");
        if (callback != null) {
            mainHandler.post(() -> callback.onError(s));
        }
    }

    @NonNull
    private static String abbreviate(@NonNull String s, int max) {
        if (s.length() <= max) return s;
        return s.substring(0, Math.max(0, max)) + "…(" + s.length() + " chars)";
    }

    @NonNull
    private static String msgTypeName(int mt) {
        switch (mt) {
            case AisHubConstants.MSG_HELLO_ACK: return "HELLO_ACK";
            case AisHubConstants.MSG_SNAPSHOT_BEGIN: return "SNAPSHOT_BEGIN";
            case AisHubConstants.MSG_SNAPSHOT_CHUNK: return "SNAPSHOT_CHUNK";
            case AisHubConstants.MSG_SNAPSHOT_END: return "SNAPSHOT_END";
            case AisHubConstants.MSG_EVENT: return "EVENT";
            case AisHubConstants.MSG_STATUS: return "STATUS";
            case AisHubConstants.MSG_ERROR: return "ERROR";
            case AisHubConstants.MSG_PONG: return "PONG";
            default: return "UNKNOWN";
        }
    }
}
