package org.sputnikdev.bluetooth.gattparser;

/*-
 * #%L
 * org.sputnikdev:bluetooth-gatt-parser
 * %%
 * Copyright (C) 2017 Sputnik Dev
 * %%
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 * 
 *      http://www.apache.org/licenses/LICENSE-2.0
 * 
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 * #L%
 */

import org.apache.commons.beanutils.converters.AbstractConverter;
import org.apache.commons.beanutils.converters.ArrayConverter;
import org.apache.commons.beanutils.converters.BigDecimalConverter;
import org.apache.commons.beanutils.converters.BigIntegerConverter;
import org.apache.commons.beanutils.converters.BooleanConverter;
import org.apache.commons.beanutils.converters.ByteConverter;
import org.apache.commons.beanutils.converters.DoubleConverter;
import org.apache.commons.beanutils.converters.FloatConverter;
import org.apache.commons.beanutils.converters.IntegerConverter;
import org.apache.commons.beanutils.converters.LongConverter;
import org.apache.commons.beanutils.converters.StringConverter;
import org.sputnikdev.bluetooth.gattparser.num.TwosComplementNumberFormatter;
import org.sputnikdev.bluetooth.gattparser.spec.Enumeration;
import org.sputnikdev.bluetooth.gattparser.spec.Field;
import org.sputnikdev.bluetooth.gattparser.spec.FieldFormat;
import org.sputnikdev.bluetooth.gattparser.spec.FieldType;
import org.sputnikdev.bluetooth.gattparser.spec.FlagUtils;

import java.io.UnsupportedEncodingException;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.math.RoundingMode;
import java.util.BitSet;

/**
 * Bluetooth GATT field holder. Field holder encapsulates notion about field type and field value as well as some
 * helper methods to access field values in a user-friendly manner.
 *
 * @author Vlad Kolotov
 */
public class FieldHolder {

    private final Field field;
    private Object value;

    /**
     * Creates a new field holder for a given GATT field and its raw value.
     * @param field GATT field specification
     * @param value field value
     */
    protected FieldHolder(Field field, Object value) {
        this.field = field;
        this.value = value;
    }

    /**
     * Create a new field holder for a given GATT field.
     * @param field GATT field specification
     */
    protected FieldHolder(Field field) {
        this.field = field;
    }

    /**
     * Returns the GATT field specification.
     * @return GATT field specification
     */
    public Field getField() {
        return field;
    }

    /**
     * Checks whether the field is a number.
     * @return true if a given field is a number, false otherwise
     */
    public boolean isNumber() {
        return field.getFormat().isNumber();
    }

    /**
     * Checks whether the field is of boolean type.
     * @return true if a given field is of type boolean, false otherwise
     */
    public boolean isBoolean() {
        return field.getFormat().isBoolean();
    }

    /**
     * Checks whether the field is of string type.
     * @return true if a given field is of type string, false otherwise
     */
    public boolean isString() {
        return field.getFormat().isString();
    }

    /**
     * Checks whether the field is of struct type.
     * @return true if a given field is of type struct, false otherwise
     */
    public boolean isStruct() {
        return field.getFormat().isStruct();
    }

    /**
     * Returns an Integer representation of the field or a default value in case if the field cannot
     * be converted to an Integer.
     * @param def the default value to be returned if an error occurs converting the field
     * @return an Integer representation of the field
     */
    public Integer getInteger(Integer def) {
        Integer result = new IntegerConverter(null).convert(Integer.class, prepareValue());
        if (result != null) {
            double multiplier = getMultiplier();
            double offset = getOffset();
            if (multiplier != 1.0 || offset != 0.0) {
                return (int) Math.round(result * multiplier + offset);
            } else {
                return result;
            }
        } else {
            return def;
        }
    }

    /**
     * Returns a Long representation of the field or a default value in case if the field cannot
     * be converted to a Long.
     * @param def the default value to be returned if an error occurs converting the field
     * @return a Long representation of the field
     */
    public Long getLong(Long def) {
        Long result = new LongConverter(null).convert(Long.class, prepareValue());
        if (result != null) {
            double multiplier = getMultiplier();
            double offset = getOffset();
            if (multiplier != 1.0 || offset != 0.0) {
                return Math.round(result * multiplier + offset);
            } else {
                return result;
            }
        } else {
            return def;
        }
    }

    /**
     * Returns a BigInteger representation of the field or a default value in case if the field cannot
     * be converted to a BigInteger.
     * @param def the default value to be returned if an error occurs converting the field
     * @return a BigInteger representation of the field
     */
    public BigInteger getBigInteger(BigInteger def) {
        BigDecimal result = new BigDecimalConverter(null).convert(BigDecimal.class, prepareValue());
        return result != null
                ? result.multiply(BigDecimal.valueOf(getMultiplier()))
                        .add(BigDecimal.valueOf(getOffset())).setScale(0, RoundingMode.HALF_UP).toBigInteger()
                : def;
    }

    /**
     * Returns a BigDecimal representation of the field or a default value in case if the field cannot
     * be converted to a BigDecimal.
     * @param def the default value to be returned if an error occurs converting the field
     * @return a BigDecimal representation of the field
     */
    public BigDecimal getBigDecimal(BigDecimal def) {
        BigDecimal result = new BigDecimalConverter(null).convert(BigDecimal.class, prepareValue());
        return result != null
                ? result.multiply(BigDecimal.valueOf(getMultiplier()))
                : def;
    }

    /**
     * Returns a Float representation of the field or a default value in case if the field cannot
     * be converted to a Float.
     * @param def the default value to be returned if an error occurs converting the field
     * @return a Float representation of the field
     */
    public Float getFloat(Float def) {
        Float result = new FloatConverter(null).convert(Float.class, prepareValue());
        if (result != null) {
            return (float) (result * getMultiplier() + getOffset());
        } else {
            return def;
        }
    }

    /**
     * Returns a Double representation of the field or a default value in case if the field cannot
     * be converted to a Double.
     * @param def the default value to be returned if an error occurs converting the field
     * @return a Double representation of the field
     */
    public Double getDouble(Double def) {
        Double result = new FloatConverter(null).convert(Double.class, prepareValue());
        if (result != null) {
            return result * getMultiplier() + getOffset();
        } else {
            return def;
        }
    }

    /**
     * Returns a Boolean representation of the field or a default value in case if the field cannot
     * be converted to a Boolean.
     * @param def the default value to be returned if an error occurs converting the field
     * @return a Boolean representation of the field
     */
    public Boolean getBoolean(Boolean def) {
        return new BooleanConverter(def).convert(Boolean.class, prepareValue());
    }

    /**
     * Returns a String representation of the field or a default value in case if the field cannot
     * be converted to a String.
     * @param def the default value to be returned if an error occurs converting the field
     * @return a String representation of the field
     */
    public String getString(String def) {
        return new StringConverter(def).convert(String.class, prepareValue());
    }

    /**
     * Returns an array representation of the field or a default value in case if the field cannot
     * be converted to array.
     * @param def the default value to be returned if an error occurs converting the field
     * @return an array representation of the field
     */
    public byte[] getBytes(byte[] def) {
        return new ArrayConverter(byte[].class, new ByteConverter()).convert(byte[].class, value);
    }

    /**
     * Returns an Integer representation of the field or null in case if the field cannot
     * be converted to an Integer.
     * @return an Integer representation of the field
     */
    public Integer getInteger() {
        return getInteger(null);
    }

    /**
     * Returns a Long representation of the field or null in case if the field cannot
     * be converted to a Long.
     * @return a Long representation of the field
     */
    public Long getLong() {
        return getLong(null);
    }

    /**
     * Returns a BigInteger representation of the field or null in case if the field cannot
     * be converted to a BigInteger.
     * @return a BigInteger representation of the field
     */
    public BigInteger getBigInteger() {
        return getBigInteger(null);
    }

    /**
     * Returns a BigDecimal representation of the field or null in case if the field cannot
     * be converted to a BigDecimal.
     * @return a BigDecimal representation of the field
     */
    public BigDecimal getBigDecimal() {
        return getBigDecimal(null);
    }

    /**
     * Returns a Float representation of the field or null in case if the field cannot
     * be converted to a Float.
     * @return a Float representation of the field
     */
    public Float getFloat() {
        return getFloat(null);
    }

    /**
     * Returns a Double representation of the field or null in case if the field cannot
     * be converted to a Double.
     * @return a Double representation of the field
     */
    public Double getDouble() {
        return getDouble(null);
    }

    /**
     * Returns a Boolean representation of the field or null in case if the field cannot
     * be converted to a Boolean.
     * @return a Boolean representation of the field
     */
    public Boolean getBoolean() {
        return getBoolean(null);
    }

    /**
     * Returns a String representation of the field or null in case if the field cannot
     * be converted to a String.
     * @return a String representation of the field
     */
    public String getString() {
        return getString(null);
    }

    /**
     * Returns an array representation of the field or null in case if the field cannot
     * be converted to an array.
     * @return a String representation of the field
     */
    public byte[] getBytes() {
        return getBytes(null);
    }

    /**
     * Returns field raw value.
     * @return field raw value
     */
    public Object getRawValue() {
        return value;
    }

    /**
     * Returns field enumeration according to the field value.
     * @return fields enumeration according to the field value
     */
    public Enumeration getEnumeration() {
        BigInteger key;
        if (field.getFormat().isStruct() && value instanceof byte[]) {
            byte[] data = (byte[]) value;
            key = new TwosComplementNumberFormatter().deserializeBigInteger(BitSet.valueOf(data),
                    data.length * 8, false);
        } else if (field.getFormat().isString() && value instanceof String) {
            String encoding = field.getFormat().getType() == FieldType.UTF8S ? "UTF-8" : "UTF-16";
            try {
                byte[] data = ((String) value).getBytes(encoding);
                key = new TwosComplementNumberFormatter().deserializeBigInteger(BitSet.valueOf(data),
                        data.length * 8, false);
            } catch (UnsupportedEncodingException e) {
                throw new IllegalStateException(e);
            }
        } else {
            key = getBigInteger();
        }
        return FlagUtils.getEnumeration(field, key).orElse(null);
    }

    /**
     * Returns field enumeration value according to the field value.
     * @return fields enumeration value according to the field value
     */
    public String getEnumerationValue() {
        Enumeration enumeration = getEnumeration();
        return enumeration != null ? enumeration.getValue() : null;
    }

    /**
     * Returns field enumeration "requires" according to the field value.
     * @return fields enumeration "requires" (or a its flag) according to the field value
     */
    public String getEnumerationRequires() {
        Enumeration enumeration = getEnumeration();
        return enumeration != null ? enumeration.getRequires() : null;
    }

    /**
     * Sets the field value into a new boolean value.
     * @param value a new field value
     */
    public void setBoolean(Boolean value) {
        this.value = value;
    }

    /**
     * Sets the field value into a new Integer value.
     * @param value a new field value
     */
    public void setInteger(Integer value) {
        if (value == null) {
            this.value = null;
        } else {
            Double maximum = field.getMaximum();
            if (maximum != null && maximum < value) {
                throw new IllegalArgumentException("Value [" + value + "] is greater than maximum: " + maximum);
            }
            Double minimum = field.getMinimum();
            if (minimum != null && minimum > value) {
                throw new IllegalArgumentException("Value [" + value + "] is less than minimum: " + minimum);
            }
            double multiplier = getMultiplier();
            double offset = getOffset();
            if (multiplier != 1.0 || offset != 0.0) {
                this.value = getConverter().convert(null, Math.round((value - offset) / multiplier));
            } else {
                this.value = getConverter().convert(null, value);
            }
        }
    }

    /**
     * Sets the field value into a new Long value.
     * @param value a new field value
     */
    public void setLong(Long value) {
        if (value == null) {
            this.value = null;
        } else {
            Double maximum = field.getMaximum();
            if (maximum != null && maximum < value) {
                throw new IllegalArgumentException("Value [" + value + "] is greater than maximum: " + maximum);
            }
            Double minimum = field.getMinimum();
            if (minimum != null && minimum > value) {
                throw new IllegalArgumentException("Value [" + value + "] is less than minimum: " + minimum);
            }
            double multiplier = getMultiplier();
            double offset = getOffset();
            if (multiplier != 1.0 || offset != 0.0) {
                this.value = getConverter().convert(null, Math.round((value - offset) / multiplier));
            } else {
                this.value = getConverter().convert(null, value);
            }
        }
    }

    /**
     * Sets the field value into a new BigInteger value.
     * @param value a new field value
     */
    public void setBigInteger(BigInteger value) {
        if (value == null) {
            this.value = null;
        } else {
            BigDecimal vl = new BigDecimal(value);
            Double maximum = field.getMaximum();
            if (maximum != null && vl.compareTo(new BigDecimal(maximum)) > 0) {
                throw new IllegalArgumentException("Value [" + value + "] is greater than maximum: " + maximum);
            }
            Double minimum = field.getMinimum();
            if (minimum != null && vl.compareTo(new BigDecimal(minimum)) < 0) {
                throw new IllegalArgumentException("Value [" + value + "] is less than minimum: " + minimum);
            }
            double multiplier = getMultiplier();
            double offset = getOffset();
            BigInteger adjusted;
            if (multiplier != 1.0 || offset != 0.0) {
                adjusted = vl.subtract(BigDecimal.valueOf(offset)).setScale(0, RoundingMode.HALF_UP)
                                .divide(BigDecimal.valueOf(multiplier)).toBigInteger();
            } else {
                adjusted = value;
            }

            if (field.getFormat().isStruct()) {
                this.value = new TwosComplementNumberFormatter().serialize(adjusted,
                        adjusted.bitLength(), false).toByteArray();
            } else {
                this.value = getConverter().convert(null, adjusted);
            }
        }
    }

    /**
     * Sets the field value into a new Float value.
     * @param value a new field value
     */
    public void setFloat(Float value) {
        if (value == null) {
            this.value = null;
        } else {
            Double maximum = field.getMaximum();
            if (maximum != null && maximum < value) {
                throw new IllegalArgumentException("Value [" + value + "] is greater than maximum: " + maximum);
            }
            Double minimum = field.getMinimum();
            if (minimum != null && minimum > value) {
                throw new IllegalArgumentException("Value [" + value + "] is less than minimum: " + minimum);
            }
            this.value = getConverter().convert(null, (value - getOffset()) / getMultiplier());
        }
    }

    /**
     * Sets the field value into a new Double value.
     * @param value a new field value
     */
    public void setDouble(Double value) {
        if (value == null) {
            this.value = null;
        } else {
            Double maximum = field.getMaximum();
            if (maximum != null && maximum < value) {
                throw new IllegalArgumentException("Value [" + value + "] is greater than maximum: " + maximum);
            }
            Double minimum = field.getMinimum();
            if (minimum != null && minimum > value) {
                throw new IllegalArgumentException("Value [" + value + "] is less than minimum: " + minimum);
            }
            this.value = getConverter().convert(null, (value - getOffset()) / getMultiplier());
        }
    }

    /**
     * Sets the field value into a new String value.
     * @param value a new field value
     */
    public void setString(String value) {
        this.value = value;
    }

    /**
     * Sets the field value to a "struct" (array) value from an array.
     * @param struct a new field value
     */
    public void setStruct(byte[] struct) {
        value = struct;
    }

    /**
     * Sets the field value from the given enumeration (enumeration key).
     * @param value a new field value
     */
    public void setEnumeration(Enumeration value) {
        if (value == null) {
            this.value = null;
        } else {

            BigInteger key = value.getKey();

            if (field.getFormat().isStruct()) {
                this.value = new TwosComplementNumberFormatter().serialize(key, key.bitLength(), false).toByteArray();
            } else if (field.getFormat().isString()) {
                String encoding = field.getFormat().getType() == FieldType.UTF8S ? "UTF-8" : "UTF-16";
                try {
                    this.value = new String(new TwosComplementNumberFormatter().serialize(key, key.bitLength(), false)
                            .toByteArray(), encoding);
                } catch (UnsupportedEncodingException e) {
                    throw new IllegalStateException(e);
                }
            } else {
                setBigInteger(key);
            }
        }
    }

    /**
     * Sets the field value to a raw value.
     * @param value a new field value
     */
    public void setRawValue(Object value) {
        this.value = value;
    }

    /**
     * Checks whether field value is set.
     * @return true if field value is set, false otherwise
     */
    public boolean isValueSet() {
        return value != null;
    }

    @Override
    public String toString() {
        return getString();
    }

    private double getMultiplier() {
        double multiplier = 1;
        if (field.getDecimalExponent() != null) {
            multiplier = Math.pow(10, field.getDecimalExponent());
        }
        if (field.getBinaryExponent() != null) {
            multiplier *= Math.pow(2, field.getBinaryExponent());
        }
        if (field.getMultiplier() != null && field.getMultiplier() != 0) {
            multiplier *= (double) field.getMultiplier();
        }
        return multiplier;
    }

    /**
     * Reads offset-to-be-added to field value received from request.
     * This is an extension to official GATT characteristic field specification, 
     * allowing to implement subset of proprietary devices that almost follow standard
     * GATT specifications.
     * @return offset as double if set, 0 if not present
     */
    private double getOffset() {
        return (field.getOffset() != null) ? field.getOffset() : 0;
    }

    private AbstractConverter getConverter() {
        FieldFormat fieldFormat = field.getFormat();
        int size = fieldFormat.getSize();
        switch (fieldFormat.getType()) {
            case BOOLEAN: return new BooleanConverter();
            case UINT:
                if (size < 32) {
                    return new IntegerConverter();
                } else if (size < 64) {
                    return new LongConverter();
                } else {
                    return new BigIntegerConverter();
                }
            case SINT:
                if (size <= 32) {
                    return new IntegerConverter();
                } else if (size <= 64) {
                    return new LongConverter();
                } else {
                    return new BigIntegerConverter();
                }
            case FLOAT_IEE754:
            case FLOAT_IEE11073: return size <= 32 ? new FloatConverter() : new DoubleConverter();
            case UTF8S:
            case UTF16S: return new StringConverter();
            default:
                throw new IllegalStateException("Unsupported field format: " + fieldFormat.getType());
        }
    }

    private Object prepareValue() {
        if (field.getFormat().isStruct() && value instanceof byte[]) {
            byte[] data = (byte[]) value;
            return new TwosComplementNumberFormatter().deserializeBigInteger(BitSet.valueOf(data),
                    data.length * 8, false);
        } else {
            return value;
        }
    }

}