package co.nstant.in.cbor;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.LinkedList;
import java.util.List;
import java.util.Objects;

import co.nstant.in.cbor.decoder.ArrayDecoder;
import co.nstant.in.cbor.decoder.ByteStringDecoder;
import co.nstant.in.cbor.decoder.MapDecoder;
import co.nstant.in.cbor.decoder.NegativeIntegerDecoder;
import co.nstant.in.cbor.decoder.SpecialDecoder;
import co.nstant.in.cbor.decoder.TagDecoder;
import co.nstant.in.cbor.decoder.UnicodeStringDecoder;
import co.nstant.in.cbor.decoder.UnsignedIntegerDecoder;
import co.nstant.in.cbor.model.Array;
import co.nstant.in.cbor.model.DataItem;
import co.nstant.in.cbor.model.LanguageTaggedString;
import co.nstant.in.cbor.model.MajorType;
import co.nstant.in.cbor.model.Number;
import co.nstant.in.cbor.model.RationalNumber;
import co.nstant.in.cbor.model.Tag;
import co.nstant.in.cbor.model.UnicodeString;

/**
 * Decoder for the CBOR format based.
 */
public class CborDecoder {

    private final InputStream inputStream;
    private final UnsignedIntegerDecoder unsignedIntegerDecoder;
    private final NegativeIntegerDecoder negativeIntegerDecoder;
    private final ByteStringDecoder byteStringDecoder;
    private final UnicodeStringDecoder unicodeStringDecoder;
    private final ArrayDecoder arrayDecoder;
    private final MapDecoder mapDecoder;
    private final TagDecoder tagDecoder;
    private final SpecialDecoder specialDecoder;

    private boolean autoDecodeInfinitiveArrays = true;
    private boolean autoDecodeInfinitiveMaps = true;
    private boolean autoDecodeInfinitiveByteStrings = true;
    private boolean autoDecodeInfinitiveUnicodeStrings = true;
    private boolean autoDecodeRationalNumbers = true;
    private boolean autoDecodeLanguageTaggedStrings = true;
    private boolean rejectDuplicateKeys = false;

    /**
     * Initialize a new decoder which reads the binary encoded data from an
     * {@link OutputStream}.
     * 
     * @param inputStream the {@link OutputStream} to read the data from
     */
    public CborDecoder(InputStream inputStream) {
        Objects.requireNonNull(inputStream);
        this.inputStream = inputStream;
        unsignedIntegerDecoder = new UnsignedIntegerDecoder(this, inputStream);
        negativeIntegerDecoder = new NegativeIntegerDecoder(this, inputStream);
        byteStringDecoder = new ByteStringDecoder(this, inputStream);
        unicodeStringDecoder = new UnicodeStringDecoder(this, inputStream);
        arrayDecoder = new ArrayDecoder(this, inputStream);
        mapDecoder = new MapDecoder(this, inputStream);
        tagDecoder = new TagDecoder(this, inputStream);
        specialDecoder = new SpecialDecoder(this, inputStream);
    }

    /**
     * Convenience method to decode a byte array directly.
     *
     * @param bytes the CBOR encoded data
     * @return a list of {@link DataItem}s
     * @throws CborException if decoding failed
     */
    public static List<DataItem> decode(byte[] bytes) throws CborException {
        return new CborDecoder(new ByteArrayInputStream(bytes)).decode();
    }

    /**
     * Decode the {@link InputStream} to a list of {@link DataItem}s.
     *
     * @return the list of {@link DataItem}s
     * @throws CborException if decoding failed
     */
    public List<DataItem> decode() throws CborException {
        List<DataItem> dataItems = new LinkedList<>();
        DataItem dataItem;
        while ((dataItem = decodeNext()) != null) {
            dataItems.add(dataItem);
        }
        return dataItems;
    }

    /**
     * Streaming decoding of an input stream. On each decoded DataItem, the callback
     * listener is invoked.
     *
     * @param dataItemListener the callback listener
     * @throws CborException if decoding failed
     */
    public void decode(DataItemListener dataItemListener) throws CborException {
        Objects.requireNonNull(dataItemListener);
        DataItem dataItem = decodeNext();
        while (dataItem != null) {
            dataItemListener.onDataItem(dataItem);
            dataItem = decodeNext();
        }
    }

    /**
     * Decodes exactly one DataItem from the input stream.
     *
     * @return a {@link DataItem} or null if end of stream has reached.
     * @throws CborException if decoding failed
     */
    public DataItem decodeNext() throws CborException {
        int symbol;
        try {
            symbol = inputStream.read();
        } catch (IOException ioException) {
            throw new CborException(ioException);
        }
        if (symbol == -1) {
            return null;
        }
        switch (MajorType.ofByte(symbol)) {
        case ARRAY:
            return arrayDecoder.decode(symbol);
        case BYTE_STRING:
            return byteStringDecoder.decode(symbol);
        case MAP:
            return mapDecoder.decode(symbol);
        case NEGATIVE_INTEGER:
            return negativeIntegerDecoder.decode(symbol);
        case UNICODE_STRING:
            return unicodeStringDecoder.decode(symbol);
        case UNSIGNED_INTEGER:
            return unsignedIntegerDecoder.decode(symbol);
        case SPECIAL:
            return specialDecoder.decode(symbol);
        case TAG:
            Tag tag = tagDecoder.decode(symbol);
            DataItem next = decodeNext();
            if (next == null) {
                throw new CborException("Unexpected end of stream: tag without following data item.");
            } else {
                if (autoDecodeRationalNumbers && tag.getValue() == 30) {
                    return decodeRationalNumber(next);
                } else if (autoDecodeLanguageTaggedStrings && tag.getValue() == 38) {
                    return decodeLanguageTaggedString(next);
                } else {
                    DataItem itemToTag = next;
                    while (itemToTag.hasTag())
                        itemToTag = itemToTag.getTag();
                    itemToTag.setTag(tag);
                    return next;
                }
            }
        case INVALID:
        default:
            throw new CborException("Not implemented major type " + symbol);
        }
    }

    private DataItem decodeLanguageTaggedString(DataItem dataItem) throws CborException {
        if (!(dataItem instanceof Array)) {
            throw new CborException("Error decoding LanguageTaggedString: not an array");
        }

        Array array = (Array) dataItem;

        if (array.getDataItems().size() != 2) {
            throw new CborException("Error decoding LanguageTaggedString: array size is not 2");
        }

        DataItem languageDataItem = array.getDataItems().get(0);

        if (!(languageDataItem instanceof UnicodeString)) {
            throw new CborException("Error decoding LanguageTaggedString: first data item is not an UnicodeString");
        }

        DataItem stringDataItem = array.getDataItems().get(1);

        if (!(stringDataItem instanceof UnicodeString)) {
            throw new CborException("Error decoding LanguageTaggedString: second data item is not an UnicodeString");
        }

        UnicodeString language = (UnicodeString) languageDataItem;
        UnicodeString string = (UnicodeString) stringDataItem;

        return new LanguageTaggedString(language, string);
    }

    private DataItem decodeRationalNumber(DataItem dataItem) throws CborException {
        if (!(dataItem instanceof Array)) {
            throw new CborException("Error decoding RationalNumber: not an array");
        }

        Array array = (Array) dataItem;

        if (array.getDataItems().size() != 2) {
            throw new CborException("Error decoding RationalNumber: array size is not 2");
        }

        DataItem numeratorDataItem = array.getDataItems().get(0);

        if (!(numeratorDataItem instanceof Number)) {
            throw new CborException("Error decoding RationalNumber: first data item is not a number");
        }

        DataItem denominatorDataItem = array.getDataItems().get(1);

        if (!(denominatorDataItem instanceof Number)) {
            throw new CborException("Error decoding RationalNumber: second data item is not a number");
        }

        Number numerator = (Number) numeratorDataItem;
        Number denominator = (Number) denominatorDataItem;

        return new RationalNumber(numerator, denominator);
    }

    public boolean isAutoDecodeInfinitiveArrays() {
        return autoDecodeInfinitiveArrays;
    }

    public void setAutoDecodeInfinitiveArrays(boolean autoDecodeInfinitiveArrays) {
        this.autoDecodeInfinitiveArrays = autoDecodeInfinitiveArrays;
    }

    public boolean isAutoDecodeInfinitiveMaps() {
        return autoDecodeInfinitiveMaps;
    }

    public void setAutoDecodeInfinitiveMaps(boolean autoDecodeInfinitiveMaps) {
        this.autoDecodeInfinitiveMaps = autoDecodeInfinitiveMaps;
    }

    public boolean isAutoDecodeInfinitiveByteStrings() {
        return autoDecodeInfinitiveByteStrings;
    }

    public void setAutoDecodeInfinitiveByteStrings(boolean autoDecodeInfinitiveByteStrings) {
        this.autoDecodeInfinitiveByteStrings = autoDecodeInfinitiveByteStrings;
    }

    public boolean isAutoDecodeInfinitiveUnicodeStrings() {
        return autoDecodeInfinitiveUnicodeStrings;
    }

    public void setAutoDecodeInfinitiveUnicodeStrings(boolean autoDecodeInfinitiveUnicodeStrings) {
        this.autoDecodeInfinitiveUnicodeStrings = autoDecodeInfinitiveUnicodeStrings;
    }

    public boolean isAutoDecodeRationalNumbers() {
        return autoDecodeRationalNumbers;
    }

    public void setAutoDecodeRationalNumbers(boolean autoDecodeRationalNumbers) {
        this.autoDecodeRationalNumbers = autoDecodeRationalNumbers;
    }

    public boolean isAutoDecodeLanguageTaggedStrings() {
        return autoDecodeLanguageTaggedStrings;
    }

    public void setAutoDecodeLanguageTaggedStrings(boolean autoDecodeLanguageTaggedStrings) {
        this.autoDecodeLanguageTaggedStrings = autoDecodeLanguageTaggedStrings;
    }

    public boolean isRejectDuplicateKeys() {
        return rejectDuplicateKeys;
    }

    public void setRejectDuplicateKeys(boolean rejectDuplicateKeys) {
        this.rejectDuplicateKeys = rejectDuplicateKeys;
    }

    /**
     * Sets the given amount of bytes as maximum preallocation limit for arrays in
     * all decoders. This prevents OutOfMemory exceptions on malicious CBOR with
     * forged fixed length items. Note that items may exceed the given size when the
     * decoded data actually contains much data. This may be limited by using a
     * limiting stream.
     *
     * @param maxSize Maximum number of bytes to preallocate in array-based items.
     *                Set to 0 to disable.
     */
    public void setMaxPreallocationSize(int maxSize) {
        unsignedIntegerDecoder.setMaxPreallocationSize(maxSize);
        negativeIntegerDecoder.setMaxPreallocationSize(maxSize);
        byteStringDecoder.setMaxPreallocationSize(maxSize);
        unicodeStringDecoder.setMaxPreallocationSize(maxSize);
        arrayDecoder.setMaxPreallocationSize(maxSize);
        mapDecoder.setMaxPreallocationSize(maxSize);
        tagDecoder.setMaxPreallocationSize(maxSize);
        specialDecoder.setMaxPreallocationSize(maxSize);
    }

}