package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.communicator.v2;

import android.bluetooth.BluetoothGatt;
import android.bluetooth.BluetoothGattCharacteristic;
import android.content.Intent;

import androidx.annotation.Nullable;
import androidx.localbroadcastmanager.content.LocalBroadcastManager;

import org.apache.commons.lang3.ArrayUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.Arrays;
import java.util.Date;
import java.util.UUID;

import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.database.DBHandler;
import nodomain.freeyourgadget.gadgetbridge.database.DBHelper;
import nodomain.freeyourgadget.gadgetbridge.devices.garmin.GarminActivitySampleProvider;
import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession;
import nodomain.freeyourgadget.gadgetbridge.entities.Device;
import nodomain.freeyourgadget.gadgetbridge.entities.GarminActivitySample;
import nodomain.freeyourgadget.gadgetbridge.entities.User;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind;
import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample;
import nodomain.freeyourgadget.gadgetbridge.model.DeviceService;
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.SetDeviceStateAction;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminSupport;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.GarminTimeUtils;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.communicator.CobsCoDec;
import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.communicator.ICommunicator;
import nodomain.freeyourgadget.gadgetbridge.util.GB;

public class CommunicatorV2 implements ICommunicator {
    private static final Logger LOG = LoggerFactory.getLogger(CommunicatorV2.class);

    public static final String BASE_UUID = "6A4E%04X-667B-11E3-949A-0800200C9A66";
    public static final UUID UUID_SERVICE_GARMIN_ML_GFDI = UUID.fromString(String.format(BASE_UUID, 0x2800));

    private static final long GADGETBRIDGE_CLIENT_ID = 2L;

    private BluetoothGattCharacteristic characteristicSend;
    private BluetoothGattCharacteristic characteristicReceive;

    private final GarminSupport mSupport;

    private int gfdiHandle = 0;
    public int maxWriteSize = 20;
    public final CobsCoDec cobsCoDec;

    private int realtimeHrHandle = 0;
    private boolean realtimeHrOneShot = false;

    private int realtimeStepsHandle = 0;
    private int previousSteps = -1;

    private int realtimeAccelHandle = 0;

    private int realtimeSpo2Handle = 0;
    private int realtimeRespirationHandle = 0;
    private int realtimeHrvHandle = 0;

    public CommunicatorV2(final GarminSupport garminSupport) {
        this.mSupport = garminSupport;
        this.cobsCoDec = new CobsCoDec();
    }

    @Override
    public void onMtuChanged(final int mtu) {
        maxWriteSize = mtu - 3;
    }

    @Override
    public void initializeDevice(final TransactionBuilder builder) {
        // Iterate through the known ML characteristics until we find a known pair
        // send characteristic = read characteristic + 0x10 (eg. 2810 / 2820)
        for (int i = 0x2810; i <= 0x2814; i++) {
            characteristicReceive = mSupport.getCharacteristic(UUID.fromString(String.format(BASE_UUID, i)));
            characteristicSend = mSupport.getCharacteristic(UUID.fromString(String.format(BASE_UUID, i + 0x10)));

            if (characteristicSend != null && characteristicReceive != null) {
                LOG.debug("Using characteristics receive/send = {}/{}", characteristicReceive.getUuid(), characteristicSend.getUuid());

                builder.notify(characteristicReceive, true);
                builder.write(characteristicSend, closeAllServices());

                return;
            }
        }

        LOG.warn("Failed to find any known ML characteristics");

        builder.add(new SetDeviceStateAction(mSupport.getDevice(), GBDevice.State.NOT_CONNECTED, mSupport.getContext()));
    }

    @Override
    public void sendMessage(final String taskName, final byte[] message) {
        if (null == message)
            return;
        if (0 == gfdiHandle) {
            LOG.error("CANNOT SENT GFDI MESSAGE, HANDLE NOT YET SET. MESSAGE {}", message);
            return;
        }
        final byte[] payload = cobsCoDec.encode(message);
//        LOG.debug("SENDING MESSAGE: {} - COBS ENCODED: {}", GB.hexdump(message), GB.hexdump(payload));
        final TransactionBuilder builder = new TransactionBuilder(taskName);
        int remainingBytes = payload.length;
        if (remainingBytes > maxWriteSize - 1) {
            int position = 0;
            while (remainingBytes > 0) {
                final byte[] fragment = Arrays.copyOfRange(payload, position, position + Math.min(remainingBytes, maxWriteSize - 1));
                builder.write(characteristicSend, ArrayUtils.addAll(new byte[]{(byte) gfdiHandle}, fragment));
                position += fragment.length;
                remainingBytes -= fragment.length;
            }
        } else {
            builder.write(characteristicSend, ArrayUtils.addAll(new byte[]{(byte) gfdiHandle}, payload));
        }
        builder.queue(this.mSupport.getQueue());
    }

    @Override
    public boolean onCharacteristicChanged(final BluetoothGatt gatt, final BluetoothGattCharacteristic characteristic) {
        if (!characteristic.getUuid().equals(characteristicReceive.getUuid())) {
            // Not ML
            return false;
        }

        final ByteBuffer message = ByteBuffer.wrap(characteristic.getValue()).order(ByteOrder.LITTLE_ENDIAN);
        final byte handle = message.get();

        if (0x00 == handle) {
            processHandleManagement(message);
        } else if (this.gfdiHandle == handle) {
            processGfdi(message);
        } else if (this.realtimeHrHandle == handle) {
            processRealtimeHeartRate(message);
        } else if (this.realtimeStepsHandle == handle) {
            processRealtimeSteps(message);
        } else if (this.realtimeAccelHandle == handle) {
            processRealtimeAccelerometer(message);
        } else if (this.realtimeSpo2Handle == handle) {
            processRealtimeSpo2(message);
        } else if (this.realtimeRespirationHandle == handle) {
            processRealtimeRespiration(message);
        } else if (this.realtimeHrvHandle == handle) {
            processRealtimeHrv(message);
        } else {
            LOG.warn("Got message for unknown handle {}: {}", handle, GB.hexdump(characteristic.getValue()));
        }

        return true;
    }

    @Override
    public void onHeartRateTest() {
        realtimeHrOneShot = true;
        if (realtimeHrHandle == 0) {
            new TransactionBuilder("heart rate test")
                    .write(characteristicSend, registerService(Service.REALTIME_HR, false))
                    .queue(this.mSupport.getQueue());
        }
    }

    @Override
    public void onEnableRealtimeHeartRateMeasurement(final boolean enable) {
        toggleService(Service.REALTIME_HR, realtimeHrHandle, enable);
    }

    @Override
    public void onEnableRealtimeSteps(final boolean enable) {
        if (toggleService(Service.REALTIME_STEPS, realtimeStepsHandle, enable)) {
            previousSteps = -1;
        }
    }

    private boolean toggleService(final Service service, final int currentHandle, final boolean enable) {
        if (enable && currentHandle == 0) {
            new TransactionBuilder(service + " = true")
                    .write(characteristicSend, registerService(service, false))
                    .queue(this.mSupport.getQueue());
            return true;
        } else if (!enable && currentHandle != 0) {
            new TransactionBuilder(service + " = false")
                    .write(characteristicSend, closeService(service, currentHandle))
                    .queue(this.mSupport.getQueue());
            return true;
        }

        return false;
    }

    private void processHandleManagement(final ByteBuffer message) {
        final byte type = message.get();
        final long incomingClientID = message.getLong();

        if (incomingClientID != GADGETBRIDGE_CLIENT_ID) {
            LOG.warn("Ignoring incoming message, client ID {} is not ours. Message: {}", incomingClientID, GB.hexdump(message.array()));
            return;
        }

        final RequestType requestType = RequestType.fromCode(type);
        if (null == requestType) {
            LOG.error("Unknown request type {}. Message: {}", type, message.array());
            return;
        }

        switch (requestType) {
            case REGISTER_ML_REQ:
            case CLOSE_HANDLE_REQ:
            case CLOSE_ALL_REQ:
            case UNK_REQ:
                LOG.warn("Received handle request, expecting responses. Message: {}", message.array());
                return;
            case REGISTER_ML_RESP: {
                final short registeredServiceCode = message.getShort();
                final Service registeredService = Service.fromCode(registeredServiceCode);
                final byte status = message.get();
                if (registeredService == null) {
                    LOG.error("Got register response status={} for unknown service {}", status, registeredServiceCode);
                    return;
                }
                if (status != 0) {
                    LOG.warn("Failed to register {}, status={}", registeredService, status);
                    return;
                }
                final int handle = message.get();
                final int reliable = message.get();
                LOG.debug("Got register response for {}, handle={}, reliable={}", registeredService, handle, reliable);

                switch (registeredService) {
                    case GFDI:
                        this.gfdiHandle = handle;
                        break;
                    case REALTIME_HR:
                        this.realtimeHrHandle = handle;
                        break;
                    case REALTIME_STEPS:
                        this.realtimeStepsHandle = handle;
                        break;
                    case REALTIME_ACCELEROMETER:
                        this.realtimeAccelHandle = handle;
                        new TransactionBuilder("start realtime accel")
                                .write(characteristicSend, new byte[]{(byte) handle, 0x01})
                                .queue(this.mSupport.getQueue());
                        break;
                    case REALTIME_SPO2:
                        this.realtimeSpo2Handle = handle;
                        break;
                    case REALTIME_RESPIRATION:
                        this.realtimeRespirationHandle = handle;
                        break;
                    case REALTIME_HRV:
                        this.realtimeHrvHandle = handle;
                        break;
                }
                break;
            }
            case CLOSE_HANDLE_RESP: {
                final short serviceCode = message.getShort();
                final Service service = Service.fromCode(serviceCode);
                final int handle = message.get();
                final byte status = message.get();
                LOG.debug("Received close handle response: service={}, handle={}, status={}", service, handle, status);
                if (service != null) {
                    switch (service) {
                        case GFDI:
                            this.gfdiHandle = 0;
                            break;
                        case REALTIME_HR:
                            this.realtimeHrHandle = 0;
                            break;
                        case REALTIME_STEPS:
                            this.realtimeStepsHandle = 0;
                            break;
                        case REALTIME_ACCELEROMETER:
                            this.realtimeAccelHandle = 0;
                            break;
                        case REALTIME_SPO2:
                            this.realtimeSpo2Handle = 0;
                            break;
                        case REALTIME_RESPIRATION:
                            this.realtimeRespirationHandle = 0;
                            break;
                        case REALTIME_HRV:
                            this.realtimeHrvHandle = 0;
                            break;
                    }
                }
                break;
            }
            case CLOSE_ALL_RESP:
                LOG.debug("Received close all handles response. Message: {}", message.array());
                this.gfdiHandle = 0;
                this.realtimeHrHandle = 0;
                this.realtimeStepsHandle = 0;
                this.realtimeAccelHandle = 0;
                this.realtimeSpo2Handle = 0;
                this.realtimeRespirationHandle = 0;
                this.realtimeHrvHandle = 0;
                new TransactionBuilder("open GFDI")
                        .write(characteristicSend, registerService(Service.GFDI, false))
                        .queue(this.mSupport.getQueue());
                break;
            case UNK_RESP:
                LOG.debug("Received unknown. Message: {}", message.array());
                break;
        }
    }

    private void processGfdi(final ByteBuffer message) {
        final byte[] partial = new byte[message.remaining()];
        message.get(partial);
        this.cobsCoDec.receivedBytes(partial);

        this.mSupport.onMessage(this.cobsCoDec.retrieveMessage());
    }

    private void processRealtimeHeartRate(final ByteBuffer buf) {
        final byte type = buf.get(); // 0/2/3? 3 == realtime?
        final int hr = buf.get();
        final int resting = buf.get();
        // ff ff after
        LOG.debug("Got realtime HR: type={} hr={} resting={}", type, hr, resting);

        if (hr > 0) {
            broadcastRealtimeActivity(hr, -1);

            if (realtimeHrOneShot && realtimeHrHandle != 0) {
                onEnableRealtimeHeartRateMeasurement(false);
            }
        }
    }

    private void processRealtimeSteps(final ByteBuffer buf) {
        final int steps = buf.getInt();
        final int goal = buf.getInt();
        LOG.debug("Got realtime steps: steps={} goal={}", steps, goal);

        if (previousSteps == -1) {
            previousSteps = steps;
        }

        broadcastRealtimeActivity(-1, steps - previousSteps);

        previousSteps = steps;
    }

    private void processRealtimeAccelerometer(final ByteBuffer message) {
        final byte[] partial = new byte[message.remaining()];
        message.get(partial);

        LOG.debug("Got realtime accel: {}", GB.hexdump(partial));
    }

    private void processRealtimeSpo2(final ByteBuffer message) {
        final int spo2 = message.get(); // -1 when unknown, and the ts is not valid in that case
        final int garminTs = message.getInt();

        LOG.debug("Got realtime SpO2 at {}: {}", new Date(GarminTimeUtils.garminTimestampToJavaMillis(garminTs)), spo2);
    }

    private void processRealtimeRespiration(final ByteBuffer message) {
        final int breathsPerMinute = message.get(); // can be negative if unknown, usually -2

        LOG.debug("Got realtime respiration: {}", breathsPerMinute);
    }

    private void processRealtimeHrv(final ByteBuffer message) {
        final short rr = message.getShort();
        final int unk = message.getInt();

        LOG.debug("Got realtime HRV: rr={}, unk={}", rr, unk);
    }

    private byte[] closeAllServices() {
        final ByteBuffer toSend = ByteBuffer.allocate(13).order(ByteOrder.LITTLE_ENDIAN);
        toSend.put((byte) 0); // handle
        toSend.put((byte) RequestType.CLOSE_ALL_REQ.ordinal());
        toSend.putLong(GADGETBRIDGE_CLIENT_ID);
        toSend.putShort((short) 0);
        return toSend.array();
    }

    private byte[] registerService(final Service service, final boolean reliable) {
        final ByteBuffer toSend = ByteBuffer.allocate(13).order(ByteOrder.LITTLE_ENDIAN);
        toSend.put((byte) 0);
        toSend.put((byte) RequestType.REGISTER_ML_REQ.ordinal());
        toSend.putLong(GADGETBRIDGE_CLIENT_ID);
        toSend.putShort(service.getCode());
        toSend.put((byte) (reliable ? 2 : 0));
        return toSend.array();
    }

    private byte[] closeService(final Service service, final int handle) {
        final ByteBuffer toSend = ByteBuffer.allocate(13).order(ByteOrder.LITTLE_ENDIAN);
        toSend.put((byte) 0);
        toSend.put((byte) RequestType.CLOSE_HANDLE_REQ.ordinal());
        toSend.putLong(GADGETBRIDGE_CLIENT_ID);
        toSend.putShort(service.getCode());
        toSend.put((byte) handle);
        return toSend.array();
    }

    private void broadcastRealtimeActivity(final int hr, final int steps) {
        final GarminActivitySample sample;
        try (final DBHandler dbHandler = GBApplication.acquireDB()) {
            final DaoSession session = dbHandler.getDaoSession();

            final GBDevice gbDevice = mSupport.getDevice();
            final Device device = DBHelper.getDevice(gbDevice, session);
            final User user = DBHelper.getUser(session);
            final GarminActivitySampleProvider provider = new GarminActivitySampleProvider(gbDevice, session);
            sample = provider.createActivitySample();

            sample.setDeviceId(device.getId());
            sample.setUserId(user.getId());
            sample.setTimestamp((int) (System.currentTimeMillis() / 1000));
            sample.setHeartRate(hr);
            sample.setSteps(steps);
            sample.setRawKind(ActivityKind.UNKNOWN.getCode());
            sample.setRawIntensity(ActivitySample.NOT_MEASURED);
            sample.setRawKind(ActivityKind.UNKNOWN.getCode());
        } catch (final Exception e) {
            LOG.error("Error creating activity sample", e);
            return;
        }

        final Intent intent = new Intent(DeviceService.ACTION_REALTIME_SAMPLES)
                .putExtra(GBDevice.EXTRA_DEVICE, mSupport.getDevice())
                .putExtra(DeviceService.EXTRA_REALTIME_SAMPLE, sample);
        LocalBroadcastManager.getInstance(mSupport.getContext()).sendBroadcast(intent);
    }

    private enum RequestType {
        REGISTER_ML_REQ,
        REGISTER_ML_RESP,
        CLOSE_HANDLE_REQ,
        CLOSE_HANDLE_RESP,
        UNK_HANDLE,
        CLOSE_ALL_REQ,
        CLOSE_ALL_RESP,
        UNK_REQ,
        UNK_RESP;

        @Nullable
        public static RequestType fromCode(final int code) {
            for (final RequestType requestType : RequestType.values()) {
                if (requestType.ordinal() == code) {
                    return requestType;
                }
            }

            return null;
        }
    }

    private enum Service {
        GFDI(1),
        REGISTRATION(4),
        REALTIME_HR(6),
        REALTIME_STEPS(7),
        REALTIME_CALORIES(8),
        REALTIME_INTENSITY(10),
        REALTIME_HRV(12),
        REALTIME_STRESS(13),
        REALTIME_ACCELEROMETER(16),
        REALTIME_SPO2(19),
        REALTIME_BODY_BATTERY(20),
        REALTIME_RESPIRATION(21),
        ;

        private final short code;

        Service(final int code) {
            this.code = (short) code;
        }

        public short getCode() {
            return code;
        }

        @Nullable
        public static Service fromCode(final int code) {
            for (final Service service : Service.values()) {
                if (service.code == code) {
                    return service;
                }
            }

            return null;
        }
    }
}
