/*
 * The MIT License (MIT)
 *
 * Copyright (c) 2014-2019 Yegor Bugayenko
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included
 * in all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */
package org.takes.rq;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
import org.cactoos.Text;
import org.cactoos.scalar.Ternary;
import org.cactoos.scalar.Unchecked;
import org.cactoos.text.Sub;
import org.cactoos.text.TextOf;
import org.cactoos.text.Trimmed;
import org.cactoos.text.UncheckedText;

/**
 * Input stream from chunked coded http request body.
 *
 * @since 0.31.2
 * @checkstyle LineLengthCheck (1 lines)
 * @checkstyle ClassDataAbstractionCouplingCheck (500 lines)
 * @link <a href="https://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.6.1">Chunked Transfer Coding</a>
 */
final class ChunkedInputStream extends InputStream {

    /**
     * The inputstream that we're wrapping.
     */
    private final InputStream origin;

    /**
     * The chunk size.
     */
    private int size;

    /**
     * The current position within the current chunk.
     */
    private int pos;

    /**
     * True if we'are at the beginning of stream.
     */
    private boolean bof;

    /**
     * True if we've reached the end of stream.
     */
    private boolean eof;

    /**
     * Ctor.
     * @param stream The raw input stream
     */
    ChunkedInputStream(final InputStream stream) {
        super();
        this.bof = true;
        this.origin = stream;
    }

    @Override
    public int read() throws IOException {
        if (!this.eof && this.pos >= this.size) {
            this.nextChunk();
        }
        final int result;
        if (this.eof) {
            result = -1;
        } else {
            ++this.pos;
            result = this.origin.read();
        }
        return result;
    }

    @Override
    public int read(final byte[] buf, final int off, final int len)
        throws IOException {
        if (!this.eof && this.pos >= this.size) {
            this.nextChunk();
        }
        final int result;
        if (this.eof) {
            result = -1;
        } else {
            final int shift = Math.min(len, this.size - this.pos);
            final int count = this.origin.read(buf, off, shift);
            this.pos += count;
            if (shift == len) {
                result = len;
            } else {
                result = shift + this.read(buf, off + shift, len - shift);
            }
        }
        return result;
    }

    @Override
    public int read(final byte[] buf) throws IOException {
        return this.read(buf, 0, buf.length);
    }

    /**
     * Read the CRLF terminator.
     * @throws IOException If an IO error occurs.
     */
    private void readCrlf() throws IOException {
        final int crsymbol = this.origin.read();
        final int lfsymbol = this.origin.read();
        if (crsymbol != '\r' || lfsymbol != '\n') {
            throw new IOException(
                String.format(
                    "%s %d%s%d",
                    "CRLF expected at end of chunk: ",
                    crsymbol,
                    "/",
                    lfsymbol
                )
            );
        }
    }

    /**
     * Read the next chunk.
     * @throws IOException If an IO error occurs.
     */
    private void nextChunk() throws IOException {
        if (!this.bof) {
            this.readCrlf();
        }
        this.size = ChunkedInputStream.chunkSize(this.origin);
        this.bof = false;
        this.pos = 0;
        if (this.size == 0) {
            this.eof = true;
        }
    }

    /**
     * Expects the stream to start with a chunksize in hex with optional
     * comments after a semicolon. The line must end with a CRLF: "a3; some
     * comment\r\n" Positions the stream at the start of the next line.
     * @param stream The new input stream.
     * @return The chunk size as integer
     * @throws IOException when the chunk size could not be parsed
     */
    private static int chunkSize(final InputStream stream)
        throws IOException {
        final ByteArrayOutputStream baos = ChunkedInputStream.sizeLine(stream);
        final String data = baos.toString(Charset.defaultCharset().name());
        final int separator = data.indexOf(';');
        final Text number = new Trimmed(
            new Unchecked<>(
                new Ternary<>(
                    separator > 0,
                    new Sub(data, 0, separator),
                    new TextOf(data)
                )
            ).value()
        );
        try {
            // @checkstyle MagicNumberCheck (10 lines)
            return Integer.parseInt(
                new UncheckedText(
                    number
                ).asString(),
                16
            );
        } catch (final NumberFormatException ex) {
            throw new IOException(
                String.format(
                    "Bad chunk size: %s",
                    baos.toString(Charset.defaultCharset().name())
                ),
                ex
            );
        }
    }

    /**
     * Possible states of FSM that used to find chunk size.
     */
    private enum State {
        /**
         * Normal.
         */
        NORMAL,
        /**
         * If \r was scanned.
         */
        R,
        /**
         * Inside quoted string.
         */
        QUOTED_STRING,
        /**
         * End.
         */
        END;
    }

    /**
     * Extract line with chunk size from stream.
     * @param stream Input stream.
     * @return Line with chunk size.
     * @throws IOException If fails.
     */
    private static ByteArrayOutputStream sizeLine(final InputStream stream)
        throws IOException {
        State state = State.NORMAL;
        final ByteArrayOutputStream result = new ByteArrayOutputStream();
        while (state != State.END) {
            state = next(stream, state, result);
        }
        return result;
    }

    /**
     * Get next state for FSM.
     * @param stream Input stream.
     * @param state Current state.
     * @param line Current chunk size line.
     * @return New state.
     * @throws IOException If fails.
     */
    private static State next(final InputStream stream, final State state,
        final ByteArrayOutputStream line) throws IOException {
        final int next = stream.read();
        if (next == -1) {
            throw new IOException("chunked stream ended unexpectedly");
        }
        final State result;
        switch (state) {
            case NORMAL:
                result = nextNormal(state, line, next);
                break;
            case R:
                if (next == '\n') {
                    result = State.END;
                } else {
                    throw new IOException(
                        String.format(
                            "%s%s",
                            "Protocol violation: Unexpected",
                            " single newline character in chunk size"
                        )
                    );
                }
                break;
            case QUOTED_STRING:
                result = nextQuoted(stream, state, line, next);
                break;
            default:
                throw new IllegalStateException("Bad state");
        }
        return result;
    }

    /**
     * Maintain next symbol for current state = State.NORMAL.
     * @param state Current state.
     * @param line Current chunk size line.
     * @param next Next symbol.
     * @return New state.
     */
    private static State nextNormal(final State state,
        final ByteArrayOutputStream line, final int next) {
        final State result;
        switch (next) {
            case '\r':
                result = State.R;
                break;
            case '\"':
                result = State.QUOTED_STRING;
                break;
            default:
                result = state;
                line.write(next);
                break;
        }
        return result;
    }

    /**
     * Maintain next symbol for current state = State.QUOTED_STRING.
     * @param stream Input stream.
     * @param state Current state.
     * @param line Current chunk size line.
     * @param next Next symbol.
     * @return New state.
     * @throws IOException If fails.
     * @checkstyle ParameterNumberCheck (3 lines)
     */
    private static State nextQuoted(final InputStream stream, final State state,
        final ByteArrayOutputStream line, final int next)
        throws IOException {
        final State result;
        switch (next) {
            case '\\':
                result = state;
                line.write(stream.read());
                break;
            case '\"':
                result = State.NORMAL;
                break;
            default:
                result = state;
                line.write(next);
                break;
        }
        return result;
    }
}