package com.wytehat.btlogger;

import android.content.Context;
import android.content.SharedPreferences;
import android.os.Build;
import android.provider.Settings;
import android.util.Base64;

import java.security.KeyStore;
import java.util.UUID;

import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;

/**
 * The identifier this phone reports itself as, sent in X-Device-ID on every
 * upload so the server can attribute sightings to the device that saw them.
 *
 * Settings.Secure.ANDROID_ID is preferred: it survives app reinstalls, costs
 * nothing to read, and needs no permission. It is not always usable, though -
 * it is null before the device finishes first boot, and a well-known family of
 * buggy builds all report the same constant. When it cannot be trusted, a
 * random UUID is generated once and kept.
 *
 * That fallback is stored encrypted. The plain identifier would otherwise sit
 * in a readable preferences file, and on a rooted or backed-up device that is
 * a stable handle on the user that anything able to read the file could lift.
 * Encryption uses an AES-GCM key held in the AndroidKeyStore, so the key
 * material itself never enters the app process and cannot be pulled out of a
 * backup.
 *
 * androidx.security's EncryptedSharedPreferences does exactly this, but it
 * needs the AndroidX support libraries and API 23; this project is built in
 * AIDE with no dependency resolution and minSdk 14, so the same construction
 * is done directly against the platform Keystore. Below API 23 there is no
 * Keystore AES support and the value is stored in the clear - see
 * {@link #isFallbackEncrypted}.
 *
 * Values produced here match what the server accepts: 16 lower-case hex digits
 * (ANDROID_ID) or a canonical UUID (the fallback).
 */
public final class DeviceIdentity {

    private static final String PREFS = "device_identity";

    /** Encrypted (or, below API 23, plain) fallback identifier. */
    private static final String KEY_DEVICE_UUID = "device_uuid";

    private static final String KEYSTORE = "AndroidKeyStore";
    private static final String KEY_ALIAS = "btlogger_device_id_v1";
    private static final String TRANSFORMATION = "AES/GCM/NoPadding";

    /** 96 bits is the IV size GCM is specified around; anything else is slower. */
    private static final int GCM_IV_BYTES = 12;
    private static final int GCM_TAG_BITS = 128;

    /**
     * Shipped in a number of early builds, where every unit returned it. It is
     * a real ANDROID_ID value, so it passes a format check, but it identifies a
     * model rather than a phone and must not be used.
     */
    private static final String KNOWN_BAD_ANDROID_ID = "9774d56d682e549c";

    /** Resolved once per process; the value cannot change while we are running. */
    private static volatile String cached;

    private DeviceIdentity() {
    }

    /**
     * The identifier to send in X-Device-ID. Never null, never empty.
     *
     * Safe to call from any thread, including the upload worker.
     */
    public static String get(Context context) {

        String local = cached;

        if (local != null) {
            return local;
        }

        synchronized (DeviceIdentity.class) {

            if (cached == null) {
                cached = resolve(context.getApplicationContext());
            }

            return cached;
        }
    }

    /** True when the stored fallback is actually encrypted at rest. */
    public static boolean isFallbackEncrypted() {
        return Build.VERSION.SDK_INT >= 23;
    }

    // ------------------------------------------------------------------
    // Resolution
    // ------------------------------------------------------------------

    private static String resolve(Context context) {

        String androidId = readAndroidId(context);

        if (androidId != null) {
            return androidId;
        }

        return storedUuid(context);
    }

    /**
     * ANDROID_ID, lower-cased, or null when it cannot be trusted.
     *
     * Rejects the value outright rather than trying to repair it: a short or
     * non-hex reading means the platform gave us something we do not
     * understand, and guessing at it would produce an identifier that changes
     * between reads.
     */
    private static String readAndroidId(Context context) {

        String value;

        try {
            value = Settings.Secure.getString(
                context.getContentResolver(), Settings.Secure.ANDROID_ID);
        } catch (Exception error) {
            // Some heavily modified builds throw rather than returning null.
            return null;
        }

        if (value == null) {
            return null;
        }

        value = value.trim().toLowerCase();

        if (value.length() != 16 || !isHex(value)) {
            return null;
        }

        if (KNOWN_BAD_ANDROID_ID.equals(value)) {
            return null;
        }

        // All-zero shows up on emulators and on devices read before the ID has
        // been generated. It is not unique to anything.
        if (value.equals("0000000000000000")) {
            return null;
        }

        return value;
    }

    /**
     * The persisted fallback UUID, generating and storing one on first use.
     *
     * A stored value that cannot be read back - because the Keystore key was
     * cleared, or the app was restored onto a different device where the key
     * did not come with it - is replaced rather than treated as fatal. The
     * device gets a new identity in that case, which is the honest outcome:
     * the old one is genuinely unrecoverable.
     */
    private static String storedUuid(Context context) {

        SharedPreferences prefs =
            context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);

        String stored = prefs.getString(KEY_DEVICE_UUID, null);

        if (stored != null) {

            String plain = isFallbackEncrypted() ? decrypt(stored) : stored;

            if (isUuid(plain)) {
                return plain;
            }
        }

        String fresh = UUID.randomUUID().toString();

        String toStore = isFallbackEncrypted() ? encrypt(fresh) : fresh;

        if (toStore == null) {
            // Encryption is unavailable on this device. Storing the identifier
            // in the clear is worse than not persisting it, so keep it for the
            // life of the process only; the next launch generates another.
            return fresh;
        }

        prefs.edit().putString(KEY_DEVICE_UUID, toStore).commit();

        return fresh;
    }

    // ------------------------------------------------------------------
    // Validation
    // ------------------------------------------------------------------

    private static boolean isHex(String value) {

        for (int index = 0; index < value.length(); index++) {

            char character = value.charAt(index);

            boolean digit = character >= '0' && character <= '9';
            boolean letter = character >= 'a' && character <= 'f';

            if (!digit && !letter) {
                return false;
            }
        }

        return true;
    }

    /** Canonical 8-4-4-4-12 lower-case UUID, the shape the server accepts. */
    private static boolean isUuid(String value) {

        if (value == null || value.length() != 36) {
            return false;
        }

        for (int index = 0; index < 36; index++) {

            char character = value.charAt(index);

            if (index == 8 || index == 13 || index == 18 || index == 23) {
                if (character != '-') {
                    return false;
                }
                continue;
            }

            boolean digit = character >= '0' && character <= '9';
            boolean letter = character >= 'a' && character <= 'f';

            if (!digit && !letter) {
                return false;
            }
        }

        return true;
    }

    // ------------------------------------------------------------------
    // Keystore-backed encryption
    //
    // Isolated behind SDK_INT checks and only touched on API 23+, so the
    // android.security.keystore classes are never resolved on older devices.
    // ------------------------------------------------------------------

    /** "ivBase64:ciphertextBase64", or null if the device cannot encrypt. */
    private static String encrypt(String plain) {

        try {

            Cipher cipher = Cipher.getInstance(TRANSFORMATION);
            cipher.init(Cipher.ENCRYPT_MODE, secretKey());

            byte[] iv = cipher.getIV();
            byte[] ciphertext = cipher.doFinal(plain.getBytes("UTF-8"));

            return Base64.encodeToString(iv, Base64.NO_WRAP)
                + ":"
                + Base64.encodeToString(ciphertext, Base64.NO_WRAP);

        } catch (Exception error) {
            return null;
        }
    }

    /** Plaintext, or null when the value cannot be recovered. */
    private static String decrypt(String stored) {

        try {

            int separator = stored.indexOf(':');

            if (separator <= 0 || separator == stored.length() - 1) {
                return null;
            }

            byte[] iv = Base64.decode(
                stored.substring(0, separator), Base64.NO_WRAP);
            byte[] ciphertext = Base64.decode(
                stored.substring(separator + 1), Base64.NO_WRAP);

            if (iv.length != GCM_IV_BYTES) {
                return null;
            }

            Cipher cipher = Cipher.getInstance(TRANSFORMATION);
            cipher.init(Cipher.DECRYPT_MODE, secretKey(),
                new GCMParameterSpec(GCM_TAG_BITS, iv));

            return new String(cipher.doFinal(ciphertext), "UTF-8");

        } catch (Exception error) {
            // Includes AEADBadTagException when the ciphertext was tampered
            // with, and KeyPermanentlyInvalidatedException after a restore.
            return null;
        }
    }

    /**
     * The AES key for this install, created on first use.
     *
     * Generated inside the Keystore, so the raw key never exists in the app's
     * memory and does not leave the device in a backup.
     */
    private static SecretKey secretKey() throws Exception {

        KeyStore keyStore = KeyStore.getInstance(KEYSTORE);
        keyStore.load(null);

        KeyStore.Entry existing = keyStore.getEntry(KEY_ALIAS, null);

        if (existing instanceof KeyStore.SecretKeyEntry) {
            return ((KeyStore.SecretKeyEntry) existing).getSecretKey();
        }

        KeyGenerator generator =
            KeyGenerator.getInstance(android.security.keystore.KeyProperties.KEY_ALGORITHM_AES,
                KEYSTORE);

        generator.init(new android.security.keystore.KeyGenParameterSpec.Builder(
                KEY_ALIAS,
                android.security.keystore.KeyProperties.PURPOSE_ENCRYPT
                    | android.security.keystore.KeyProperties.PURPOSE_DECRYPT)
            .setBlockModes(android.security.keystore.KeyProperties.BLOCK_MODE_GCM)
            .setEncryptionPaddings(
                android.security.keystore.KeyProperties.ENCRYPTION_PADDING_NONE)
            // No user-authentication requirement: uploads run from a background
            // service while the phone is locked, and a key that needed an
            // unlock would make the identifier unreadable exactly then.
            .setRandomizedEncryptionRequired(true)
            .build());

        return generator.generateKey();
    }
}
