/****************************************************************
 * Licensed to the Apache Software Foundation (ASF) under one   *
 * or more contributor license agreements.  See the NOTICE file *
 * distributed with this work for additional information        *
 * regarding copyright ownership.  The ASF licenses this file   *
 * to you 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.apache.james.mime4j.codec;

import java.io.IOException;
import java.io.InputStream;
import java.lang.ref.SoftReference;

import org.apache.james.mime4j.util.BufferRecycler;
import org.apache.james.mime4j.util.ByteArrayBuffer;
import org.apache.james.mime4j.util.RecycledByteArrayBuffer;

/**
 * Performs Quoted-Printable decoding on an underlying stream.
 */
public class QuotedPrintableInputStream extends InputStream {
    protected static final ThreadLocal<SoftReference<BufferRecycler>> _recyclerRef = new ThreadLocal<>();

    public static BufferRecycler getBufferRecycler() {
        SoftReference<BufferRecycler> ref = _recyclerRef.get();
        BufferRecycler br = (ref == null) ? null : ref.get();

        if (br == null) {
            br = new BufferRecycler();
            ref = new SoftReference<>(br);
            _recyclerRef.set(ref);
        }
        return br;
    }

    private static final int DEFAULT_BUFFER_SIZE = 1024 * 2;

    private static final byte EQ = 0x3D;
    private static final byte CR = 0x0D;
    private static final byte LF = 0x0A;

    private final byte[] singleByte = new byte[1];

    private final InputStream in;
    private final RecycledByteArrayBuffer decodedBuf;
    private final RecycledByteArrayBuffer blanks;

    private final byte[] encoded;
    private int pos = 0; // current index into encoded buffer
    private int limit = 0; // current size of encoded buffer

    private boolean lastWasCR = false;
    private boolean closed;

    private final DecodeMonitor monitor;

    public QuotedPrintableInputStream(final InputStream in, DecodeMonitor monitor) {
        this(DEFAULT_BUFFER_SIZE, in, monitor);
    }

    protected QuotedPrintableInputStream(final int bufsize, final InputStream in, DecodeMonitor monitor) {
        super();
        this.in = in;
        BufferRecycler recycler = getBufferRecycler();
        this.encoded = recycler.allocByteBuffer(1, bufsize);
        this.decodedBuf = new RecycledByteArrayBuffer(recycler, 512);
        this.blanks = new RecycledByteArrayBuffer(recycler, 512);
        this.closed = false;
        this.monitor = monitor;
    }

    protected QuotedPrintableInputStream(final int bufsize, final InputStream in, boolean strict) {
        this(bufsize, in, strict ? DecodeMonitor.STRICT : DecodeMonitor.SILENT);
    }

    public QuotedPrintableInputStream(final InputStream in, boolean strict) {
        this(DEFAULT_BUFFER_SIZE, in, strict);
    }

    public QuotedPrintableInputStream(final InputStream in) {
        this(in, false);
    }

    /**
     * Terminates Quoted-Printable coded content. This method does NOT close
     * the underlying input stream.
     */
    @Override
    public void close() {
        closed = true;
        getBufferRecycler().releaseByteBuffer(1, encoded);
        decodedBuf.release();
        blanks.release();
    }

    private int fillBuffer() throws IOException {
        // Compact buffer if needed
        if (pos < limit) {
            System.arraycopy(encoded, pos, encoded, 0, limit - pos);
            limit -= pos;
            pos = 0;
        } else {
            limit = 0;
            pos = 0;
        }

        int capacity = encoded.length - limit;
        if (capacity > 0) {
            int bytesRead = in.read(encoded, limit, capacity);
            if (bytesRead > 0) {
                limit += bytesRead;
            }
            return bytesRead;
        } else {
            return 0;
        }
    }

    private int getnext() {
        if (pos < limit) {
            byte b =  encoded[pos];
            pos++;
            return b & 0xFF;
        } else {
            return -1;
        }
    }

    private int peek(int i) {
        if (pos + i < limit) {
            return encoded[pos + i] & 0xFF;
        } else {
            return -1;
        }
    }

    private int transfer(
            final int b, final byte[] buffer, final int from, final int to, boolean keepblanks) throws IOException {
        int index = from;
        if (keepblanks && blanks.length() > 0) {
            int chunk = Math.min(blanks.length(), to - index);
            System.arraycopy(blanks.buffer(), 0, buffer, index, chunk);
            index += chunk;
            int remaining = blanks.length() - chunk;
            if (remaining > 0) {
                decodedBuf.append(blanks.buffer(), chunk, remaining);
            }
            blanks.clear();
        } else if (blanks.length() > 0 && !keepblanks) {
            StringBuilder sb = new StringBuilder(blanks.length() * 3);
            for (int i = 0; i < blanks.length(); i++) sb.append(" ").append(blanks.byteAt(i));
            if (monitor.warn("ignored blanks", sb.toString()))
                throw new IOException("ignored blanks");
        }
        if (b != -1) {
            if (index < to) {
                buffer[index++] = (byte) b;
            } else {
                decodedBuf.append(b);
            }
        }
        return index;
    }

    private int read0(final byte[] buffer, final int off, final int len) throws IOException {
        boolean eof = false;
        int to = off + len;
        int index = off;

        // check if a previous invocation left decoded content
        if (decodedBuf.length() > 0) {
            int chunk = Math.min(decodedBuf.length(), to - index);
            System.arraycopy(decodedBuf.buffer(), 0, buffer, index, chunk);
            decodedBuf.remove(0, chunk);
            index += chunk;
        }

        while (index < to) {

            if (limit - pos < 3) {
                int bytesRead = fillBuffer();
                eof = bytesRead == -1;
            }

            // end of stream?
            if (limit - pos == 0 && eof) {
                return index == off ? -1 : index - off;
            }

            while (pos < limit && index < to) {
                int b = encoded[pos++] & 0xFF;

                if (lastWasCR && b != LF) {
                    if (monitor.warn("Found CR without LF", "Leaving it as is")) {
                        throw new IOException("Found CR without LF");
                    }
                    index = transfer(CR, buffer, index, to, false);
                } else if (!lastWasCR && b == LF) {
                    if (monitor.warn("Found LF without CR", "Translating to CRLF")) {
                        throw new IOException("Found LF without CR");
                    }
                }

                if (b == CR) {
                    lastWasCR = true;
                    continue;
                } else {
                    lastWasCR = false;
                }

                if (b == LF) {
                    // at end of line
                    if (blanks.length() == 0) {
                        index = transfer(CR, buffer, index, to, false);
                        index = transfer(LF, buffer, index, to, false);
                    } else {
                        if (blanks.byteAt(0) != EQ) {
                            // hard line break
                            index = transfer(CR, buffer, index, to, false);
                            index = transfer(LF, buffer, index, to, false);
                        }
                    }
                    blanks.clear();
                } else if (b == EQ) {
                    if (limit - pos < 2 && !eof) {
                        // not enough buffered data
                        pos--;
                        break;
                    }

                    // found special char '='
                    int b2 = getnext();
                    if (b2 == EQ) {
                        index = transfer(b2, buffer, index, to, true);
                        // deal with '==\r\n' brokenness
                        int bb1 = peek(0);
                        int bb2 = peek(1);
                        if (bb1 == LF || (bb1 == CR && bb2 == LF)) {
                            monitor.warn("Unexpected ==EOL encountered", "== 0x"+bb1+" 0x"+bb2);
                            blanks.append(b2);
                        } else {
                            monitor.warn("Unexpected == encountered", "==");
                        }
                    } else if (Character.isWhitespace((char) b2)) {
                        // soft line break
                        int b3 = peek(0);
                        if (!(b2 == CR && b3 == LF)) {
                            if (monitor.warn("Found non-standard soft line break", "Translating to soft line break")) {
                                throw new IOException("Non-standard soft line break");
                            }
                        }
                        if (b3 == LF) {
                            lastWasCR = b2 == CR;
                        }
                        index = transfer(-1, buffer, index, to, true);
                        if (b2 != LF) {
                            blanks.append(b);
                            blanks.append(b2);
                        }
                    } else {
                        int b3 = getnext();
                        int upper = convert(b2);
                        int lower = convert(b3);
                        if (upper < 0 || lower < 0) {
                            monitor.warn("Malformed encoded value encountered", "leaving "+((char) EQ)+((char) b2)+((char) b3)+" as is");
                            // TODO see MIME4J-160
                            index = transfer(EQ, buffer, index, to, true);
                            index = transfer(b2, buffer, index, to, false);
                            index = transfer(b3, buffer, index, to, false);
                        } else {
                            index = transfer((upper << 4) | lower, buffer, index, to, true);
                        }
                    }
                } else if (Character.isWhitespace(b)) {
                    blanks.append(b);
                } else {
                    index = transfer(b & 0xFF, buffer, index, to, true);
                }
            }
        }
        return to - off;
    }

    /**
     * Converts '0' => 0, 'A' => 10, etc.
     * @param c ASCII character value.
     * @return Numeric value of hexadecimal character.
     */
    private int convert(int c) {
        if (c >= '0' && c <= '9') {
            return (c - '0');
        } else if (c >= 'A' && c <= 'F') {
            return (0xA + (c - 'A'));
        } else if (c >= 'a' && c <= 'f') {
            return (0xA + (c - 'a'));
        } else {
            return -1;
        }
    }

    @Override
    public int read() throws IOException {
        if (closed) {
            throw new IOException("Stream has been closed");
        }
        for (;;) {
            int bytes = read(singleByte, 0, 1);
            if (bytes == -1) {
                return -1;
            }
            if (bytes == 1) {
                return singleByte[0] & 0xff;
            }
        }
    }

    @Override
    public int read(byte[] b, int off, int len) throws IOException {
        if (closed) {
            throw new IOException("Stream has been closed");
        }
        return read0(b, off, len);
    }

}
