/*  Copyright (C) 2024 José Rebelo

    This file is part of Gadgetbridge.

    Gadgetbridge is free software: you can redistribute it and/or modify
    it under the terms of the GNU Affero General Public License as published
    by the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.

    Gadgetbridge is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU Affero General Public License for more details.

    You should have received a copy of the GNU Affero General Public License
    along with this program.  If not, see <https://www.gnu.org/licenses/>. */
package nodomain.freeyourgadget.gadgetbridge.service.devices.cmfwatchpro;

import android.bluetooth.BluetoothGattCharacteristic;

import androidx.annotation.Nullable;

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

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.security.GeneralSecurityException;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.zip.CRC32;

import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions;
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder;
import nodomain.freeyourgadget.gadgetbridge.util.CryptoUtils;
import nodomain.freeyourgadget.gadgetbridge.util.GB;

public class CmfCharacteristic {
    private final Logger LOG = LoggerFactory.getLogger(CmfCharacteristic.class);

    private static final byte[] AES_IV = new byte[]{0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x5a};
    private static final byte PAYLOAD_HEADER = (byte) 0xf5;

    private final BluetoothGattCharacteristic bluetoothGattCharacteristic;
    private final UUID characteristicUUID;

    private final Handler handler;

    private byte[] sessionKey;

    private int mtu = 247;

    private final Map<CmfCommand, ChunkBuffer> chunkBuffers = new HashMap<>();

    public CmfCharacteristic(final BluetoothGattCharacteristic bluetoothGattCharacteristic,
                             final Handler handler) {
        this.bluetoothGattCharacteristic = bluetoothGattCharacteristic;
        this.characteristicUUID = bluetoothGattCharacteristic.getUuid();
        this.handler = handler;
    }

    public UUID getCharacteristicUUID() {
        return characteristicUUID;
    }

    public void setSessionKey(final byte[] sessionKey) {
        this.sessionKey = sessionKey;
    }

    public void setMtu(final int mtu) {
        this.mtu = mtu;
    }

    public void sendCommand(final TransactionBuilder builder, final CmfCommand cmd, final byte[] payload) {
        final byte[][] chunks;

        if (shouldEncrypt(cmd)) {
            chunks = makeChunksEncrypted(payload);
        } else {
            chunks = makeChunksPlaintext(payload);
        }

        if (chunks == null) {
            // Something went wrong chunking - error was already printed
            return;
        }

        for (int i = 0; i < chunks.length; i++) {
            final byte[] chunk = chunks[i];

            final ByteBuffer buf = ByteBuffer.allocate(chunk.length + 11).order(ByteOrder.BIG_ENDIAN);
            buf.put(PAYLOAD_HEADER);
            buf.putShort((short) chunk.length);
            buf.putShort((short) cmd.getCmd1());
            buf.putShort((short) chunks.length);
            buf.putShort((short) (i + 1));
            buf.putShort((short) cmd.getCmd2());
            buf.put(chunk);

            builder.write(bluetoothGattCharacteristic, buf.array());
        }
    }

    private byte[][] makeChunksPlaintext(final byte[] payload) {
        final int chunkSize = mtu - 20;
        final int numChunks = (int) Math.ceil(payload.length / (float) chunkSize);
        final byte[][] chunks = new byte[numChunks][];

        final CRC32 crc = new CRC32();

        for (int i = 0; i < numChunks; i++) {
            final int startIdx = i * chunkSize;
            final int endIdx = Math.min(startIdx + chunkSize, payload.length);
            final byte[] chunk = ArrayUtils.subarray(payload, startIdx, endIdx);

            crc.reset();
            crc.update(chunk, 0, chunk.length);

            chunks[i] = ArrayUtils.addAll(
                    chunk,
                    BLETypeConversions.fromUint32((int) crc.getValue())
            );
        }

        return chunks;
    }

    @Nullable
    private byte[][] makeChunksEncrypted(final byte[] payload) {
        if (payload.length == 0) {
            return new byte[1][0];
        }

        // AES will output 16-byte blocks, exclude the protocol overhead (11 bytes)
        final int maxEncryptedPayloadSize = ((mtu - 11) / 16) * 16;
        final int maxPayloadSize = maxEncryptedPayloadSize - 4 - 1; // exclude 4 bytes for crc and 1 byte of aes padding
        final int numChunks = (int) Math.ceil(payload.length / (float) (maxPayloadSize));
        final byte[][] chunks = new byte[numChunks][];

        if (numChunks != 1) {
            LOG.debug("Splitting payload into {} chunks of {} bytes", numChunks, maxPayloadSize);
        }

        final CRC32 crc = new CRC32();

        for (int i = 0; i < numChunks; i++) {
            final int startIdx = i * maxPayloadSize;
            final int endIdx = Math.min(startIdx + maxPayloadSize, payload.length);
            final byte[] chunk = ArrayUtils.subarray(payload, startIdx, endIdx);

            crc.reset();
            crc.update(chunk, 0, chunk.length);

            final byte[] payloadToEncrypt = ArrayUtils.addAll(
                    chunk,
                    BLETypeConversions.fromUint32((int) crc.getValue())
            );

            try {
                chunks[i] = CryptoUtils.encryptAES_CBC_Pad(payloadToEncrypt, sessionKey, AES_IV);
            } catch (final GeneralSecurityException e) {
                LOG.error("Failed to encrypt chunk", e);
                return null;
            }
        }

        return chunks;
    }

    private boolean shouldEncrypt(final CmfCommand cmd) {
        switch (cmd) {
            case DATA_CHUNK_WRITE_AGPS:
            case DATA_CHUNK_WRITE_WATCHFACE:
                return false;
        }

        return true;
    }

    public void onCharacteristicChanged(final byte[] value) {
        final ByteBuffer buf = ByteBuffer.wrap(value).order(ByteOrder.BIG_ENDIAN);

        final byte header = buf.get();
        if (header != PAYLOAD_HEADER) {
            LOG.error("Unexpected first byte {}", String.format("0x%02x", header));
            return;
        }

        final int encryptedPayloadLength = buf.getShort();
        final int cmd1 = buf.getShort() & 0xFFFF;
        final int chunkCount = buf.getShort();
        final int chunkIndex = buf.getShort();
        final int cmd2 = buf.getShort() & 0xFFFF;

        final CmfCommand cmd = CmfCommand.fromCodes(cmd1, cmd2);

        final byte[] payload;
        if (encryptedPayloadLength > 0) {
            final byte[] encryptedPayload = new byte[encryptedPayloadLength];
            buf.get(encryptedPayload);

            try {
                final byte[] decryptedPayload = CryptoUtils.decryptAES_CBC_Pad(encryptedPayload, sessionKey, AES_IV);
                payload = ArrayUtils.subarray(decryptedPayload, 0, decryptedPayload.length - 4);
                final int expectedCrc = BLETypeConversions.toUint32(decryptedPayload, decryptedPayload.length - 4);
                final CRC32 crc = new CRC32();
                crc.update(payload, 0, payload.length);
                final int actualCrc = (int) crc.getValue();
                if (actualCrc != expectedCrc) {
                    LOG.error("Payload CRC mismatch for {}: got {}, expected {}", cmd, String.format("%08X", actualCrc), String.format("%08X", expectedCrc));
                    if (chunkCount > 1) {
                        chunkBuffers.remove(cmd);
                    }
                    return;
                }
            } catch (final GeneralSecurityException e) {
                LOG.error("Failed to decrypt payload for {}", cmd, e);
                if (chunkCount > 1) {
                    chunkBuffers.remove(cmd);
                }
                return;
            }
        } else {
            payload = new byte[0];
        }

        LOG.debug(
                "Got {}: {}{}",
                chunkCount > 1 ? String.format(Locale.ROOT, "chunk %d/%d", chunkIndex, chunkCount) : "command",
                cmd != null ? String.format("cmd=%s", cmd) : String.format("cmd1=0x%04x cmd2=0x%04x", cmd1, cmd2),
                payload.length > 0 ? " payload=" + GB.hexdump(payload) : ""
        );

        if (cmd == null) {
            // Just ignore unknown commands
            LOG.warn("Unknown command cmd1={} cmd2={}", String.format("0x%04x", cmd1), String.format("0x%04x", cmd2));
            return;
        }

        final byte[] fullPayload;
        if (chunkCount == 1) {
            // Single-chunk payload - just pass it through
            fullPayload = payload;
        } else {
            final ChunkBuffer buffer;
            if (chunkBuffers.containsKey(cmd)) {
                buffer = Objects.requireNonNull(chunkBuffers.get(cmd));
            } else {
                buffer = new ChunkBuffer();
            }

            if (chunkIndex != buffer.expectedChunk) {
                LOG.warn("Got unexpected chunk, expected {}", buffer.expectedChunk);

                if (chunkIndex != 1) {
                    // This chunk is not the first one and we got out of sync - ignore it and do not proceed
                    return;
                }

                // Just discard whatever we had and start over
                buffer.baos.reset();
            }

            try {
                buffer.baos.write(payload);
            } catch (final IOException e) {
                LOG.error("Failed to write payload to chunk buffer", e);
                return;
            }

            buffer.expectedChunk = chunkIndex + 1;

            if (chunkIndex != chunkCount) {
                // Chunk buffer not full yet
                return;
            }

            LOG.debug("Got all {} chunks for {}", chunkCount, cmd);

            fullPayload = buffer.baos.toByteArray().clone();
            chunkBuffers.remove(cmd);
        }

        if (handler == null) {
            LOG.error("Handler is null for {}", characteristicUUID);
            return;
        }

        try {
            handler.onCommand(cmd, fullPayload);
        } catch (final Exception e) {
            LOG.error("Exception while handling command", e);
        }
    }

    public interface Handler {
        void onCommand(CmfCommand cmd, byte[] payload);
    }

    private static class ChunkBuffer {
        private int expectedChunk = 1;
        private final ByteArrayOutputStream baos = new ByteArrayOutputStream();
    }
}
