/*  Copyright (C) 2023-2024 Andreas Shimokawa, 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.xiaomi;

import android.content.SharedPreferences;
import android.os.Build;

import androidx.annotation.Nullable;

import com.google.protobuf.ByteString;

import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.bouncycastle.shaded.crypto.CryptoException;
import org.bouncycastle.shaded.crypto.engines.AESEngine;
import org.bouncycastle.shaded.crypto.modes.CCMBlockCipher;
import org.bouncycastle.shaded.crypto.params.AEADParameters;
import org.bouncycastle.shaded.crypto.params.KeyParameter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Locale;

import javax.crypto.Mac;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;

import nodomain.freeyourgadget.gadgetbridge.BuildConfig;
import nodomain.freeyourgadget.gadgetbridge.GBApplication;
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice;
import nodomain.freeyourgadget.gadgetbridge.proto.xiaomi.XiaomiProto;
import nodomain.freeyourgadget.gadgetbridge.service.devices.xiaomi.services.AbstractXiaomiService;
import nodomain.freeyourgadget.gadgetbridge.util.GB;

public class XiaomiAuthService extends AbstractXiaomiService {
    private static final Logger LOG = LoggerFactory.getLogger(XiaomiAuthService.class);


    public static final int COMMAND_TYPE = 1;

    public static final int CMD_SEND_USERID = 5;
    public static final int CMD_NONCE = 26;
    public static final int CMD_AUTH = 27;

    private boolean encryptionInitialized = false;

    private final byte[] secretKey = new byte[16];
    private final byte[] nonce = new byte[16];
    private final byte[] encryptionKey = new byte[16];
    private final byte[] decryptionKey = new byte[16];
    private final byte[] encryptionNonce = new byte[4];
    private final byte[] decryptionNonce = new byte[4];

    public XiaomiAuthService(final XiaomiSupport support) {
        super(support);
    }

    public boolean isEncryptionInitialized() {
        return encryptionInitialized;
    }

    protected void startEncryptedHandshake() {
        encryptionInitialized = false;

        System.arraycopy(getSecretKey(getSupport().getDevice()), 0, secretKey, 0, 16);
        new SecureRandom().nextBytes(nonce);

        getSupport().sendCommand("auth step 1", buildNonceCommand(nonce));
    }

    protected void startClearTextHandshake() {
        final XiaomiProto.Auth auth = XiaomiProto.Auth.newBuilder()
                .setUserId(getUserId(getSupport().getDevice()))
                .build();

        final XiaomiProto.Command command = XiaomiProto.Command.newBuilder()
                .setType(XiaomiAuthService.COMMAND_TYPE)
                .setSubtype(XiaomiAuthService.CMD_SEND_USERID)
                .setAuth(auth)
                .build();

        getSupport().sendCommand("auth step 1", command);
    }

    @Override
    public void handleCommand(final XiaomiProto.Command cmd) {
        if (cmd.getType() != COMMAND_TYPE) {
            throw new IllegalArgumentException("Not an auth command");
        }

        switch (cmd.getSubtype()) {
            case CMD_NONCE: {
                LOG.debug("Got watch nonce");

                // Watch nonce
                final XiaomiProto.Command command = handleWatchNonce(cmd.getAuth().getWatchNonce());

                if (command == null) {
                    LOG.error("handleWatchNonce returned null, disconnecting");
                    final GBDevice device = getSupport().getDevice();

                    if (device != null) {
                        GBApplication.deviceService(device).disconnect();
                    }

                    return;
                }

                getSupport().sendCommand("auth step 2", command);
                break;
            }

            case CMD_AUTH:
            case CMD_SEND_USERID: {
                if (cmd.getSubtype() == CMD_AUTH || cmd.getAuth().getStatus() == 1) {
                    encryptionInitialized = cmd.getSubtype() == CMD_AUTH;

                    LOG.info("Authenticated, further communications are {}", encryptionInitialized ? "encrypted" : "in plaintext");

                    getSupport().getDevice().setState(GBDevice.State.INITIALIZED);
                    getSupport().getDevice().sendDeviceUpdateIntent(getSupport().getContext(), GBDevice.DeviceUpdateSubject.DEVICE_STATE);

                    getSupport().onAuthSuccess();
                } else {
                    LOG.warn("could not authenticate");
                }
                break;
            }
            default:
                LOG.warn("Unknown auth payload subtype {}", cmd.getSubtype());
        }
    }

    public byte[] encrypt(final byte[] arr, final int i) {
        final ByteBuffer packetNonce = ByteBuffer.allocate(12).order(ByteOrder.LITTLE_ENDIAN)
                .put(encryptionNonce)
                .putInt(0)
                .putInt(i);

        try {
            return encrypt(encryptionKey, packetNonce.array(), arr);
        } catch (final CryptoException e) {
            throw new RuntimeException("failed to encrypt", e);
        }
    }

    public byte[] decrypt(final byte[] arr) {
        final ByteBuffer packetNonce = ByteBuffer.allocate(12).order(ByteOrder.LITTLE_ENDIAN);
        packetNonce.put(decryptionNonce);
        packetNonce.putInt(0);
        packetNonce.putInt(0);

        try {
            return decrypt(decryptionKey, packetNonce.array(), arr);
        } catch (final CryptoException e) {
            throw new RuntimeException("failed to decrypt", e);
        }
    }

    @Nullable
    private XiaomiProto.Command handleWatchNonce(final XiaomiProto.WatchNonce watchNonce) {
        final byte[] step2hmac = computeAuthStep3Hmac(secretKey, nonce, watchNonce.getNonce().toByteArray());

        System.arraycopy(step2hmac, 0, decryptionKey, 0, 16);
        System.arraycopy(step2hmac, 16, encryptionKey, 0, 16);
        System.arraycopy(step2hmac, 32, decryptionNonce, 0, 4);
        System.arraycopy(step2hmac, 36, encryptionNonce, 0, 4);

        if (BuildConfig.DEBUG) {
            LOG.debug("decryptionKey: {}", GB.hexdump(decryptionKey));
            LOG.debug("encryptionKey: {}", GB.hexdump(encryptionKey));
            LOG.debug("decryptionNonce: {}", GB.hexdump(decryptionNonce));
            LOG.debug("encryptionNonce: {}", GB.hexdump(encryptionNonce));
        }

        final byte[] decryptionConfirmation = hmacSHA256(decryptionKey, ArrayUtils.addAll(watchNonce.getNonce().toByteArray(), nonce));
        if (!Arrays.equals(decryptionConfirmation, watchNonce.getHmac().toByteArray())) {
            LOG.warn("Watch hmac mismatch");
            return null;
        }

        final XiaomiProto.AuthDeviceInfo authDeviceInfo = XiaomiProto.AuthDeviceInfo.newBuilder()
                .setUnknown1(0) // TODO ?
                .setPhoneApiLevel(Build.VERSION.SDK_INT)
                .setPhoneName(Build.MODEL)
                .setUnknown3(224) // TODO ?
                // TODO region should be actual device region?
                .setRegion(Locale.getDefault().getLanguage().substring(0, 2).toUpperCase(Locale.ROOT))
                .build();

        final byte[] encryptedNonces = hmacSHA256(encryptionKey, ArrayUtils.addAll(nonce, watchNonce.getNonce().toByteArray()));
        final byte[] encryptedDeviceInfo = encrypt(authDeviceInfo.toByteArray(), 0);
        final XiaomiProto.AuthStep3 authStep3 = XiaomiProto.AuthStep3.newBuilder()
                .setEncryptedNonces(ByteString.copyFrom(encryptedNonces))
                .setEncryptedDeviceInfo(ByteString.copyFrom(encryptedDeviceInfo))
                .build();

        final XiaomiProto.Command.Builder cmd = XiaomiProto.Command.newBuilder();
        cmd.setType(COMMAND_TYPE);
        cmd.setSubtype(CMD_AUTH);

        final XiaomiProto.Auth.Builder auth = XiaomiProto.Auth.newBuilder();
        auth.setAuthStep3(authStep3);

        return cmd.setAuth(auth.build()).build();
    }

    public static XiaomiProto.Command buildNonceCommand(final byte[] nonce) {
        final XiaomiProto.PhoneNonce.Builder phoneNonce = XiaomiProto.PhoneNonce.newBuilder();
        phoneNonce.setNonce(ByteString.copyFrom(nonce));

        final XiaomiProto.Auth.Builder auth = XiaomiProto.Auth.newBuilder();
        auth.setPhoneNonce(phoneNonce.build());

        final XiaomiProto.Command.Builder command = XiaomiProto.Command.newBuilder();
        command.setType(COMMAND_TYPE);
        command.setSubtype(CMD_NONCE);
        command.setAuth(auth.build());
        return command.build();
    }

    public static byte[] computeAuthStep3Hmac(final byte[] secretKey,
                                              final byte[] phoneNonce,
                                              final byte[] watchNonce) {
        final byte[] miwearAuthBytes = "miwear-auth".getBytes();

        final Mac mac;
        try {
            mac = Mac.getInstance("HmacSHA256");
            // Compute the actual key and re-initialize the mac
            mac.init(new SecretKeySpec(ArrayUtils.addAll(phoneNonce, watchNonce), "HmacSHA256"));
            final byte[] hmacKeyBytes = mac.doFinal(secretKey);
            final SecretKeySpec key = new SecretKeySpec(hmacKeyBytes, "HmacSHA256");
            mac.init(key);
        } catch (final NoSuchAlgorithmException | InvalidKeyException e) {
            throw new IllegalStateException("Failed to initialize hmac for auth step 2", e);
        }

        final byte[] output = new byte[64];
        byte[] tmp = new byte[0];
        byte b = 1;
        int i = 0;
        while (i < output.length) {
            mac.update(tmp);
            mac.update(miwearAuthBytes);
            mac.update(b);
            tmp = mac.doFinal();
            for (int j = 0; j < tmp.length && i < output.length; j++, i++) {
                output[i] = tmp[j];
            }
            b++;
        }
        return output;
    }

    protected static byte[] getSecretKey(final GBDevice device) {
        final byte[] authKeyBytes = new byte[16];

        final SharedPreferences sharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(device.getAddress());

        final String authKey = sharedPrefs.getString("authkey", "").trim();
        if (StringUtils.isNotBlank(authKey)) {
            final byte[] srcBytes;
            // Allow both with and without 0x, to avoid user mistakes
            if (authKey.length() == 34 && authKey.startsWith("0x")) {
                srcBytes = GB.hexStringToByteArray(authKey.trim().substring(2));
            } else {
                srcBytes = GB.hexStringToByteArray(authKey.trim());
            }
            System.arraycopy(srcBytes, 0, authKeyBytes, 0, Math.min(srcBytes.length, 16));
        }

        return authKeyBytes;
    }

    protected static String getUserId(final GBDevice device) {
        final SharedPreferences sharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(device.getAddress());

        final String authKey = sharedPrefs.getString("authkey", null);
        if (StringUtils.isNotBlank(authKey)) {
            return authKey;
        }

        return "0000000000";
    }

    protected static byte[] hmacSHA256(final byte[] key, final byte[] input) {
        try {
            final Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(key, "HmacSHA256"));
            return mac.doFinal(input);
        } catch (final Exception e) {
            throw new RuntimeException("Failed to hmac", e);
        }
    }

    public static byte[] encrypt(final byte[] key, final byte[] nonce, final byte[] payload) throws
            CryptoException {
        final CCMBlockCipher cipher = createBlockCipher(true, new SecretKeySpec(key, "AES"), nonce);
        final byte[] out = new byte[cipher.getOutputSize(payload.length)];
        final int outBytes = cipher.processBytes(payload, 0, payload.length, out, 0);
        cipher.doFinal(out, outBytes);
        return out;
    }

    public static byte[] decrypt(final byte[] key,
                                 final byte[] nonce,
                                 final byte[] encryptedPayload) throws CryptoException {
        final CCMBlockCipher cipher = createBlockCipher(false, new SecretKeySpec(key, "AES"), nonce);
        final byte[] decrypted = new byte[cipher.getOutputSize(encryptedPayload.length)];
        cipher.doFinal(decrypted, cipher.processBytes(encryptedPayload, 0, encryptedPayload.length, decrypted, 0));
        return decrypted;
    }

    public static CCMBlockCipher createBlockCipher(final boolean forEncrypt,
                                                   final SecretKey secretKey,
                                                   final byte[] nonce) {
        final AESEngine aesFastEngine = new AESEngine();
        aesFastEngine.init(forEncrypt, new KeyParameter(secretKey.getEncoded()));
        final CCMBlockCipher blockCipher = new CCMBlockCipher(aesFastEngine);
        blockCipher.init(forEncrypt, new AEADParameters(new KeyParameter(secretKey.getEncoded()), 32, nonce, null));
        return blockCipher;
    }
}
