/*
 * Copyright 2002-2016 the original author or authors.
 *
 * 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.
 */

package org.springframework.http.codec.json;

import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.function.Function;

import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufUtil;
import io.netty.buffer.Unpooled;
import org.reactivestreams.Publisher;
import reactor.core.publisher.Flux;

import org.springframework.core.ResolvableType;
import org.springframework.core.codec.AbstractDecoder;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferFactory;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.util.MimeType;

/**
 * Decode an arbitrary split byte stream representing JSON objects to a byte
 * stream where each chunk is a well-formed JSON object.
 *
 * <p>This class does not do any real parsing or validation. A sequence of bytes
 * is considered a JSON object/array if it contains a matching number of opening
 * and closing braces/brackets.
 *
 * <p>Based on <a href="https://github.com/netty/netty/blob/master/codec/src/main/java/io/netty/handler/codec/json/JsonObjectDecoder.java">Netty JsonObjectDecoder</a>
 *
 * @author Sebastien Deleuze
 * @since 5.0
 */
class JsonObjectDecoder extends AbstractDecoder<DataBuffer> {

	private static final int ST_CORRUPTED = -1;

	private static final int ST_INIT = 0;

	private static final int ST_DECODING_NORMAL = 1;

	private static final int ST_DECODING_ARRAY_STREAM = 2;

	private final int maxObjectLength;

	private final boolean streamArrayElements;

	public JsonObjectDecoder() {
		// 1 MB
		this(1024 * 1024);
	}

	public JsonObjectDecoder(int maxObjectLength) {
		this(maxObjectLength, true);
	}

	public JsonObjectDecoder(boolean streamArrayElements) {
		this(1024 * 1024, streamArrayElements);
	}


	/**
	 * @param maxObjectLength maximum number of bytes a JSON object/array may
	 * use (including braces and all). Objects exceeding this length are dropped
	 * and an {@link IllegalStateException} is thrown.
	 * @param streamArrayElements if set to true and the "top level" JSON object
	 * is an array, each of its entries is passed through the pipeline individually
	 * and immediately after it was fully received, allowing for arrays with
	 */
	public JsonObjectDecoder(int maxObjectLength,
			boolean streamArrayElements) {
		super(new MimeType("application", "json", StandardCharsets.UTF_8),
				new MimeType("application", "*+json", StandardCharsets.UTF_8));
		if (maxObjectLength < 1) {
			throw new IllegalArgumentException("maxObjectLength must be a positive int");
		}
		this.maxObjectLength = maxObjectLength;
		this.streamArrayElements = streamArrayElements;
	}

	@Override
	public Flux<DataBuffer> decode(Publisher<DataBuffer> inputStream, ResolvableType elementType,
			MimeType mimeType, Map<String, Object> hints) {

		return Flux.from(inputStream)
				.flatMap(new Function<DataBuffer, Publisher<? extends DataBuffer>>() {

			int openBraces;
			int index;
			int state;
			boolean insideString;
			ByteBuf input;
			Integer writerIndex;

			@Override
			public Publisher<? extends DataBuffer> apply(DataBuffer buffer) {
				List<DataBuffer> chunks = new ArrayList<>();
				if (this.input == null) {
					this.input = Unpooled.copiedBuffer(buffer.asByteBuffer());
					DataBufferUtils.release(buffer);
					this.writerIndex = this.input.writerIndex();
				}
				else {
					this.index = this.index - this.input.readerIndex();
					this.input = Unpooled.copiedBuffer(this.input,
							Unpooled.copiedBuffer(buffer.asByteBuffer()));
					DataBufferUtils.release(buffer);
					this.writerIndex = this.input.writerIndex();
				}
				if (this.state == ST_CORRUPTED) {
					this.input.skipBytes(this.input.readableBytes());
					return Flux.error(new IllegalStateException("Corrupted stream"));
				}
				if (this.writerIndex > maxObjectLength) {
					// buffer size exceeded maxObjectLength; discarding the complete buffer.
					this.input.skipBytes(this.input.readableBytes());
					reset();
					return Flux.error(new IllegalStateException("object length exceeds " +
							maxObjectLength + ": " + this.writerIndex + " bytes discarded"));
				}
				DataBufferFactory dataBufferFactory = buffer.factory();
				for (/* use current index */; this.index < this.writerIndex; this.index++) {
					byte c = this.input.getByte(this.index);
					if (this.state == ST_DECODING_NORMAL) {
						decodeByte(c, this.input, this.index);

						// All opening braces/brackets have been closed. That's enough to conclude
						// that the JSON object/array is complete.
						if (this.openBraces == 0) {
							ByteBuf json = extractObject(this.input, this.input.readerIndex(),
									this.index + 1 - this.input.readerIndex());
							if (json != null) {
								chunks.add(dataBufferFactory.wrap(json.nioBuffer()));
							}

							// The JSON object/array was extracted => discard the bytes from
							// the input buffer.
							this.input.readerIndex(this.index + 1);
							// Reset the object state to get ready for the next JSON object/text
							// coming along the byte stream.
							reset();
						}
					}
					else if (this.state == ST_DECODING_ARRAY_STREAM) {
						decodeByte(c, this.input, this.index);

						if (!this.insideString && (this.openBraces == 1 && c == ',' ||
								this.openBraces == 0 && c == ']')) {
							// skip leading spaces. No range check is needed and the loop will terminate
							// because the byte at position index is not a whitespace.
							for (int i = this.input.readerIndex(); Character.isWhitespace(this.input.getByte(i)); i++) {
								this.input.skipBytes(1);
							}

							// skip trailing spaces.
							int idxNoSpaces = this.index - 1;
							while (idxNoSpaces >= this.input.readerIndex() &&
									Character.isWhitespace(this.input.getByte(idxNoSpaces))) {

								idxNoSpaces--;
							}

							ByteBuf json = extractObject(this.input, this.input.readerIndex(),
									idxNoSpaces + 1 - this.input.readerIndex());

							if (json != null) {
								chunks.add(dataBufferFactory.wrap(json.nioBuffer()));
							}

							this.input.readerIndex(this.index + 1);

							if (c == ']') {
								reset();
							}
						}
						// JSON object/array detected. Accumulate bytes until all braces/brackets are closed.
					}
					else if (c == '{' || c == '[') {
						initDecoding(c, streamArrayElements);

						if (this.state == ST_DECODING_ARRAY_STREAM) {
							// Discard the array bracket
							this.input.skipBytes(1);
						}
						// Discard leading spaces in front of a JSON object/array.
					}
					else if (Character.isWhitespace(c)) {
						this.input.skipBytes(1);
					}
					else {
						this.state = ST_CORRUPTED;
						return Flux.error(new IllegalStateException(
								"invalid JSON received at byte position " + this.index + ": " +
										ByteBufUtil.hexDump(this.input)));
					}
				}

				return Flux.fromIterable(chunks);
			}

			/**
			 * Override this method if you want to filter the json objects/arrays that
			 * get passed through the pipeline.
			 */
			protected ByteBuf extractObject(ByteBuf buffer, int index, int length) {
				return buffer.slice(index, length).retain();
			}

			private void decodeByte(byte c, ByteBuf input, int index) {
				if ((c == '{' || c == '[') && !this.insideString) {
					this.openBraces++;
				}
				else if ((c == '}' || c == ']') && !this.insideString) {
					this.openBraces--;
				}
				else if (c == '"') {
					// start of a new JSON string. It's necessary to detect strings as they may
					// also contain braces/brackets and that could lead to incorrect results.
					if (!this.insideString) {
						this.insideString = true;
						// If the double quote wasn't escaped then this is the end of a string.
					}
					else if (input.getByte(index - 1) != '\\') {
						this.insideString = false;
					}
				}
			}

			private void initDecoding(byte openingBrace, boolean streamArrayElements) {
				this.openBraces = 1;
				if (openingBrace == '[' && streamArrayElements) {
					this.state = ST_DECODING_ARRAY_STREAM;
				}
				else {
					this.state = ST_DECODING_NORMAL;
				}
			}

			private void reset() {
				this.insideString = false;
				this.state = ST_INIT;
				this.openBraces = 0;
			}
		});
	}

}
