/*
 * 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.sling.scripting.jsp.jasper.servlet;

import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.LineNumberReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;

import javax.servlet.Servlet;
import javax.servlet.ServletConfig;
import javax.servlet.ServletContext;
import javax.servlet.ServletException;
import javax.servlet.SingleThreadModel;
import javax.servlet.UnavailableException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.jsp.tagext.TagInfo;

import org.apache.juli.logging.Log;
import org.apache.juli.logging.LogFactory;
import org.apache.sling.api.SlingException;
import org.apache.sling.api.SlingIOException;
import org.apache.sling.api.SlingServletException;
import org.apache.sling.api.scripting.ScriptEvaluationException;
import org.apache.sling.api.scripting.SlingBindings;
import org.apache.sling.commons.classloader.DynamicClassLoader;
import org.apache.sling.scripting.jsp.SlingPageException;
import org.apache.sling.scripting.jsp.jasper.JasperException;
import org.apache.sling.scripting.jsp.jasper.JspCompilationContext;
import org.apache.sling.scripting.jsp.jasper.Options;
import org.apache.sling.scripting.jsp.jasper.compiler.ErrorDispatcher;
import org.apache.sling.scripting.jsp.jasper.compiler.JavacErrorDetail;
import org.apache.sling.scripting.jsp.jasper.compiler.JspRuntimeContext;
import org.apache.sling.scripting.jsp.jasper.compiler.Localizer;
import org.apache.sling.scripting.jsp.jasper.runtime.AnnotationProcessor;
import org.apache.sling.scripting.jsp.jasper.runtime.JspSourceDependent;

/**
 * The JSP engine (a.k.a Jasper).
 *
 * The servlet container is responsible for providing a
 * URLClassLoader for the web application context Jasper
 * is being used in. Jasper will try get the Tomcat
 * ServletContext attribute for its ServletContext class
 * loader, if that fails, it uses the parent class loader.
 * In either case, it must be a URLClassLoader.
 *
 * @author Anil K. Vijendran
 * @author Harish Prabandham
 * @author Remy Maucherat
 * @author Kin-man Chung
 * @author Glenn Nielsen
 * @author Tim Fennell
 */

public class JspServletWrapper {

    // Logger
    private final Log log = LogFactory.getLog(JspServletWrapper.class);

    private final ServletConfig config;
    private final Options options;
    private final boolean isTagFile;
    private final String jspUri;
    private final JspCompilationContext ctxt;

    private volatile Servlet theServlet;
    private volatile Class<?> tagFileClass;

    private volatile long available = 0L;
    private volatile JasperException compileException;
    private volatile int tripCount;

    private volatile List<String> dependents;

    /**
     * JspServletWrapper for JSP pages.
     */
    public JspServletWrapper(final ServletConfig config,
            final Options options,
            final String jspUri,
            final boolean isErrorPage,
            final JspRuntimeContext rctxt) {
	    this.isTagFile = false;
        this.config = config;
        this.options = options;
        this.jspUri = jspUri;
        this.ctxt = new JspCompilationContext(jspUri, isErrorPage, options,
					 config.getServletContext(),
					 rctxt);
        if ( log.isDebugEnabled() ) {
            log.debug("Creating new wrapper for servlet " + jspUri);
        }
    }

    /**
     * JspServletWrapper for precompiled JSPs
     */
    public JspServletWrapper(final ServletConfig config,
            final Options options,
            final String jspUri,
            final boolean isErrorPage,
            final JspRuntimeContext rctxt,
            final Servlet servlet) {
        this.isTagFile = false;
        this.config = config;
        this.options = options;
        this.jspUri = jspUri;
        this.ctxt = new JspCompilationContext(jspUri, isErrorPage, options,
                             config.getServletContext(),
                             rctxt, null);
        this.theServlet = servlet;
        if ( log.isDebugEnabled() ) {
            log.debug("Creating new wrapper for servlet " + jspUri);
        }
    }

    /**
     * JspServletWrapper for tag files.
     */
    public JspServletWrapper(final ServletContext servletContext,
			     final Options options,
			     final String tagFilePath,
			     final TagInfo tagInfo,
			     final JspRuntimeContext rctxt,
			     final URL tagFileJarUrl)
    throws JasperException {
        this.isTagFile = true;
        this.config = null;	// not used
        this.options = options;
        this.jspUri = tagFilePath;
        this.ctxt = new JspCompilationContext(jspUri, tagInfo, options,
					 servletContext, rctxt, tagFileJarUrl);
        if ( log.isDebugEnabled() ) {
            log.debug("Creating new wrapper for tagfile " + jspUri);
        }
    }

    public JspCompilationContext getJspEngineContext() {
        return ctxt;
    }

    public boolean isValid() {
        if ( theServlet != null ) {
            if ( theServlet.getClass().getClassLoader() instanceof DynamicClassLoader ) {
                return ((DynamicClassLoader)theServlet.getClass().getClassLoader()).isLive();
            }
        }
        return true;
    }

    @SuppressWarnings("unchecked")
    private Servlet loadServlet()
    throws ServletException, IOException {
        Servlet servlet = null;

        try {
            if ( log.isDebugEnabled() ) {
                log.debug("Loading servlet " + jspUri);
            }
            servlet = (Servlet) ctxt.load().newInstance();
            AnnotationProcessor annotationProcessor = (AnnotationProcessor) config.getServletContext().getAttribute(AnnotationProcessor.class.getName());
            if (annotationProcessor != null) {
               annotationProcessor.processAnnotations(servlet);
               annotationProcessor.postConstruct(servlet);
            }
            // update dependents
            final List<String> oldDeps = this.dependents;
            if (servlet != null && servlet instanceof JspSourceDependent) {
                this.dependents = (List<String>) ((JspSourceDependent) servlet).getDependants();
                if ( this.dependents == null ) {
                    this.dependents = Collections.EMPTY_LIST;
                }
                this.ctxt.getRuntimeContext().addJspDependencies(this, this.dependents);
            }
            if ( !equals(oldDeps, this.dependents) ) {
                this.persistDependencies();
            }
        } catch (final IllegalAccessException e) {
            throw new JasperException(e);
        } catch (final InstantiationException e) {
            throw new JasperException(e);
        } catch (final Exception e) {
            throw new JasperException(e);
        }

        servlet.init(config);

        return servlet;
    }

    /**
     * Get the name of the dependencies file.
     */
    public String getDependencyFilePath() {
        final String name;
        if (isTagFile) {
            name = this.ctxt.getTagInfo().getTagClassName();
        } else {
            name = this.ctxt.getServletPackageName() + "." + this.ctxt.getServletClassName();
        }

        final String path = ":/" + name.replace('.', '/') + ".deps";
        return path;
    }

    /**
     * Persist dependencies
     */
    private void persistDependencies() {
        final String path = this.getDependencyFilePath();
        if ( log.isDebugEnabled() ) {
            log.debug("Writing dependencies for " + jspUri);
        }
        if ( this.dependents != null && this.dependents.size() > 0 ) {
            OutputStream os = null;
            try {
                os = this.ctxt.getRuntimeContext().getIOProvider().getOutputStream(path);
                final OutputStreamWriter writer = new OutputStreamWriter(os, "UTF-8");
                for(final String dep : this.dependents) {
                    writer.write(dep);
                    writer.write("\n");
                }
                writer.flush();
            } catch ( final IOException ioe) {
                log.warn("Unable to write dependenies file " + path + " : " + ioe.getMessage(), ioe);
            } finally {
                if ( os != null ) {
                    try {
                        os.close();
                    } catch (final IOException ioe) {
                        // ignore
                    }
                }
            }
        } else {
            this.ctxt.getRuntimeContext().getIOProvider().delete(path);
        }
    }

    /**
     * Compile (if needed) and load a tag file
     */
    @SuppressWarnings("unchecked")
    public Class<?> loadTagFile() throws JasperException {
        if ( compileException != null ) {
            throw compileException;
        }

        if ( this.tagFileClass == null ) {
            synchronized (this) {
                if ( this.tagFileClass == null ) {
                    if ( log.isDebugEnabled() ) {
                        log.debug("Compiling tagfile " + jspUri);
                    }
                    this.compileException = ctxt.compile();
                    if ( compileException != null ) {
                        throw compileException;
                    }
                    if ( log.isDebugEnabled() ) {
                        log.debug("Loading tagfile " + jspUri);
                    }
                    this.tagFileClass = this.ctxt.load();
                    try {
                        final Object tag = this.tagFileClass.newInstance();
                        // update dependents
                        final List<String> oldDeps = this.dependents;
                        if (tag != null && tag instanceof JspSourceDependent) {
                            this.dependents = (List<String>) ((JspSourceDependent) tag).getDependants();
                            this.ctxt.getRuntimeContext().addJspDependencies(this, this.dependents);
                            if ( this.dependents == null ) {
                                this.dependents = Collections.EMPTY_LIST;
                            }
                        }
                        if ( !equals(oldDeps, this.dependents) ) {
                            this.persistDependencies();
                        }
                    } catch (final Throwable t) {
                        // ignore
                    }
                }
            }
        }
        return this.tagFileClass;
    }

    /**
     * Compile and load a prototype for the Tag file.  This is needed
     * when compiling tag files with circular dependencies.  A prototpe
     * (skeleton) with no dependencies on other other tag files is
     * generated and compiled.
     */
    public Class<?> loadTagFilePrototype() throws JasperException {
    	ctxt.setPrototypeMode(true);
    	try {
    	    return loadTagFile();
    	} finally {
    	    ctxt.setPrototypeMode(false);
    	}
    }

    /**
     * Get a list of files that the current page has source dependency on.
     */
    public List<String> getDependants() {
        if ( this.dependents == null ) {
            synchronized ( this ) {
                if ( this.dependents == null ) {
                    // we load the deps file
                    final String path = this.getDependencyFilePath();
                    InputStream is = null;
                    try {
                        is = this.ctxt.getRuntimeContext().getIOProvider().getInputStream(path);
                        if ( is != null ) {
                            if ( log.isDebugEnabled() ) {
                                log.debug("Loading dependencies for " + jspUri);
                            }
                            final List<String> deps = new ArrayList<String>();
                            final InputStreamReader reader = new InputStreamReader(is, "UTF-8");
                            final LineNumberReader lnr = new LineNumberReader(reader);
                            String line;
                            while ( (line = lnr.readLine()) != null ) {
                                deps.add(line.trim());
                            }
                            this.dependents = deps;
                        }
                    } catch ( final IOException ignore ) {
                        // excepted
                    } finally {
                        if ( is != null ) {
                            try { is.close(); } catch ( final IOException ioe ) {}
                        }
                    }

                    // use empty list, until servlet is compiled and loaded
                    if ( this.dependents == null ) {
                        this.dependents = Collections.emptyList();
                    }
                }
            }
        }
        return this.dependents;
    }

    public boolean isTagFile() {
        return this.isTagFile;
    }

    public int incTripCount() {
        return tripCount++;
    }

    public int decTripCount() {
        return tripCount--;
    }

    public String getJspUri() {
        return jspUri;
    }

    /**
     * Check if the compiled class is still current
     */
    private boolean isOutDated() {
        // check if class file exists
        final String targetFile = ctxt.getClassFileName();
        final long targetLastModified = ctxt.getRuntimeContext().getIOProvider().lastModified(targetFile);
        if (targetLastModified < 0) {
            return true;
        }

        // compare jsp time stamp with class file time stamp
        final String jsp = ctxt.getJspFile();
        final long jspRealLastModified = ctxt.getRuntimeContext().getIOProvider().lastModified(jsp);
        if (targetLastModified < jspRealLastModified) {
            if (log.isDebugEnabled()) {
                log.debug("Compiler: outdated: " + targetFile + " "
                        + targetLastModified);
            }
            return true;
        }

        // check includes
        final List<String> depends = this.getDependants();
        if (depends != null) {
            final Iterator<String> it = depends.iterator();
            while (it.hasNext()) {
                final String include = it.next();
                // ignore tag libs, we are reloaded if a taglib changes anyway
                if ( include.startsWith("tld:") ) {
                    continue;
                }
                final long includeLastModified = ctxt.getRuntimeContext().getIOProvider().lastModified(include);

                if (includeLastModified > targetLastModified) {
                    if (log.isDebugEnabled()) {
                        log.debug("Compiler: outdated: " + targetFile + " because of dependency " + include + " : "
                                + targetLastModified + " - " + includeLastModified);
                    }
                    return true;
                }
            }
        }

        return false;

    }

    /**
     * Prepare the servlet:
     * - compile it if it either hasn't been compiled yet or is out dated
     * - load the servlet
     *
     */
    private void prepareServlet(final HttpServletRequest request,
            final HttpServletResponse response)
    throws IOException, ServletException {
        if ( isOutDated() ) {
            // Compile...
            if ( log.isDebugEnabled() ) {
                log.debug("Compiling servlet " + this.jspUri);
            }
            this.compileException = ctxt.compile();
            if ( compileException != null ) {
                throw compileException;
            }
        }

        // (Re)load servlet class file
        this.theServlet = this.loadServlet();
    }

    /**
     * Call the jsp
     * @param bindings The bindings for the jsp
     * @throws SlingPageException JSP page exception handler exceptions
     * @throws SlingException for any non runtime exception
     * @throws RuntimeException for runtime exceptions
     *
     */
    public void service(final SlingBindings bindings) {
        try {
            service(bindings.getRequest(), bindings.getResponse());
        } catch (IOException ioe) {
            throw new SlingIOException(ioe);
        } catch (ServletException se) {
            throw new SlingServletException(se);
        }
    }

    /**
     * Process the request.
     */
    private void service(final HttpServletRequest request,
                        final HttpServletResponse response)
	throws ServletException, IOException {
        try {
            if ((available > 0L) && (available < Long.MAX_VALUE)) {
                if (available > System.currentTimeMillis()) {
                    response.setDateHeader("Retry-After", available);
                    response.sendError
                        (HttpServletResponse.SC_SERVICE_UNAVAILABLE,
                         Localizer.getMessage("jsp.error.unavailable"));
                    return;
                }
                // Wait period has expired. Reset.
                available = 0;
            }
            if ( theServlet == null ) {
                synchronized ( this ) {
                    if ( compileException != null ) {
                        throw compileException;
                    }
                    if ( theServlet == null ) {
                        this.prepareServlet(request, response);
                    }
                }
            }
            if ( compileException != null ) {
                throw compileException;
            }

            // Service request
            if (theServlet instanceof SingleThreadModel) {
               // sync on the wrapper so that the freshness
               // of the page is determined right before servicing
               synchronized (this) {
                   theServlet.service(request, response);
                }
            } else {
                theServlet.service(request, response);
            }

        } catch (final UnavailableException ex) {
            String includeRequestUri = (String)
                request.getAttribute("javax.servlet.include.request_uri");
            if (includeRequestUri != null) {
                // This file was included. Throw an exception as
                // a response.sendError() will be ignored by the
                // servlet engine.
                throw ex;
            }
            int unavailableSeconds = ex.getUnavailableSeconds();
            if (unavailableSeconds <= 0) {
                unavailableSeconds = 60;        // Arbitrary default
            }
            available = System.currentTimeMillis() +
                (unavailableSeconds * 1000L);
            response.sendError
                (HttpServletResponse.SC_SERVICE_UNAVAILABLE,
                 ex.getMessage());
            return;
        }
    }

    /**
     * Destroy this wrapper
     * @param deleteGeneratedFiles Should generated files be deleted as well?
     */
    public void destroy(final boolean deleteGeneratedFiles) {
        if ( this.isTagFile ) {
            if ( log.isDebugEnabled() ) {
                log.debug("Destroying tagfile " + jspUri);
            }
            this.tagFileClass = null;
            if ( deleteGeneratedFiles ) {
                if ( log.isDebugEnabled() ) {
                    log.debug("Deleting generated files for tagfile " + jspUri);
                }
                this.ctxt.getRuntimeContext().getIOProvider().delete(this.getDependencyFilePath());
            }
        } else {
            if ( log.isDebugEnabled() ) {
                log.debug("Destroying servlet " + jspUri);
            }
            if (theServlet != null) {
                if ( deleteGeneratedFiles ) {
                    if ( log.isDebugEnabled() ) {
                        log.debug("Deleting generated files for servlet " + jspUri);
                    }
                    final String name;
                    if (isTagFile) {
                        name = this.ctxt.getTagInfo().getTagClassName();
                    } else {
                        name = this.ctxt.getServletPackageName() + "." + this.ctxt.getServletClassName();
                    }

                    final String path = ":/" + name.replace('.', '/') + ".class";
                    this.ctxt.getRuntimeContext().getIOProvider().delete(path);
                    this.ctxt.getRuntimeContext().getIOProvider().delete(this.getDependencyFilePath());
                    final org.apache.sling.scripting.jsp.jasper.compiler.Compiler c = this.ctxt.getCompiler();
                    if ( c != null ) {
                        c.removeGeneratedFiles();
                    }
                }
                theServlet.destroy();
                AnnotationProcessor annotationProcessor = (AnnotationProcessor) config.getServletContext().getAttribute(AnnotationProcessor.class.getName());
                if (annotationProcessor != null) {
                    try {
                        annotationProcessor.preDestroy(theServlet);
                    } catch (Exception e) {
                        // Log any exception, since it can't be passed along
                        log.error(Localizer.getMessage("jsp.error.file.not.found",
                               e.getMessage()), e);
                    }
                }
            }
        }
    }

    /**
     * <p>Attempts to construct a JasperException that contains helpful information
     * about what went wrong. Uses the JSP compiler system to translate the line
     * number in the generated servlet that originated the exception to a line
     * number in the JSP.  Then constructs an exception containing that
     * information, and a snippet of the JSP to help debugging.
     * Please see http://issues.apache.org/bugzilla/show_bug.cgi?id=37062 and
     * http://www.tfenne.com/jasper/ for more details.
     *</p>
     *
     * @param ex the exception that was the cause of the problem.
     * @throws ServletException with more detailed information
     */
    protected void handleJspException(final Exception ex)
            throws ServletException {
        final Exception jspEx = handleJspExceptionInternal(ex);
        if ( jspEx instanceof ServletException ) {
            throw (ServletException)jspEx;
        }
        throw (SlingException)jspEx;
    }

    /**
     * Returns only a ServletException or a SlingException
     */
    private Exception handleJspExceptionInternal(final Exception ex) {
        Throwable realException = ex;
        String exMessage = realException.getMessage();
        if (ex instanceof ServletException) {
            realException = ((ServletException) ex).getRootCause();
            // root cause might be null (eg. for a JasperException ex)
            if (realException == null) {
                realException = ex;
            } else {
                exMessage = ex.toString();
            }
        }

        // avoid nested ScriptEvaluationExceptions (eg. in nested jsp includes)
        while (realException instanceof ScriptEvaluationException) {
            realException = realException.getCause();
            exMessage = realException.getMessage();
        }

        try {
            // First identify the stack frame in the trace that represents the JSP
            StackTraceElement[] frames = realException.getStackTrace();
            StackTraceElement jspFrame = null;

            for (int i=0; i<frames.length; ++i) {
                if ( frames[i].getClassName().equals(this.ctxt.getClassName()) ) {
                    jspFrame = frames[i];
                    break;
                }
            }

            if (jspFrame == null) {
                // If we couldn't find a frame in the stack trace corresponding
                // to the generated servlet class, we can't really add anything
                if ( ex instanceof ServletException ) {
                    return ex;
                }
                return new SlingException(ex.getMessage(), ex);
            }
            int javaLineNumber = jspFrame.getLineNumber();
            JavacErrorDetail detail = ErrorDispatcher.createJavacError(
                    jspFrame.getMethodName(),
                    this.ctxt.getCompiler().getPageNodes(),
                    null,
                    javaLineNumber,
                    ctxt);

            // If the line number is less than one we couldn't find out
            // where in the JSP things went wrong
            int jspLineNumber = detail.getJspBeginLineNumber();
            if (jspLineNumber < 1) {
                if ( realException instanceof ServletException ) {
                    return (ServletException)realException;
                }
                return new SlingException(exMessage, realException);
            }

            if (options.getDisplaySourceFragment() && detail.getJspExtract() != null ) {
                return new SlingException(Localizer.getMessage
                        ("jsp.exception", detail.getJspFileName(),
                                "" + jspLineNumber) +
                        "\n\n" + detail.getJspExtract() +
                        "\n", realException);

            }
            return new SlingException(Localizer.getMessage
                    ("jsp.exception", detail.getJspFileName(),
                            "" + jspLineNumber), realException);
        } catch (final Exception je) {
            // If anything goes wrong, just revert to the original behaviour
            if (realException instanceof ServletException) {
                return (ServletException)realException;
            }
            return new SlingException(exMessage, realException);
        }
    }

    /**
     * Compare the dependencies.
     */
    private boolean equals(final List<String> oldDeps, final List<String> newDeps) {
        if ( oldDeps == null ) {
            if ( newDeps == null || newDeps.size() == 0 ) {
                return true;
            }
            return false;
        }
        if ( oldDeps.size() != newDeps.size() ) {
            return false;
        }
        final Iterator<String> i1 = oldDeps.iterator();
        final Iterator<String> i2 = newDeps.iterator();
        while ( i1.hasNext() ) {
            if ( !i1.next().equals(i2.next()) ) {
                return false;
            }
        }
        return true;
    }
}
