/*
 * Java
 *
 * Copyright 2019-2020 MicroEJ Corp. All rights reserved.
 * This library is provided in source code for use, modification and test, subject to license terms.
 * Any modification of the source code will break MicroEJ Corp. warranties on the whole library.
 */
package ej.bluetooth.util;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import ej.annotation.Nullable;
import ej.bluetooth.BluetoothDataTypes;
import ej.bluetooth.BluetoothUuid;
import ej.bon.ByteArray;
import ej.util.message.Level;

/**
 * The <code>AdvertisementData</code> class represents the content of a Bluetooth advertisement data payload.
 * <p>
 * This helper class may be used to parse the advertisement data received in the
 * {@link ej.bluetooth.listeners.ConnectionListener#onScanResult(ej.bluetooth.BluetoothAddress, byte[], int) scan result
 * callback}, by using the {@link #parse(byte[]) parse} method of this class.
 * <p>
 * This class may also be used in order to build an advertisement data payload which is used in the
 * {@link ej.bluetooth.BluetoothAdapter#startAdvertising(byte[]) start advertising API}, by using the constructor of
 * this class, its set/add methods and finally its {@link #serialize() serialize} method.
 */
public class AdvertisementData {

	private static final int MAX_DATA_SIZE = 31;

	private byte flags;
	private @Nullable String deviceName;
	private short appearance;
	private final Map<Short, byte[]> manufacturerData;
	private final List<BluetoothUuid> serviceUuids;

	/**
	 * Creates an advertisement data object with the default advertisement flags and no other content.
	 */
	public AdvertisementData() {
		this.flags = AdvertisementFlags.LE_GENERAL_DISC_MODE | AdvertisementFlags.BR_EDR_NOT_SUPPORTED;
		this.deviceName = null;
		this.appearance = 0;
		this.manufacturerData = new HashMap<>();
		this.serviceUuids = new ArrayList<>();
	}

	/**
	 * Gets the advertisement flags contained in this advertisement data.
	 *
	 * @return the advertisement flags (see {@link AdvertisementFlags}).
	 */
	public byte getFlags() {
		return this.flags;
	}

	/**
	 * Gets the device name contained in this advertisement data.
	 *
	 * @return the device name, or {@code null} if it is not defined.
	 */
	public @Nullable String getDeviceName() {
		return this.deviceName;
	}

	/**
	 * Gets the appearance contained in this advertisement data.
	 *
	 * @return the appearance, or {@code 0} if it is not defined.
	 */
	public short getAppearance() {
		return this.appearance;
	}

	/**
	 * Gets the manufacturer data associated with the given manufacturer ID contained in this advertisement data.
	 *
	 * @param manufacturerId
	 *            the manufacturer ID.
	 * @return the manufacturer data associated with the given manufacturer ID, or {@code null} if it is not defined.
	 */
	public @Nullable byte[] getManufacturerData(int manufacturerId) {
		byte[] data = this.manufacturerData.get(Short.valueOf((short) manufacturerId));
		return (data == null ? null : Arrays.copyOf(data, data.length));
	}

	/**
	 * Gets the number of service UUIDs.
	 *
	 * @return the number of service UUIDs.
	 */
	public int getNumServiceUuids() {
		return this.serviceUuids.size();
	}

	/**
	 * Gets the service UUID at the given index.
	 *
	 * @param index
	 *            the index of the service UUID.
	 * @return the service UUID at the given index.
	 * @throws IndexOutOfBoundsException
	 *             if the index is out of range ({@code index < 0 || index >= getNumServiceUuids()}).
	 */
	public BluetoothUuid getServiceUuid(int index) {
		BluetoothUuid serviceUuid = this.serviceUuids.get(index);
		assert (serviceUuid != null);
		return serviceUuid;
	}

	/**
	 * Sets the advertisement flags of this advertisement data.
	 *
	 * @param flags
	 *            the advertisement flags (see {@link AdvertisementFlags}).
	 */
	public void setFlags(int flags) {
		this.flags = (byte) flags;
	}

	/**
	 * Sets the device name of this advertisement data.
	 *
	 * @param deviceName
	 *            the device name.
	 */
	public void setDeviceName(String deviceName) {
		this.deviceName = deviceName;
	}

	/**
	 * Sets the appearance of this advertisement data.
	 *
	 * @param appearance
	 *            the appearance.
	 */
	public void setAppearance(short appearance) {
		this.appearance = appearance;
	}

	/**
	 * Adds manufacturer data associated with the given manufacturer ID to this advertisement data.
	 *
	 * @param manufacturerId
	 *            the manufacturer ID.
	 * @param data
	 *            the manufacturer data.
	 */
	public void addManufacturerData(int manufacturerId, byte[] data) {
		this.manufacturerData.put(Short.valueOf((short) manufacturerId), Arrays.copyOf(data, data.length));
	}

	/**
	 * Adds a service UUID.
	 *
	 * @param serviceUuid
	 *            the service UUID.
	 */
	public void addServiceUuid(BluetoothUuid serviceUuid) {
		if (!this.serviceUuids.contains(serviceUuid)) {
			this.serviceUuids.add(serviceUuid);
		}
	}

	/**
	 * Serializes this advertisement data into a byte array.
	 *
	 * @return the serialized payload.
	 */
	public byte[] serialize() {
		byte[] buffer = new byte[MAX_DATA_SIZE];
		int offset = 0;

		// add flags
		if (this.flags != 0) {
			offset = addField(buffer, offset, BluetoothDataTypes.FLAGS, new byte[] { this.flags });
		}

		// add device name
		if (this.deviceName != null) {
			offset = addField(buffer, offset, BluetoothDataTypes.COMPLETE_LOCAL_NAME, this.deviceName.getBytes());
		}

		// add appearance
		if (this.appearance != 0) {
			byte[] data = new byte[2];
			ByteArray.writeShort(data, 0, this.appearance, ByteArray.LITTLE_ENDIAN);
			offset = addField(buffer, offset, BluetoothDataTypes.APPEARANCE, data);
		}

		// add manufacturer data
		for (Map.Entry<Short, byte[]> entry : this.manufacturerData.entrySet()) {
			Short id = entry.getKey();
			byte[] value = entry.getValue();
			assert (id != null && value != null);

			byte[] data = new byte[2 + value.length];
			ByteArray.writeShort(data, 0, id.shortValue(), ByteArray.LITTLE_ENDIAN);
			System.arraycopy(value, 0, data, 2, value.length);
			offset = addField(buffer, offset, BluetoothDataTypes.MANUFACTURER_SPECIFIC_DATA, data);
		}

		// count 16-bit and 128-bit service UUIDs
		int num16BitUuids = 0;
		int num128BitUuids = 0;
		for (BluetoothUuid serviceUuid : this.serviceUuids) {
			assert (serviceUuid != null);
			if (serviceUuid.is16Bit()) {
				num16BitUuids++;
			} else {
				num128BitUuids++;
			}
		}

		// add 16-bit service UUIDs
		if (num16BitUuids > 0) {
			byte[] data = new byte[num16BitUuids * 2];
			int uuidIndex = 0;
			for (BluetoothUuid serviceUuid : this.serviceUuids) {
				assert (serviceUuid != null);
				if (serviceUuid.is16Bit()) {
					short uuid16 = serviceUuid.get16BitValue();
					ByteArray.writeShort(data, uuidIndex * 2, uuid16, ByteArray.LITTLE_ENDIAN);
					uuidIndex++;
				}
			}
			offset = addField(buffer, offset, BluetoothDataTypes.SERVICE_UUID16_INCOMPLETE_LIST, data);
		}

		// add 128-bit service UUIDs
		if (num128BitUuids > 0) {
			byte[] data = new byte[num128BitUuids * 16];
			int uuidIndex = 0;
			for (BluetoothUuid serviceUuid : this.serviceUuids) {
				assert (serviceUuid != null);
				if (!serviceUuid.is16Bit()) {
					byte[] uuid128 = new byte[16];
					serviceUuid.getBytes(uuid128, 0);
					swapEndianness(uuid128, data, uuidIndex * 16);
					uuidIndex++;
				}
			}
			offset = addField(buffer, offset, BluetoothDataTypes.SERVICE_UUID128_INCOMPLETE_LIST, data);
		}

		return Arrays.copyOf(buffer, offset);
	}

	private static int addField(byte[] buffer, int offset, byte type, byte[] value) {
		if (offset + 2 + value.length > MAX_DATA_SIZE) {
			Messages.getLogger().log(Level.WARNING, Messages.CATEGORY,
					Messages.ERROR_MAX_ADVERTISEMENT_DATA_SIZE_REACHED);
			return offset;
		} else {
			buffer[offset] = (byte) (1 + value.length);
			buffer[offset + 1] = type;
			System.arraycopy(value, 0, buffer, offset + 2, value.length);
			return offset + 2 + value.length;
		}
	}

	/**
	 * Parses the given advertisement data payload and returns an advertisement data object.
	 *
	 * @param data
	 *            the advertisement data payload.
	 * @return the advertisement data object.
	 */
	public static AdvertisementData parse(byte[] data) {
		AdvertisementData advertisementData = new AdvertisementData();

		int offset = 0;
		while (offset < data.length) {
			int length = data[offset];
			if (length == 0 || offset + 1 + length > data.length) {
				break;
			}

			byte type = data[offset + 1];

			byte[] value = new byte[length - 1];
			System.arraycopy(data, offset + 2, value, 0, length - 1);
			parseField(type, value, advertisementData);

			offset += 1 + length;
		}

		return advertisementData;
	}

	private static void parseField(byte type, byte[] value, AdvertisementData advertisementData) {
		switch (type) {
		case BluetoothDataTypes.FLAGS:
			if (value.length >= 1) {
				advertisementData.setFlags(value[0]);
			}
			break;
		case BluetoothDataTypes.COMPLETE_LOCAL_NAME:
		case BluetoothDataTypes.SHORTENED_LOCAL_NAME:
			advertisementData.setDeviceName(new String(value));
			break;
		case BluetoothDataTypes.APPEARANCE:
			if (value.length == 2) {
				short appearance = ByteArray.readShort(value, 0, ByteArray.LITTLE_ENDIAN);
				advertisementData.setAppearance(appearance);
			}
			break;
		case BluetoothDataTypes.MANUFACTURER_SPECIFIC_DATA:
			if (value.length >= 2) {
				short manufacturerId = ByteArray.readShort(value, 0, ByteArray.LITTLE_ENDIAN);
				byte[] manufacturerData = Arrays.copyOfRange(value, 2, value.length);
				advertisementData.addManufacturerData(manufacturerId, manufacturerData);
			}
			break;
		case BluetoothDataTypes.SERVICE_UUID16_COMPLETE_LIST:
		case BluetoothDataTypes.SERVICE_UUID16_INCOMPLETE_LIST:
			if (value.length == 2) {
				short uuid16 = ByteArray.readShort(value, 0, ByteArray.LITTLE_ENDIAN);
				advertisementData.addServiceUuid(new BluetoothUuid(uuid16));
			}
			break;
		case BluetoothDataTypes.SERVICE_UUID128_COMPLETE_LIST:
		case BluetoothDataTypes.SERVICE_UUID128_INCOMPLETE_LIST:
			if (value.length == 16) {
				byte[] uuid128 = new byte[16];
				swapEndianness(value, uuid128, 0);
				advertisementData.addServiceUuid(new BluetoothUuid(uuid128, 0));
			}
			break;
		default:
		}
	}

	private static void swapEndianness(byte[] in, byte[] out, int outOffset) {
		int length = in.length;
		for (int i = 0; i < length; i++) {
			out[outOffset + i] = in[length - 1 - i];
		}
	}
}
