package org.apache.velocity.tools.view;

/*
 * 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.
 */

import java.io.BufferedReader;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.io.Reader;
import java.io.StringReader;
import java.io.StringWriter;
import java.io.UnsupportedEncodingException;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLConnection;
import java.util.Locale;
import javax.servlet.RequestDispatcher;
import javax.servlet.ServletContext;
import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpServletResponseWrapper;
import org.apache.velocity.runtime.log.Log;

/**
 * <p>Provides methods to import arbitrary local or remote resources as strings.</p>
 * <p>Based on ImportSupport from the JSTL taglib by Shawn Bayern</p>
 *
 * @author <a href="mailto:marinoj@centrum.is">Marino A. Jonsson</a>
 * @since VelocityTools 2.0
 * @version $Revision: 591088 $ $Date: 2007-11-01 10:11:41 -0700 (Thu, 01 Nov 2007) $
 */
public abstract class ImportSupport
{
    protected static final String VALID_SCHEME_CHARS =
        "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+.-";

    /** Default character encoding for response. */
    protected static final String DEFAULT_ENCODING = "ISO-8859-1";

    protected Log LOG;
    protected ServletContext application;
    protected HttpServletRequest request;
    protected HttpServletResponse response;


    // --------------------------------------- Setup Methods -------------

    public void setLog(Log log)
    {
        if (log == null)
        {
            throw new NullPointerException("log should not be set to null");
        }
        this.LOG = log;
    }

    /**
     * Sets the current {@link HttpServletRequest}. This is required
     * for this tool to operate and will throw a NullPointerException
     * if this is not set or is set to {@code null}.
     */
    public void setRequest(HttpServletRequest request)
    {
        if (request == null)
        {
            throw new NullPointerException("request should not be null");
        }
        this.request = request;
    }

    /**
     * Sets the current {@link HttpServletResponse}. This is required
     * for this tool to operate and will throw a NullPointerException
     * if this is not set or is set to {@code null}.
     */
    public void setResponse(HttpServletResponse response)
    {
        if (response == null)
        {
            throw new NullPointerException("response should not be null");
        }
        this.response = response;
    }

    /**
     * Sets the {@link ServletContext}. This is required
     * for this tool to operate and will throw a NullPointerException
     * if this is not set or is set to {@code null}.
     */
    public void setServletContext(ServletContext application)
    {
        if (application == null)
        {
            throw new NullPointerException("servlet context should not be null");
        }
        this.application = application;
    }

    //*********************************************************************
    // URL importation logic

    /*
     * Overall strategy:  we have two entry points, acquireString() and
     * acquireReader().  The latter passes data through unbuffered if
     * possible (but note that it is not always possible -- specifically
     * for cases where we must use the RequestDispatcher.  The remaining
     * methods handle the common.core logic of loading either a URL or a local
     * resource.
     *
     * We consider the 'natural' form of absolute URLs to be Readers and
     * relative URLs to be Strings.  Thus, to avoid doing extra work,
     * acquireString() and acquireReader() delegate to one another as
     * appropriate.  (Perhaps I could have spelled things out more clearly,
     * but I thought this implementation was instructive, not to mention
     * somewhat cute...)
     */

    /**
     *
     * @param url the URL resource to return as string
     * @return the URL resource as string
     * @throws IOException
     * @throws java.lang.Exception
     */
    protected String acquireString(String url) throws IOException, Exception {
        // Record whether our URL is absolute or relative
        if (isAbsoluteUrl(url))
        {
            // for absolute URLs, delegate to our peer
            BufferedReader r = null;
            try
            {
                r = new BufferedReader(acquireReader(url));
                StringBuilder sb = new StringBuilder();
                int i;
                // under JIT, testing seems to show this simple loop is as fast
                // as any of the alternatives
                while ((i = r.read()) != -1)
                {
                    sb.append((char)i);
                }
                return sb.toString();
            }
            finally
            {
                if(r != null)
                {
                    try
                    {
                        r.close();
                    }
                    catch (IOException ioe)
                    {
                        LOG.error("ImportSupport : Could not close reader.", ioe);
                    }
                }
	        }
        }
        else // handle relative URLs ourselves
        {
            // URL is relative, so we must be an HTTP request
            if (!(request instanceof HttpServletRequest
                  && response instanceof HttpServletResponse))
            {
                throw new Exception("Relative import from non-HTTP request not allowed");
            }

            // retrieve an appropriate ServletContext
            // normalize the URL if we have an HttpServletRequest
            if (!url.startsWith("/"))
            {
                String sp = ((HttpServletRequest)request).getServletPath();
                url = sp.substring(0, sp.lastIndexOf('/')) + '/' + url;
            }

            // strip the session id from the url
            url = stripSession(url);

            // from this context, get a dispatcher
            RequestDispatcher rd = application.getRequestDispatcher(url);
            if (rd == null)
            {
                throw new Exception("Couldn't get a RequestDispatcher for \""
                                    + url + "\"");
            }

            // include the resource, using our custom wrapper
            ImportResponseWrapper irw =
                new ImportResponseWrapper((HttpServletResponse)response);
            try
            {
                rd.include(request, irw);
            }
            catch (IOException ex)
            {
                throw new Exception("Problem importing the relative URL \""
                                    + url + "\". " + ex);
            }
            catch (RuntimeException ex)
            {
                throw new Exception("Problem importing the relative URL \""
                                    + url + "\". " + ex);
            }

            // disallow inappropriate response codes per JSTL spec
            if (irw.getStatus() < 200 || irw.getStatus() > 299)
            {
                throw new Exception("Invalid response code '" + irw.getStatus()
                                    + "' for \"" + url + "\"");
            }

            // recover the response String from our wrapper
            return irw.getString();
        }
    }

    /**
     *
     * @param url the URL to read
     * @return a Reader for the InputStream created from the supplied URL
     * @throws IOException
     * @throws java.lang.Exception
     */
    protected Reader acquireReader(String url) throws IOException, Exception
    {
        if (!isAbsoluteUrl(url))
        {
            // for relative URLs, delegate to our peer
            return new StringReader(acquireString(url));
        }
        else
        {
            // absolute URL
            URLConnection uc = null;
            HttpURLConnection huc = null;
            InputStream i = null;

            try
            {
                // handle absolute URLs ourselves, using java.net.URL
                URL u = new URL(url);
                // URL u = new URL("http", "proxy.hi.is", 8080, target);
                uc = u.openConnection();
                i = uc.getInputStream();

                // check response code for HTTP URLs, per spec,
                if (uc instanceof HttpURLConnection)
                {
                    huc = (HttpURLConnection)uc;

                    int status = huc.getResponseCode();
                    if (status < 200 || status > 299)
                    {
                        throw new Exception(status + " " + url);
                    }
                }

                // okay, we've got a stream; encode it appropriately
                Reader r = null;
                String charSet;

                // charSet extracted according to RFC 2045, section 5.1
                String contentType = uc.getContentType();
                if (contentType != null)
                {
                    charSet = ImportSupport.getContentTypeAttribute(contentType, "charset");
                    if (charSet == null)
                    {
                        charSet = DEFAULT_ENCODING;
                    }
                }
                else
                {
                    charSet = DEFAULT_ENCODING;
                }

                try
                {
                    r = new InputStreamReader(i, charSet);
                }
                catch (UnsupportedEncodingException ueex)
                {
                    r = new InputStreamReader(i, DEFAULT_ENCODING);
                }

                if (huc == null)
                {
                    return r;
                }
                else
                {
                    return new SafeClosingHttpURLConnectionReader(r, huc);
                }
            }
            catch (IOException ex)
            {
                if (i != null)
                {
                    try
                    {
                        i.close();
                    }
                    catch (IOException ioe)
                    {
                        LOG.error("ImportSupport : Could not close InputStream", ioe);
                    }
                }

                if (huc != null)
                {
                    huc.disconnect();
                }
                throw new Exception("Problem accessing the absolute URL \""
                                    + url + "\". " + ex);
            }
            catch (RuntimeException ex)
            {
                if (i != null)
                {
                    try
                    {
                        i.close();
                    }
                    catch (IOException ioe)
                    {
                        LOG.error("ImportSupport : Could not close InputStream", ioe);
                    }
                }

                if (huc != null)
                {
                    huc.disconnect();
                }
                // because the spec makes us
                throw new Exception("Problem accessing the absolute URL \""
                                    + url + "\". " + ex);
            }
        }
    }

    protected static class SafeClosingHttpURLConnectionReader extends Reader
    {
        private final HttpURLConnection huc;
        private final Reader wrappedReader;

        SafeClosingHttpURLConnectionReader(Reader r, HttpURLConnection huc)
        {
            this.wrappedReader = r;
            this.huc = huc;
        }

        public void close() throws IOException
        {
            if(null != huc)
            {
                huc.disconnect();
            }

            wrappedReader.close();
        }

        // Pass-through methods.
        public void mark(int readAheadLimit) throws IOException
        {
            wrappedReader.mark(readAheadLimit);
        }

        public boolean markSupported()
        {
            return wrappedReader.markSupported();
        }

        public int read() throws IOException
        {
            return wrappedReader.read();
        }

        public int read(char[] buf) throws IOException
        {
            return wrappedReader.read(buf);
        }

        public int read(char[] buf, int off, int len) throws IOException
        {
            return wrappedReader.read(buf, off, len);
        }

        public boolean ready() throws IOException
        {
            return wrappedReader.ready();
        }

        public void reset() throws IOException
        {
            wrappedReader.reset();
        }

        public long skip(long n) throws IOException
        {
            return wrappedReader.skip(n);
        }
    }


    /** Wraps responses to allow us to retrieve results as Strings. */
    protected static class ImportResponseWrapper extends HttpServletResponseWrapper
    {
        /*
         * We provide either a Writer or an OutputStream as requested.
         * We actually have a true Writer and an OutputStream backing
         * both, since we don't want to use a character encoding both
         * ways (Writer -> OutputStream -> Writer).  So we use no
         * encoding at all (as none is relevant) when the target resource
         * uses a Writer.  And we decode the OutputStream's bytes
         * using OUR tag's 'charEncoding' attribute, or ISO-8859-1
         * as the default.  We thus ignore setLocale() and setContentType()
         * in this wrapper.
         *
         * In other words, the target's asserted encoding is used
         * to convert from a Writer to an OutputStream, which is typically
         * the medium through with the target will communicate its
         * ultimate response.  Since we short-circuit that mechanism
         * and read the target's characters directly if they're offered
         * as such, we simply ignore the target's encoding assertion.
         */

        /** The Writer we convey. */
        private StringWriter sw;

        /** A buffer, alternatively, to accumulate bytes. */
        private ByteArrayOutputStream bos;

        /** 'True' if getWriter() was called; false otherwise. */
        private boolean isWriterUsed;

        /** 'True if getOutputStream() was called; false otherwise. */
        private boolean isStreamUsed;

        /** The HTTP status set by the target. */
        private int status = 200;

        //************************************************************
        // Constructor and methods

        /**
         * Constructs a new ImportResponseWrapper.
         * @param response the response to wrap
         */
        public ImportResponseWrapper(HttpServletResponse response)
        {
            super(response);
        }

        /**
         * @return a Writer designed to buffer the output.
         */
        public PrintWriter getWriter()
        {
            if (isStreamUsed)
            {
                throw new IllegalStateException("Unexpected internal error during import: "
                                                + "Target servlet called getWriter(), then getOutputStream()");
            }
            isWriterUsed = true;
            if (sw == null)
            {
                sw = new StringWriter();
            }
            return new PrintWriter(sw);
        }

        /**
         * @return a ServletOutputStream designed to buffer the output.
         */
        public ServletOutputStream getOutputStream()
        {
            if (isWriterUsed)
            {
                throw new IllegalStateException("Unexpected internal error during import: "
                                                + "Target servlet called getOutputStream(), then getWriter()");
            }
            isStreamUsed = true;
            if (bos == null)
            {
                bos = new ByteArrayOutputStream();
            }
            ServletOutputStream sos = new ServletOutputStream()
                {
                    public void write(int b) throws IOException
                    {
                        bos.write(b);
                    }
                };
            return sos;
        }

        /** Has no effect. */
        public void setContentType(String x)
        {
            // ignore
        }

        /** Has no effect. */
        public void setLocale(Locale x)
        {
            // ignore
        }

        /**
         * Sets the status of the response
         * @param status the status code
         */
        public void setStatus(int status)
        {
            this.status = status;
        }

        /**
         * @return the status of the response
         */
        public int getStatus()
        {
            return status;
        }

        /**
         * Retrieves the buffered output, using the containing tag's
         * 'charEncoding' attribute, or the tag's default encoding,
         * <b>if necessary</b>.
         * @return the buffered output
         * @throws UnsupportedEncodingException if the encoding is not supported
         */
        public String getString() throws UnsupportedEncodingException
        {
            if (isWriterUsed)
            {
                return sw.toString();
            }
            else if (isStreamUsed)
            {
                return bos.toString(this.getCharacterEncoding());
            }
            else
            {
                return ""; // target didn't write anything
            }
        }
    }

    //*********************************************************************
    // Public utility methods

    /**
     * Returns <tt>true</tt> if our current URL is absolute,
     * <tt>false</tt> otherwise.
     *
     * @param url the url to check out
     * @return true if the url is absolute
     */
    public static boolean isAbsoluteUrl(String url) {
        // a null URL is not absolute, by our definition
        if (url == null)
        {
            return false;
        }

        // do a fast, simple check first
        int colonPos;
        if ((colonPos = url.indexOf(':')) == -1)
        {
            return false;
        }

        // if we DO have a colon, make sure that every character
        // leading up to it is a valid scheme character
        for (int i = 0; i < colonPos; i++)
        {
            if (VALID_SCHEME_CHARS.indexOf(url.charAt(i)) == -1)
            {
                return false;
            }
        }
        // if so, we've got an absolute url
        return true;
    }

    /**
     * Strips a servlet session ID from <tt>url</tt>.  The session ID
     * is encoded as a URL "path parameter" beginning with "jsessionid=".
     * We thus remove anything we find between ";jsessionid=" (inclusive)
     * and either EOS or a subsequent ';' (exclusive).
     *
     * @param url the url to strip the session id from
     * @return the stripped url
     */
    public static String stripSession(String url)
    {
        StringBuilder u = new StringBuilder(url);
        int sessionStart;
        while ((sessionStart = u.toString().indexOf(";jsessionid=")) != -1)
        {
            int sessionEnd = u.toString().indexOf(";", sessionStart + 1);
            if (sessionEnd == -1)
            {
                sessionEnd = u.toString().indexOf("?", sessionStart + 1);
            }
            if (sessionEnd == -1)
            {
                // still
                sessionEnd = u.length();
            }
            u.delete(sessionStart, sessionEnd);
        }
        return u.toString();
    }

    /**
     * Get the value associated with a content-type attribute.
     * Syntax defined in RFC 2045, section 5.1.
     *
     * @param input the string containing the attributes
     * @param name the name of the content-type attribute
     * @return the value associated with a content-type attribute
     */
    public static String getContentTypeAttribute(String input, String name)
    {
        int begin;
        int end;
        int index = input.toUpperCase().indexOf(name.toUpperCase());
        if (index == -1)
        {
            return null;
        }
        index = index + name.length(); // positioned after the attribute name
        index = input.indexOf('=', index); // positioned at the '='
        if (index == -1)
        {
            return null;
        }
        index += 1; // positioned after the '='
        input = input.substring(index).trim();

        if (input.charAt(0) == '"')
        {
            // attribute value is a quoted string
            begin = 1;
            end = input.indexOf('"', begin);
            if (end == -1)
            {
                return null;
            }
        }
        else
        {
            begin = 0;
            end = input.indexOf(';');
            if (end == -1)
            {
                end = input.indexOf(' ');
            }
            if (end == -1)
            {
                end = input.length();
            }
        }
        return input.substring(begin, end).trim();
    }

}
