/*
 * 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.pdfbox.pdfparser;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.LogManager;
import org.apache.pdfbox.contentstream.PDContentStream;
import org.apache.pdfbox.contentstream.operator.Operator;
import org.apache.pdfbox.contentstream.operator.OperatorName;
import org.apache.pdfbox.cos.COSBase;
import org.apache.pdfbox.cos.COSBoolean;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.cos.COSNull;
import org.apache.pdfbox.cos.COSNumber;
import org.apache.pdfbox.io.RandomAccessReadBuffer;

/**
 * This will parse a PDF byte stream and extract operands and such.
 *
 * @author Ben Litchfield
 */
public class PDFStreamParser extends COSParser
{
    /**
     * Log instance.
     */
    private static final Logger LOG = LogManager.getLogger(PDFStreamParser.class);

    private static final int MAX_BIN_CHAR_TEST_LENGTH = 10;
    private final byte[] binCharTestArr = new byte[MAX_BIN_CHAR_TEST_LENGTH];
    private int inlineImageDepth = 0;
    private long inlineOffset = 0;
    
    /**
     * Constructor.
     *
     * @param pdContentstream The content stream to parse.
     * @throws IOException If there is an error initializing the stream.
     */
    public PDFStreamParser(PDContentStream pdContentstream) throws IOException
    {
        super(pdContentstream.getContentsForStreamParsing());
    }

    /**
     * Constructor.
     *
     * @param bytes the bytes to parse.
     * @throws IOException If there is an error initializing the stream.
     */
    public PDFStreamParser(byte[] bytes) throws IOException
    {
        super(new RandomAccessReadBuffer(bytes));
    }

    /**
     * This will parse all the tokens in the stream. This will close the stream when it is finished parsing.
     *
     * @return All of the tokens in the stream.
     * @throws IOException If there is an error while parsing the stream.
     */
    public List<Object> parse() throws IOException
    {
        List<Object> streamObjects = new ArrayList<>(100);
        Object token;
        while( (token = parseNextToken()) != null )
        {
            streamObjects.add( token );
        }
        return streamObjects;
    }

    /**
     * This will parse the next token in the stream.
     *
     * @return The next token in the stream or null if there are no more tokens in the stream.
     *
     * @throws IOException If an io error occurs while parsing the stream.
     */
    public Object parseNextToken() throws IOException
    {
        if (source.isClosed())
        {
            return null;
        }
        skipSpaces();
        if (source.isEOF())
        {
            close();
            return null;
        }
        char c = (char) source.peek();
        switch (c)
        {
            case '<':
                // pull off first left bracket
                source.read();
                // check for second left bracket
                c = (char) source.peek();

                if (c == '<')
                {
                    // put back first bracket
                    source.rewind(1);
                    try
                    {
                        return parseCOSDictionary(true);
                    }
                    catch (IOException exception)
                    {
                        LOG.warn("Stop reading invalid dictionary from content stream at offset {}",
                                source.getPosition());
                        close();
                        return null;
                    }
                }
                else
                {
                    return parseCOSHexString();
                }
            case '[':
                // array
                try
                {
                    return parseCOSArray();
                }
                catch (IOException exception)
                {
                    LOG.warn("Stop reading invalid array from content stream at offset {}",
                            source.getPosition());
                    close();
                    return null;
                }
            case '(':
                // string
                return parseCOSLiteralString();
            case '/':
                // name
                return parseCOSName();
            case 'n':   
                // null
                String nullString = readString();
                if( nullString.equals( "null") )
                {
                    return COSNull.NULL;
                }
                else
                {
                    return Operator.getOperator(nullString);
                }
            case 't':
            case 'f':
                String next = readString();
                if( next.equals( "true" ) )
                {
                    return COSBoolean.TRUE;
                }
                else if( next.equals( "false" ) )
                {
                    return COSBoolean.FALSE;
                }
                else
                {
                    return Operator.getOperator(next);
                }
            case '0':
            case '1':
            case '2':
            case '3':
            case '4':
            case '5':
            case '6':
            case '7':
            case '8':
            case '9':
            case '-':
            case '+':
            case '.':
                /* We will be filling buf with the rest of the number.  Only
                 * allow 1 "." and "-" and "+" at start of number. */
                StringBuilder buf = new StringBuilder();
                buf.append( c );
                source.read();
                
                // Ignore double negative (this is consistent with Adobe Reader)
                if (c == '-' && source.peek() == c)
                {
                    source.read();
                }

                boolean dotNotRead = c != '.';
                while (Character.isDigit(c = (char) source.peek()) || dotNotRead && c == '.'
                        || c == '-')
                {
                    if (c != '-')
                    {
                        // PDFBOX-4064: ignore "-" in the middle of a number
                        buf.append(c);
                    }
                    source.read();

                    if (dotNotRead && c == '.')
                    {
                        dotNotRead = false;
                    }
                }
                String s = buf.toString();
                if ("+".equals(s))
                {
                    // PDFBOX-5906
                    LOG.warn("isolated '+' is ignored");
                    return COSNull.NULL;
                }
                return COSNumber.get(s);
            case 'B':
                String nextOperator = readString();
                Operator beginImageOP = Operator.getOperator(nextOperator);
                if (nextOperator.equals(OperatorName.BEGIN_INLINE_IMAGE))
                {
                    inlineImageDepth++;
                    if (inlineImageDepth > 1)
                    {
                        // PDFBOX-6038
                        throw new IOException("Nested '" + OperatorName.BEGIN_INLINE_IMAGE +
                                "' operator not allowed at offset " + source.getPosition() + ", first: " + inlineOffset);
                    }
                    else
                    {
                        inlineOffset = source.getPosition();
                    }
                    COSDictionary imageParams = new COSDictionary();
                    beginImageOP.setImageParameters( imageParams );
                    Object nextToken = null;
                    while( (nextToken = parseNextToken()) instanceof COSName )
                    {
                        Object value = parseNextToken();
                        if (!(value instanceof COSBase))
                        {
                            LOG.warn("Unexpected token in inline image dictionary at offset {}",
                                    source.isClosed() ? "EOF" : source.getPosition());
                            break;
                        }
                        imageParams.setItem( (COSName)nextToken, (COSBase)value );
                    }
                    //final token will be the image data, maybe??
                    if (nextToken instanceof Operator)
                    {
                        Operator imageData = (Operator) nextToken;
                        if (imageData.getImageData() == null || imageData.getImageData().length == 0)
                        {
                            LOG.warn("empty inline image at stream offset {}",
                                    (source.isClosed() ? "EOF" : source.getPosition()));
                        }
                        beginImageOP.setImageData(imageData.getImageData());
                        inlineImageDepth--;
                    }
                    else
                    {
                        LOG.warn("nextToken {} at position {}, expected {}?!", 
                                nextToken, (source.isClosed() ? "EOF" : source.getPosition()), 
                                OperatorName.BEGIN_INLINE_IMAGE_DATA);
                    }
                }
                return beginImageOP;
            case 'I':
                //Special case for ID operator
                String id = Character.toString((char) source.read()) + (char) source.read();
                if (!id.equals(OperatorName.BEGIN_INLINE_IMAGE_DATA))
                {
                    long currentPosition = source.getPosition();
                    close();
                    throw new IOException( "Error: Expected operator 'ID' actual='" + id +
                            "' at stream offset " + currentPosition);
                }
                ByteArrayOutputStream imageData = new ByteArrayOutputStream();
                // skip one line break (CR, LF or CRLF) or any one-byte whitespace
                if (!skipLinebreak() && isWhitespace())
                {
                    // pull off the whitespace character
                    source.read();
                }
                int lastByte = source.read();
                int currentByte = source.read();
                // PDF spec is kinda unclear about this. Should a whitespace
                // always appear before EI? Not sure, so that we just read
                // until EI<whitespace>.
                // Be aware not all kind of whitespaces are allowed here. see PDFBOX-1561
                while( !(lastByte == 'E' &&
                         currentByte == 'I' &&
                         hasNextSpaceOrReturn() &&
                    hasNoFollowingBinData()) &&
                    !isEOF())
                {
                    imageData.write( lastByte );
                    lastByte = currentByte;
                    currentByte = source.read();
                }
                // the EI operator isn't unread, as it won't be processed anyway
                Operator beginImageDataOP = Operator
                        .getOperator(OperatorName.BEGIN_INLINE_IMAGE_DATA);
                // save the image data to the operator, so that it can be accessed later
                beginImageDataOP.setImageData(imageData.toByteArray());
                return beginImageDataOP;
            case ']':
                // some ']' around without its previous '['
                // this means a PDF is somewhat corrupt but we will continue to parse.
                source.read();
                
                // must be a better solution than null...
                return COSNull.NULL;
            default:
                // we must be an operator
                String operator = readOperator().trim();
                if (!operator.isEmpty())
                {
                    return Operator.getOperator(operator);
                }
        }
        return null;
    }

    /**
     * Looks up an amount of bytes if they contain only ASCII characters (no
     * control sequences etc.), and that these ASCII characters begin with a
     * sequence of 1-3 non-blank characters between blanks
     *
     * @return <code>true</code> if next bytes are probably printable ASCII
     * characters starting with a PDF operator, otherwise <code>false</code>
     */
    private boolean hasNoFollowingBinData() throws IOException
    {
        // as suggested in PDFBOX-1164
        final int readBytes = source.read(binCharTestArr, 0, MAX_BIN_CHAR_TEST_LENGTH);
        boolean noBinData = true;
        int startOpIdx = -1;
        int endOpIdx = -1;
        String s = "";

        LOG.debug("String after EI: '{}'", () -> new String(binCharTestArr, StandardCharsets.US_ASCII));

        if (readBytes > 0)
        {
            for (int bIdx = 0; bIdx < readBytes; bIdx++)
            {
                final byte b = binCharTestArr[bIdx];
                if (b != 0 && b < 0x09 || b > 0x0a && b < 0x20 && b != 0x0d)
                {
                    // control character or > 0x7f -> we have binary data
                    noBinData = false;
                    break;
                }
                // find the start of a PDF operator
                if (startOpIdx == -1 && !(b == 0 || b == 9 || b == 0x20 || b == 0x0a || b == 0x0d))
                {
                    startOpIdx = bIdx;
                }
                else if (startOpIdx != -1 && endOpIdx == -1 &&
                         (b == 0 || b == 9 || b == 0x20 || b == 0x0a || b == 0x0d))
                {
                    endOpIdx = bIdx;
                }
            }

            // PDFBOX-3742: just assuming that 1-3 non blanks is a PDF operator isn't enough
            if (noBinData && endOpIdx != -1 && startOpIdx != -1)
            {
                // usually, the operator here is Q, sometimes EMC (PDFBOX-2376), S (PDFBOX-3784),
                // or a number (PDFBOX-5957)
                s = new String(binCharTestArr, startOpIdx, endOpIdx - startOpIdx, StandardCharsets.US_ASCII);
                if (!"Q".equals(s) && !"EMC".equals(s) && !"S".equals(s) &&
                    !s.matches("^\\d*\\.?\\d*$"))
                {
                    // operator is not Q, not EMC, not S, nor a number -> assume binary data
                    noBinData = false;
                }
            }

            // only if not close to EOF
            if (noBinData && startOpIdx != -1 && readBytes == MAX_BIN_CHAR_TEST_LENGTH) 
            {
                if (endOpIdx == -1)
                {
                    endOpIdx = MAX_BIN_CHAR_TEST_LENGTH;
                    s = new String(binCharTestArr, startOpIdx, endOpIdx - startOpIdx, StandardCharsets.US_ASCII);
                }
                LOG.debug("startOpIdx: {} endOpIdx: {} s = '{}'", startOpIdx, endOpIdx, s);
                // look for token of 3 chars max or a number
                if (endOpIdx - startOpIdx > 3 && !s.matches("^\\d*\\.?\\d*$"))
                {
                    noBinData = false; // "operator" too long, assume binary data
                }
            }
            source.rewind(readBytes);
        }
        if (!noBinData)
        {
            LOG.warn(
                    "ignoring 'EI' assumed to be in the middle of inline image at stream offset {}, s = '{}'",
                    source.getPosition(), s);
        }
        return noBinData;
    }

    /**
     * This will read an operator from the stream.
     *
     * @return The operator that was read from the stream.
     *
     * @throws IOException If there is an error reading from the stream.
     */
    private String readOperator() throws IOException
    {
        skipSpaces();

        //average string size is around 2 and the normal string buffer size is
        //about 16 so lets save some space.
        StringBuilder buffer = new StringBuilder(4);
        int nextChar = source.peek();
        while(
            nextChar != -1 && // EOF
            !isWhitespace(nextChar) &&
            nextChar != '[' &&
            nextChar != '<' &&
            nextChar != '(' &&
            nextChar != '/' &&
            nextChar != '%' &&
            (nextChar < '0' ||
             nextChar > '9' ) )
        {
            char currentChar = (char) source.read();
            nextChar = source.peek();
            buffer.append( currentChar );
            // Type3 Glyph description has operators with a number in the name
            if (currentChar == 'd' && (nextChar == '0' || nextChar == '1') ) 
            {
                buffer.append((char) source.read());
                nextChar = source.peek();
            }
        }
        return buffer.toString();
    }
    
    
    private boolean isSpaceOrReturn( int c )
    {
        return c == 10 || c == 13 || c == 32;
    }

    /**
     * Checks if the next char is a space or a return.
     * 
     * @return true if the next char is a space or a return
     * @throws IOException if something went wrong
     */
    private boolean hasNextSpaceOrReturn() throws IOException
    {
        return isSpaceOrReturn(source.peek());
    }

    /**
     * Close the underlying resource.
     * 
     * @throws IOException if something went wrong
     */
    public void close() throws IOException
    {
        if (source != null && !source.isClosed())
        {
            source.close();
        }
    }

}
