/*
 * 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 net.hasor.web.upload;
import net.hasor.core.Settings;
import net.hasor.utils.ExceptionUtils;
import net.hasor.web.FileItem;
import net.hasor.web.FileItemFactory;
import net.hasor.web.FileItemHeaders;
import net.hasor.web.FileItemStream;
import net.hasor.web.upload.util.Closeable;
import net.hasor.web.upload.util.HeadersSet;
import net.hasor.web.upload.util.LimitedInputStream;
import net.hasor.web.upload.util.Streams;

import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.util.*;

import static java.lang.String.format;
import static net.hasor.web.upload.FileUploadException.UploadErrorCodes.*;

/**
 * <p>High level API for processing file uploads.</p>
 *
 * <p>This class handles multiple files per single HTML widget, sent using <code>multipart/mixed</code> encoding type, as specified by
 * <a href="http://www.ietf.org/rfc/rfc1867.txt">RFC 1867</a>.
 *
 * <p>How the data for individual parts is stored is determined by the factory
 * used to create them; a given part may be in memory, on disk, or somewhere else.</p>
 *
 * @version $Id: FileUpload.java 1743630 2016-05-13 09:20:45Z jochen $
 */
public class FileUpload {
    /** HTTP content type header name. */
    public static final  String CONTENT_TYPE        = "Content-type";
    /** HTTP content disposition header name. */
    public static final  String CONTENT_DISPOSITION = "Content-disposition";
    /** HTTP content length header name. */
    public static final  String CONTENT_LENGTH      = "Content-length";
    /** Content-disposition value for form data. */
    public static final  String FORM_DATA           = "form-data";
    /** Content-disposition value for file attachment. */
    public static final  String ATTACHMENT          = "attachment";
    /** Part of HTTP content type header. */
    public static final  String MULTIPART           = "multipart/";
    /** HTTP content type header for multipart forms. */
    public static final  String MULTIPART_FORM_DATA = "multipart/form-data";
    /** HTTP content type header for multiple uploads. */
    public static final  String MULTIPART_MIXED     = "multipart/mixed";
    /** Constant for HTTP POST method. */
    private static final String POST_METHOD         = "POST";
    // ---------------------------------------------------------- Class methods

    /**
     * Utility method that determines whether the request contains multipart content.
     * @param request The servlet request to be evaluated. Must be non-null.
     * @return <code>true</code> if the request is multipart;
     *         <code>false</code> otherwise.
     */
    public static final boolean isMultipartContent(HttpServletRequest request) {
        if (!POST_METHOD.equalsIgnoreCase(request.getMethod())) {
            return false;
        }
        String contentType = new ServletRequestContext(request).getContentType();
        if (contentType == null) {
            return false;
        }
        if (contentType.toLowerCase(Locale.ENGLISH).startsWith(MULTIPART)) {
            return true;
        }
        return false;
    }
    // ----------------------------------------------------------- Data members
    /** The maximum size permitted for the complete request, as opposed to {@link #fileSizeMax}. A value of -1 indicates no maximum. */
    private long   sizeMax     = -1;
    /** The maximum size permitted for a single uploaded file, as opposed to {@link #sizeMax}. A value of -1 indicates no maximum. */
    private long   fileSizeMax = -1;
    /** The content encoding to use when reading part headers. */
    private String headerEncoding;

    // ----------------------------------------------------- Property accessors
    public FileUpload() {
    }

    public FileUpload(Settings settings) {
        this.setHeaderEncoding(Settings.DefaultCharset);
        this.setSizeMax(settings.getInteger("hasor.fileupload.maxRequestSize", -1));
        this.setFileSizeMax(settings.getInteger("hasor.fileupload.maxFileSize", -1));
    }

    /**
     * Returns the maximum allowed size of a complete request, as opposed to {@link #getFileSizeMax()}.
     * @return The maximum allowed size, in bytes. The default value of -1 indicates, that there is no limit.
     * @see #setSizeMax(long)
     */
    public long getSizeMax() {
        return sizeMax;
    }

    /**
     * Sets the maximum allowed size of a complete request, as opposed to {@link #setFileSizeMax(long)}.
     * @param sizeMax The maximum allowed size, in bytes. The default value of -1 indicates, that there is no limit.
     * @see #getSizeMax()
     */
    public void setSizeMax(long sizeMax) {
        this.sizeMax = sizeMax;
    }

    /**
     * Returns the maximum allowed size of a single uploaded file, as opposed to {@link #getSizeMax()}.
     * @see #setFileSizeMax(long)
     * @return Maximum size of a single uploaded file.
     */
    public long getFileSizeMax() {
        return fileSizeMax;
    }

    /**
     * Sets the maximum allowed size of a single uploaded file, as opposed to {@link #getSizeMax()}.
     * @see #getFileSizeMax()
     * @param fileSizeMax Maximum size of a single uploaded file.
     */
    public void setFileSizeMax(long fileSizeMax) {
        this.fileSizeMax = fileSizeMax;
    }

    /**
     * Retrieves the character encoding used when reading the headers of an individual part. 
     * When not specified, or <code>null</code>, the request encoding is used.
     * If that is also not specified, or <code>null</code>, the platform default encoding is used.
     * @return The encoding used to read part headers.
     */
    public String getHeaderEncoding() {
        return headerEncoding;
    }

    /**
     * Specifies the character encoding to be used when reading the headers of individual part. 
     * When not specified, or <code>null</code>, the request encoding is used. 
     * If that is also not specified, or <code>null</code>, the platform default encoding is used.
     * @param encoding The encoding used to read part headers.
     */
    public void setHeaderEncoding(String encoding) {
        headerEncoding = encoding;
    }
    // --------------------------------------------------------- Public methods

    /**
     * Processes an <a href="http://www.ietf.org/rfc/rfc1867.txt">RFC 1867</a> compliant <code>multipart/form-data</code> stream.
     * @param request The  request.
     * @return An iterator to instances of <code>FileItemStream</code>
     *         parsed from the request, in the order that they were transmitted.
     * @throws net.hasor.web.upload.FileUploadException if there are problems reading/parsing the request or storing files.
     * @throws IOException An I/O error occurred. This may be a network
     *   error while communicating with the client or a problem while storing the uploaded content.
     */
    public Iterator<FileItemStream> getItemIterator(HttpServletRequest request) throws IOException {
        return new FileItemIteratorImpl(new ServletRequestContext(request));
    }

    /**
     * Processes an <a href="http://www.ietf.org/rfc/rfc1867.txt">RFC 1867</a> compliant <code>multipart/form-data</code> stream.
     * @param request the request.
     * @return A list of <code>FileItem</code> instances parsed from the request, in the order that they were transmitted.
     * @throws FileUploadException if there are problems reading/parsing the request or storing files.
     */
    public List<FileItem> parseRequest(HttpServletRequest request, FileItemFactory factory) throws IOException {
        List<FileItem> items = new ArrayList<>();
        boolean successful = false;
        try {
            Iterator<FileItemStream> fileItems = this.getItemIterator(request);
            if (factory == null) {
                throw new NullPointerException("No FileItemFactory has been set.");
            }
            while (fileItems.hasNext()) {
                FileItemStream itemStream = fileItems.next();
                FileItem fileItem = factory.createItem(itemStream);
                items.add(fileItem);
            }
            successful = true;
            return items;
        } catch (IOException e) {
            throw e;
        } finally {
            if (!successful) {
                for (FileItem fileItem : items) {
                    try {
                        fileItem.deleteOrSkip();
                    } catch (Throwable e) { /*ignore it*/ }
                }
            }
        }
    }
    // ------------------------------------------------------ Protected methods

    /**
     * Retrieves the boundary from the <code>Content-type</code> header.
     * @param contentType The value of the content type header from which to extract the boundary value.
     * @return The boundary, as a byte array.
     */
    protected byte[] getBoundary(String contentType) {
        ParameterParser parser = new ParameterParser();
        parser.setLowerCaseNames(true);
        // Parameter parser can handle null input
        Map<String, String> params = parser.parse(contentType, new char[] { ';', ',' });
        String boundaryStr = params.get("boundary");
        if (boundaryStr == null) {
            return null;
        }
        byte[] boundary;
        try {
            boundary = boundaryStr.getBytes("ISO-8859-1");
        } catch (UnsupportedEncodingException e) {
            boundary = boundaryStr.getBytes(); // Intentionally falls back to default charset
        }
        return boundary;
    }

    /**
     * Retrieves the file name from the <code>Content-disposition</code> header.
     * @param headers The HTTP headers object.
     * @return The file name for the current <code>encapsulation</code>.
     */
    protected String getFileName(FileItemHeaders headers) {
        return getFileName(headers.getHeader(CONTENT_DISPOSITION));
    }

    /**
     * Returns the given content-disposition headers file name.
     * @param pContentDisposition The content-disposition headers value.
     * @return The file name
     */
    private String getFileName(String pContentDisposition) {
        String fileName = null;
        if (pContentDisposition != null) {
            String cdl = pContentDisposition.toLowerCase(Locale.ENGLISH);
            if (cdl.startsWith(FORM_DATA) || cdl.startsWith(ATTACHMENT)) {
                ParameterParser parser = new ParameterParser();
                parser.setLowerCaseNames(true);
                // Parameter parser can handle null input
                Map<String, String> params = parser.parse(pContentDisposition, ';');
                if (params.containsKey("filename")) {
                    fileName = params.get("filename");
                    if (fileName != null) {
                        fileName = fileName.trim();
                    } else {
                        // Even if there is no value, the parameter is present,
                        // so we return an empty file name rather than no file
                        // name.
                        fileName = "";
                    }
                }
            }
        }
        return fileName;
    }

    /**
     * Retrieves the field name from the <code>Content-disposition</code> header.
     * @param headers A <code>Map</code> containing the HTTP request headers.
     * @return The field name for the current <code>encapsulation</code>.
     */
    protected String getFieldName(FileItemHeaders headers) {
        return getFieldName(headers.getHeader(CONTENT_DISPOSITION));
    }

    /**
     * Returns the field name, which is given by the content-disposition header.
     * @param pContentDisposition The content-dispositions header value.
     * @return The field jake
     */
    private String getFieldName(String pContentDisposition) {
        String fieldName = null;
        if (pContentDisposition != null && pContentDisposition.toLowerCase(Locale.ENGLISH).startsWith(FORM_DATA)) {
            ParameterParser parser = new ParameterParser();
            parser.setLowerCaseNames(true);
            // Parameter parser can handle null input
            Map<String, String> params = parser.parse(pContentDisposition, ';');
            fieldName = params.get("name");
            if (fieldName != null) {
                fieldName = fieldName.trim();
            }
        }
        return fieldName;
    }

    /**
     * <p> Parses the <code>header-part</code> and returns as key/value pairs.
     * <p> If there are multiple headers of the same names, the name will map to a comma-separated list containing the values.
     * @param headerPart The <code>header-part</code> of the current
     *                   <code>encapsulation</code>.
     * @return A <code>Map</code> containing the parsed HTTP request headers.
     */
    protected FileItemHeaders getParsedHeaders(String headerPart) {
        final int len = headerPart.length();
        HeadersSet headers = new HeadersSet();
        int start = 0;
        for (; ; ) {
            int end = parseEndOfLine(headerPart, start);
            if (start == end) {
                break;
            }
            StringBuilder header = new StringBuilder(headerPart.substring(start, end));
            start = end + 2;
            while (start < len) {
                int nonWs = start;
                while (nonWs < len) {
                    char c = headerPart.charAt(nonWs);
                    if (c != ' ' && c != '\t') {
                        break;
                    }
                    ++nonWs;
                }
                if (nonWs == start) {
                    break;
                }
                // Continuation line found
                end = parseEndOfLine(headerPart, nonWs);
                header.append(" ").append(headerPart.substring(nonWs, end));
                start = end + 2;
            }
            parseHeaderLine(headers, header.toString());
        }
        return headers;
    }

    /**
     * Skips bytes until the end of the current line.
     * @param headerPart The headers, which are being parsed.
     * @param end Index of the last byte, which has yet been processed.
     * @return Index of the \r\n sequence, which indicates end of line.
     */
    private int parseEndOfLine(String headerPart, int end) {
        int index = end;
        for (; ; ) {
            int offset = headerPart.indexOf('\r', index);
            if (offset == -1 || offset + 1 >= headerPart.length()) {
                throw new IllegalStateException("Expected headers to be terminated by an empty line.");
            }
            if (headerPart.charAt(offset + 1) == '\n') {
                return offset;
            }
            index = offset + 1;
        }
    }

    /**
     * Reads the next header line.
     * @param headers String with all headers.
     * @param header Map where to store the current header.
     */
    private void parseHeaderLine(HeadersSet headers, String header) {
        final int colonOffset = header.indexOf(':');
        if (colonOffset == -1) {
            // This header line is malformed, skip it.
            return;
        }
        String headerName = header.substring(0, colonOffset).trim();
        String headerValue = header.substring(header.indexOf(':') + 1).trim();
        headers.addHeader(headerName, headerValue);
    }
    //
    //

    /** The iterator, which is returned by {@link FileUpload#getItemIterator(HttpServletRequest)}. */
    public class FileItemIteratorImpl implements Iterator<FileItemStream> {
        class FileItemStreamImpl implements FileItemStream {
            /** The file items content type. */
            private final String          contentType;
            /** The file items field name. */
            private final String          fieldName;
            /** The file items file name. */
            private final String          name;
            /** Whether the file item is a form field. */
            private final boolean         formField;
            /** The file items input stream. */
            private final InputStream     stream;
            /** Whether the file item was already opened. */
            private       boolean         opened;
            /** The headers, if any. */
            private       FileItemHeaders headers;

            /**
             * Creates a new instance.
             * @param pName The items file name, or null.
             * @param pFieldName The items field name.
             * @param pFormField Whether the item is a form field.
             * @param pContentLength The items content length, if known, or -1
             * @throws IOException Creating the file item failed.
             */
            FileItemStreamImpl(FileItemHeaders headers, String pName, String pFieldName, boolean pFormField, long pContentLength) throws IOException {
                this.headers = headers;
                this.name = pName;
                this.fieldName = pFieldName;
                this.contentType = headers.getHeader(CONTENT_TYPE);
                this.formField = pFormField;
                final MultipartStream.ItemInputStream itemStream = multi.newInputStream();
                InputStream istream = itemStream;
                if (fileSizeMax != -1) {
                    if (pContentLength != -1 && pContentLength > fileSizeMax) {
                        String logMessage = format("The field %s exceeds its maximum permitted size of %s bytes.", fieldName, fileSizeMax);
                        throw new FileUploadException(FileSizeLimitExceededException, logMessage);
                    }
                    istream = new LimitedInputStream(istream, fileSizeMax) {
                        @Override
                        protected void raiseError(long pSizeMax, long pCount) throws IOException {
                            itemStream.close(true);
                            String logMessage = format("The field %s exceeds its maximum permitted size of %s bytes.", fieldName, pSizeMax);
                            throw new FileUploadException(FileSizeLimitExceededException, logMessage);
                        }
                    };
                }
                this.stream = istream;
            }

            /**
             * Returns the items content type, or null.
             *
             * @return Content type, if known, or null.
             */
            public String getContentType() {
                return contentType;
            }

            /**
             * Returns the items field name.
             *
             * @return Field name.
             */
            public String getFieldName() {
                return fieldName;
            }

            /**
             * Returns the items file name.
             *
             * @return File name, if known, or null.
             * @throws IllegalArgumentException The file name contains a NUL character,
             *   which might be an indicator of a security attack. If you intend to
             *   use the file name anyways, catch the exception and use
             *   InvalidFileNameException#getName().
             */
            public String getName() {
                return Streams.checkFileName(name);
            }

            /**
             * Returns, whether this is a form field.
             *
             * @return True, if the item is a form field,
             *   otherwise false.
             */
            public boolean isFormField() {
                return formField;
            }

            /**
             * Returns an input stream, which may be used to
             * read the items contents.
             *
             * @return Opened input stream.
             * @throws IOException An I/O error occurred.
             */
            public InputStream openStream() throws IOException {
                if (opened) {
                    throw new IllegalStateException("The stream was already opened.");
                }
                if (((Closeable) stream).isClosed()) {
                    throw new FileUploadException(ItemSkippedException);
                }
                return stream;
            }

            /**
             * Closes the file item.
             *
             * @throws IOException An I/O error occurred.
             */
            void close() throws IOException {
                stream.close();
            }

            /**
             * Returns the file item headers.
             * @return The items header object
             */
            public FileItemHeaders getHeaders() {
                return headers;
            }
        }

        /** The multi part stream to process. */
        private final MultipartStream    multi;
        /** The boundary, which separates the various parts. */
        private final byte[]             boundary;
        /** The item, which we currently process. */
        private       FileItemStreamImpl currentItem;
        /** The current items field name. */
        private       String             currentFieldName;
        /** Whether we are currently skipping the preamble. */
        private       boolean            skipPreamble;
        /** Whether the current item may still be read. */
        private       boolean            itemValid;
        /** Whether we have seen the end of the file. */
        private       boolean            eof;

        /**
         * Creates a new instance.
         * @param ctx The request context.
         * @throws FileUploadException An error occurred while parsing the request.
         * @throws IOException An I/O error occurred.
         */
        FileItemIteratorImpl(ServletRequestContext ctx) throws IOException {
            if (ctx == null) {
                throw new NullPointerException("ctx parameter");
            }
            String contentType = ctx.getContentType();
            if ((null == contentType) || (!contentType.toLowerCase(Locale.ENGLISH).startsWith(MULTIPART))) {
                String logMEssage = format("the request doesn't contain a %s or %s stream, content type header is %s", MULTIPART_FORM_DATA, MULTIPART_MIXED, contentType);
                throw new FileUploadException(InvalidContentTypeException, logMEssage);
            }
            InputStream input = ctx.getInputStream();
            long requestSize = ctx.contentLength();
            // CHECKSTYLE:ON
            if (sizeMax >= 0) {
                if (requestSize != -1 && requestSize > sizeMax) {
                    String logMEssage = format("the request was rejected because its size (%s) exceeds the configured maximum (%s)", requestSize, sizeMax);
                    throw new FileUploadException(SizeLimitExceededException, logMEssage);
                }
                input = new LimitedInputStream(input, sizeMax) {
                    @Override
                    protected void raiseError(long pSizeMax, long pCount) throws IOException {
                        String logMEssage = format("the request was rejected because its size (%s) exceeds the configured maximum (%s)", pCount, pSizeMax);
                        throw new FileUploadException(SizeLimitExceededException, logMEssage);
                    }
                };
            }
            String charEncoding = headerEncoding;
            if (charEncoding == null) {
                charEncoding = ctx.getCharacterEncoding();
            }
            boundary = getBoundary(contentType);
            if (boundary == null) {
                throw new FileUploadException("the request was rejected because no multipart boundary was found");
            }
            try {
                multi = new MultipartStream(input, boundary);
            } catch (IllegalArgumentException iae) {
                String logMessage = format("The boundary specified in the %s header is too long", CONTENT_TYPE);
                throw new FileUploadException(InvalidContentTypeException, logMessage);
            }
            multi.setHeaderEncoding(charEncoding);
            skipPreamble = true;
            findNextItem();
        }

        /**
         * Called for finding the next item, if any.
         * @return True, if an next item was found, otherwise false.
         * @throws IOException An I/O error occurred.
         */
        private boolean findNextItem() throws IOException {
            if (eof) {
                return false;
            }
            if (currentItem != null) {
                currentItem.close();
                currentItem = null;
            }
            for (; ; ) {
                boolean nextPart;
                if (skipPreamble) {
                    nextPart = multi.skipPreamble();
                } else {
                    nextPart = multi.readBoundary();
                }
                if (!nextPart) {
                    if (currentFieldName == null) {
                        eof = true; // Outer multipart terminated -> No more data
                        return false;
                    }
                    multi.setBoundary(boundary);// Inner multipart terminated -> Return to parsing the outer
                    currentFieldName = null;
                    continue;
                }
                FileItemHeaders headers = getParsedHeaders(multi.readHeaders());
                if (currentFieldName == null) {
                    // We're parsing the outer multipart
                    String fieldName = getFieldName(headers);
                    if (fieldName != null) {
                        String subContentType = headers.getHeader(CONTENT_TYPE);
                        if (subContentType != null && subContentType.toLowerCase(Locale.ENGLISH).startsWith(MULTIPART_MIXED)) {
                            currentFieldName = fieldName;
                            // Multiple files associated with this field name
                            byte[] subBoundary = getBoundary(subContentType);
                            multi.setBoundary(subBoundary);
                            skipPreamble = true;
                            continue;
                        }
                        String fileName = getFileName(headers);
                        currentItem = new FileItemStreamImpl(headers, fileName, fieldName, fileName == null, getContentLength(headers));
                        itemValid = true;
                        return true;
                    }
                } else {
                    String fileName = getFileName(headers);
                    if (fileName != null) {
                        currentItem = new FileItemStreamImpl(headers, fileName, currentFieldName, false, getContentLength(headers));
                        itemValid = true;
                        return true;
                    }
                }
                multi.discardBodyData();
            }
        }

        private long getContentLength(FileItemHeaders pHeaders) {
            try {
                return Long.parseLong(pHeaders.getHeader(CONTENT_LENGTH));
            } catch (Exception e) {
                return -1;
            }
        }

        @Override
        public void remove() {
            throw new UnsupportedOperationException();
        }

        /**
         * Returns, whether another instance of {@link FileItemStream} is available.
         *
         * @throws FileUploadException Parsing or processing the file item failed.
         * @throws IOException Reading the file item failed.
         * @return True, if one or more additional file items are available, otherwise false.
         */
        public boolean hasNext() {
            if (eof) {
                return false;
            }
            if (itemValid) {
                return true;
            }
            try {
                return findNextItem();
            } catch (IOException e) {
                throw ExceptionUtils.toRuntimeException(e);
            }
        }

        /**
         * Returns the next available {@link FileItemStream}.
         *
         * @throws java.util.NoSuchElementException No more items are available.
         *          Use {@link #hasNext()} to prevent this exception.
         * @throws FileUploadException Parsing or processing the file item failed.
         * @throws IOException Reading the file item failed.
         * @return FileItemStream instance, which provides access to the next file item.
         */
        public FileItemStream next() {
            if (eof || (!itemValid && !hasNext())) {
                throw new NoSuchElementException();
            }
            itemValid = false;
            return currentItem;
        }
    }
}