package org.apache.tomcat.maven.runner;
/*
 * 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 org.apache.catalina.Context;
import org.apache.catalina.Host;
import org.apache.catalina.connector.Connector;
import org.apache.catalina.core.StandardContext;
import org.apache.catalina.startup.Catalina;
import org.apache.catalina.startup.ContextConfig;
import org.apache.catalina.startup.Tomcat;
import org.apache.catalina.valves.AccessLogValve;
import org.apache.juli.ClassLoaderLogManager;
import org.apache.tomcat.util.ExceptionUtils;
import org.apache.tomcat.util.http.fileupload.FileUtils;

import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.net.URISyntaxException;
import java.net.URL;
import java.security.AccessController;
import java.security.PrivilegedAction;
import java.util.HashMap;
import java.util.Map;
import java.util.Properties;
import java.util.StringTokenizer;
import java.util.logging.LogManager;

/**
 * FIXME add junit for that but when https://issues.apache.org/bugzilla/show_bug.cgi?id=52028 fixed
 * Main class used to run the standalone wars in a Apache Tomcat instance.
 *
 * @author Olivier Lamy
 * @since 2.0
 */
public class Tomcat7Runner
{
    // true/false to use the server.xml located in the jar /conf/server.xml
    public static final String USE_SERVER_XML_KEY = "useServerXml";

    // contains war name wars=foo.war,bar.war
    public static final String WARS_KEY = "wars";

    public static final String ARCHIVE_GENERATION_TIMESTAMP_KEY = "generationTimestamp";

    public static final String ENABLE_NAMING_KEY = "enableNaming";

    public static final String ACCESS_LOG_VALVE_FORMAT_KEY = "accessLogValveFormat";

    public static final String CODE_SOURCE_CONTEXT_PATH = "codeSourceContextPath";

    /**
     * key of the property which contains http protocol : HTTP/1.1 or org.apache.coyote.http11.Http11NioProtocol
     */
    public static final String HTTP_PROTOCOL_KEY = "connectorhttpProtocol";

    /**
     * key for default http port defined in the plugin
     */
    public static final String HTTP_PORT_KEY = "httpPort";


    public int httpPort;

    public int httpsPort;

    public int ajpPort;

    public String serverXmlPath;

    public Properties runtimeProperties;

    public boolean resetExtract;

    public boolean debug = false;

    public String clientAuth = "false";

    public String keyAlias = null;

    public String httpProtocol;

    public String extractDirectory = ".extract";

    public File extractDirectoryFile;

    public String codeSourceContextPath = null;

    public File codeSourceWar = null;

    public String loggerName;

    Catalina container;

    Tomcat tomcat;

    String uriEncoding = "ISO-8859-1";

    /**
     * key = context of the webapp, value = war path on file system
     */
    Map<String, String> webappWarPerContext = new HashMap<String, String>();

    public Tomcat7Runner()
    {
        // no op
    }

    public void run()
        throws Exception
    {

        PasswordUtil.deobfuscateSystemProps();

        if ( loggerName != null && loggerName.length() > 0 )
        {
            installLogger( loggerName );
        }

        this.extractDirectoryFile = new File( this.extractDirectory );

        debugMessage( "use extractDirectory:" + extractDirectoryFile.getPath() );

        boolean archiveTimestampChanged = false;

        // compare timestamp stored during previous run if exists
        File timestampFile = new File( extractDirectoryFile, ".tomcat_executable_archive.timestamp" );

        Properties timestampProps = loadProperties( timestampFile );

        if ( timestampFile.exists() )
        {
            String timestampValue = timestampProps.getProperty( Tomcat7Runner.ARCHIVE_GENERATION_TIMESTAMP_KEY );
            if ( timestampValue != null )
            {
                long timestamp = Long.parseLong( timestampValue );
                archiveTimestampChanged =
                    Long.parseLong( runtimeProperties.getProperty( Tomcat7Runner.ARCHIVE_GENERATION_TIMESTAMP_KEY ) )
                        > timestamp;

                debugMessage( "read timestamp from file " + timestampValue + ", archiveTimestampChanged: "
                                  + archiveTimestampChanged );
            }

        }

        codeSourceContextPath = runtimeProperties.getProperty( CODE_SOURCE_CONTEXT_PATH );
        if ( codeSourceContextPath != null && !codeSourceContextPath.isEmpty() )
        {
            codeSourceWar = AccessController.doPrivileged( new PrivilegedAction<File>()
            {
                public File run()
                {
                    try
                    {
                        File src =
                            new File( Tomcat7Runner.class.getProtectionDomain().getCodeSource().getLocation().toURI() );
                        if ( src.getName().endsWith( ".war" ) )
                        {
                            return src;
                        }
                        else
                        {
                            debugMessage( "ERROR: Code source is not a war file, ignoring." );
                        }
                    }
                    catch ( URISyntaxException e )
                    {
                        debugMessage( "ERROR: Could not find code source. " + e.getMessage() );

                    }
                    return null;
                }
            } );
        }

        // do we have to extract content
        {
            if ( !extractDirectoryFile.exists() || resetExtract || archiveTimestampChanged )
            {
                extract();
                //if archiveTimestampChanged or timestamp file not exists store the last timestamp from the archive
                if ( archiveTimestampChanged || !timestampFile.exists() )
                {
                    timestampProps.put( Tomcat7Runner.ARCHIVE_GENERATION_TIMESTAMP_KEY, runtimeProperties.getProperty(
                        Tomcat7Runner.ARCHIVE_GENERATION_TIMESTAMP_KEY ) );
                    saveProperties( timestampProps, timestampFile );
                }
            }
            else
            {
                String wars = runtimeProperties.getProperty( WARS_KEY );
                populateWebAppWarPerContext( wars );
            }
        }

        // create tomcat various paths
        new File( extractDirectory, "conf" ).mkdirs();
        new File( extractDirectory, "logs" ).mkdirs();
        new File( extractDirectory, "webapps" ).mkdirs();
        new File( extractDirectory, "work" ).mkdirs();
        File tmpDir = new File( extractDirectory, "temp" );
        tmpDir.mkdirs();

        System.setProperty( "java.io.tmpdir", tmpDir.getAbsolutePath() );

        System.setProperty( "catalina.base", extractDirectoryFile.getAbsolutePath() );
        System.setProperty( "catalina.home", extractDirectoryFile.getAbsolutePath() );

        // start with a server.xml
        if ( serverXmlPath != null || useServerXml() )
        {
            container = new Catalina();
            container.setUseNaming( this.enableNaming() );
            if ( serverXmlPath != null && new File( serverXmlPath ).exists() )
            {
                container.setConfig( serverXmlPath );
            }
            else
            {
                container.setConfig( new File( extractDirectory, "conf/server.xml" ).getAbsolutePath() );
            }
            container.start();
        }
        else
        {
            tomcat = new Tomcat()
            {
                public Context addWebapp( Host host, String url, String name, String path )
                {

                    Context ctx = new StandardContext();
                    ctx.setName( name );
                    ctx.setPath( url );
                    ctx.setDocBase( path );

                    ContextConfig ctxCfg = new ContextConfig();
                    ctx.addLifecycleListener( ctxCfg );

                    ctxCfg.setDefaultWebXml( new File( extractDirectory, "conf/web.xml" ).getAbsolutePath() );

                    if ( host == null )
                    {
                        getHost().addChild( ctx );
                    }
                    else
                    {
                        host.addChild( ctx );
                    }

                    return ctx;
                }
            };

            if ( this.enableNaming() )
            {
                System.setProperty( "catalina.useNaming", "true" );
                tomcat.enableNaming();
            }

            tomcat.getHost().setAppBase( new File( extractDirectory, "webapps" ).getAbsolutePath() );

            String connectorHttpProtocol = runtimeProperties.getProperty( HTTP_PROTOCOL_KEY );

            if ( httpProtocol != null && httpProtocol.trim().length() > 0 )
            {
                connectorHttpProtocol = httpProtocol;
            }

            debugMessage( "use connectorHttpProtocol:" + connectorHttpProtocol );

            if ( httpPort > 0 )
            {
                Connector connector = new Connector( connectorHttpProtocol );
                connector.setPort( httpPort );

                if ( httpsPort > 0 )
                {
                    connector.setRedirectPort( httpsPort );
                }
                connector.setURIEncoding( uriEncoding );

                tomcat.getService().addConnector( connector );

                tomcat.setConnector( connector );
            }

            // add a default acces log valve
            AccessLogValve alv = new AccessLogValve();
            alv.setDirectory( new File( extractDirectory, "logs" ).getAbsolutePath() );
            alv.setPattern( runtimeProperties.getProperty( Tomcat7Runner.ACCESS_LOG_VALVE_FORMAT_KEY ) );
            tomcat.getHost().getPipeline().addValve( alv );

            // create https connector
            if ( httpsPort > 0 )
            {
                Connector httpsConnector = new Connector( connectorHttpProtocol );
                httpsConnector.setPort( httpsPort );
                httpsConnector.setSecure( true );
                httpsConnector.setProperty( "SSLEnabled", "true" );
                httpsConnector.setProperty( "sslProtocol", "TLS" );
                httpsConnector.setURIEncoding( uriEncoding );

                String keystoreFile = System.getProperty( "javax.net.ssl.keyStore" );
                String keystorePass = System.getProperty( "javax.net.ssl.keyStorePassword" );
                String keystoreType = System.getProperty( "javax.net.ssl.keyStoreType", "jks" );

                if ( keystoreFile != null )
                {
                    httpsConnector.setAttribute( "keystoreFile", keystoreFile );
                }
                if ( keystorePass != null )
                {
                    httpsConnector.setAttribute( "keystorePass", keystorePass );
                }
                httpsConnector.setAttribute( "keystoreType", keystoreType );

                String truststoreFile = System.getProperty( "javax.net.ssl.trustStore" );
                String truststorePass = System.getProperty( "javax.net.ssl.trustStorePassword" );
                String truststoreType = System.getProperty( "javax.net.ssl.trustStoreType", "jks" );
                if ( truststoreFile != null )
                {
                    httpsConnector.setAttribute( "truststoreFile", truststoreFile );
                }
                if ( truststorePass != null )
                {
                    httpsConnector.setAttribute( "truststorePass", truststorePass );
                }
                httpsConnector.setAttribute( "truststoreType", truststoreType );

                httpsConnector.setAttribute( "clientAuth", clientAuth );
                httpsConnector.setAttribute( "keyAlias", keyAlias );

                tomcat.getService().addConnector( httpsConnector );

                if ( httpPort <= 0 )
                {
                    tomcat.setConnector( httpsConnector );
                }
            }

            // create ajp connector
            if ( ajpPort > 0 )
            {
                Connector ajpConnector = new Connector( "org.apache.coyote.ajp.AjpProtocol" );
                ajpConnector.setPort( ajpPort );
                ajpConnector.setURIEncoding( uriEncoding );
                tomcat.getService().addConnector( ajpConnector );
            }

            // add webapps
            for ( Map.Entry<String, String> entry : this.webappWarPerContext.entrySet() )
            {
                String baseDir = null;
                Context context = null;
                if ( entry.getKey().equals( "/" ) )
                {
                    baseDir = new File( extractDirectory, "webapps/ROOT.war" ).getAbsolutePath();
                    context = tomcat.addWebapp( "", baseDir );
                }
                else
                {
                    baseDir = new File( extractDirectory, "webapps/" + entry.getValue() ).getAbsolutePath();
                    context = tomcat.addWebapp( entry.getKey(), baseDir );
                }

                URL contextFileUrl = getContextXml( baseDir );
                if ( contextFileUrl != null )
                {
                    context.setConfigFile( contextFileUrl );
                }
            }

            if ( codeSourceWar != null )
            {
                String baseDir = new File( extractDirectory, "webapps/" + codeSourceWar.getName() ).getAbsolutePath();
                Context context = tomcat.addWebapp( codeSourceContextPath, baseDir );
                URL contextFileUrl = getContextXml( baseDir );
                if ( contextFileUrl != null )
                {
                    context.setConfigFile( contextFileUrl );
                }
            }

            tomcat.start();

            Runtime.getRuntime().addShutdownHook( new TomcatShutdownHook() );

        }

        waitIndefinitely();

    }

    protected class TomcatShutdownHook
        extends Thread
    {

        protected TomcatShutdownHook()
        {
            // no op
        }

        @Override
        public void run()
        {
            try
            {
                Tomcat7Runner.this.stop();
            }
            catch ( Throwable ex )
            {
                ExceptionUtils.handleThrowable( ex );
                System.out.println( "fail to properly shutdown Tomcat:" + ex.getMessage() );
            }
            finally
            {
                // If JULI is used, shut JULI down *after* the server shuts down
                // so log messages aren't lost
                LogManager logManager = LogManager.getLogManager();
                if ( logManager instanceof ClassLoaderLogManager )
                {
                    ( (ClassLoaderLogManager) logManager ).shutdown();
                }
            }
        }
    }

    private URL getContextXml( String warPath )
        throws IOException
    {
        InputStream inputStream = null;
        try
        {
            String urlStr = "jar:file:" + warPath + "!/META-INF/context.xml";
            debugMessage( "search context.xml in url:'" + urlStr + "'" );
            URL url = new URL( urlStr );
            inputStream = url.openConnection().getInputStream();
            if ( inputStream != null )
            {
                return url;
            }
        }
        catch ( FileNotFoundException e )
        {
            return null;
        }
        finally
        {
            closeQuietly( inputStream );
        }
        return null;
    }

    private static void closeQuietly( InputStream inputStream )
    {
        if ( inputStream == null )
        {
            return;
        }
        try
        {
            inputStream.close();
        }
        catch ( IOException e )
        {
            // ignore exception here
        }
    }

    private void waitIndefinitely()
    {
        Object lock = new Object();

        synchronized ( lock )
        {
            try
            {
                lock.wait();
            }
            catch ( InterruptedException exception )
            {
                throw new Error( "InterruptedException on wait Indefinitely lock:" + exception.getMessage(),
                                 exception );
            }
        }
    }

    public void stop()
        throws Exception
    {
        if ( container != null )
        {
            container.stop();
        }
        if ( tomcat != null )
        {
            tomcat.stop();
        }
    }

    protected void extract()
        throws Exception
    {

        if ( extractDirectoryFile.exists() )
        {
            debugMessage( "delete extractDirectory:" + extractDirectoryFile.getAbsolutePath() );
            FileUtils.deleteDirectory( extractDirectoryFile );
        }

        if ( !this.extractDirectoryFile.exists() )
        {
            boolean created = this.extractDirectoryFile.mkdirs();
            if ( !created )
            {
                throw new Exception( "FATAL: impossible to create directory:" + this.extractDirectoryFile.getPath() );
            }
        }

        // ensure webapp dir is here
        boolean created = new File( extractDirectory, "webapps" ).mkdirs();
        if ( !created )
        {
            throw new Exception(
                "FATAL: impossible to create directory:" + this.extractDirectoryFile.getPath() + "/webapps" );

        }

        String wars = runtimeProperties.getProperty( WARS_KEY );
        populateWebAppWarPerContext( wars );

        for ( Map.Entry<String, String> entry : webappWarPerContext.entrySet() )
        {
            debugMessage( "webappWarPerContext entry key/value: " + entry.getKey() + "/" + entry.getValue() );
            InputStream inputStream = null;
            try
            {
                File expandFile = null;
                inputStream = Thread.currentThread().getContextClassLoader().getResourceAsStream( entry.getValue() );
                if ( !useServerXml() )
                {
                    if ( entry.getKey().equals( "/" ) )
                    {
                        expandFile = new File( extractDirectory, "webapps/ROOT.war" );
                    }
                    else
                    {
                        expandFile = new File( extractDirectory, "webapps/" + entry.getValue() );
                    }
                }
                else
                {
                    expandFile = new File( extractDirectory, "webapps/" + entry.getValue() );
                }

                debugMessage( "expand to file:" + expandFile.getPath() );

                // MTOMCAT-211 ensure parent directories created

                if ( !expandFile.getParentFile().mkdirs() )
                {
                    throw new Exception( "FATAL: impossible to create directories:" + expandFile.getParentFile() );
                }

                expand( inputStream, expandFile );

            }
            finally
            {
                if ( inputStream != null )
                {
                    inputStream.close();
                }
            }
        }

        //Copy code source to webapps folder
        if ( codeSourceWar != null )
        {
            FileInputStream inputStream = null;
            try
            {
                File expandFile = new File( extractDirectory, "webapps/" + codeSourceContextPath + ".war" );
                inputStream = new FileInputStream( codeSourceWar );
                debugMessage( "move code source to file:" + expandFile.getPath() );
                expand( inputStream, expandFile );
            }
            finally
            {
                if ( inputStream != null )
                {
                    inputStream.close();
                }
            }
        }

        // expand tomcat configuration files if there
        expandConfigurationFile( "catalina.properties", extractDirectoryFile );
        expandConfigurationFile( "logging.properties", extractDirectoryFile );
        expandConfigurationFile( "tomcat-users.xml", extractDirectoryFile );
        expandConfigurationFile( "catalina.policy", extractDirectoryFile );
        expandConfigurationFile( "context.xml", extractDirectoryFile );
        expandConfigurationFile( "server.xml", extractDirectoryFile );
        expandConfigurationFile( "web.xml", extractDirectoryFile );

    }

    private static void expandConfigurationFile( String fileName, File extractDirectory )
        throws Exception
    {
        InputStream inputStream = null;
        try
        {
            inputStream = Thread.currentThread().getContextClassLoader().getResourceAsStream( "conf/" + fileName );
            if ( inputStream != null )
            {
                File confDirectory = new File( extractDirectory, "conf" );
                if ( !confDirectory.exists() )
                {
                    confDirectory.mkdirs();
                }
                expand( inputStream, new File( confDirectory, fileName ) );
            }
        }
        finally
        {
            if ( inputStream != null )
            {
                inputStream.close();
            }
        }

    }

    /**
     * @param warsValue we can value in format: wars=foo.war|contextpath;bar.war  ( |contextpath is optionnal if empty use the war name)
     *                  so here we return war file name and populate webappWarPerContext
     */
    private void populateWebAppWarPerContext( String warsValue )
    {
        if ( warsValue == null )
        {
            return;
        }

        StringTokenizer st = new StringTokenizer( warsValue, ";" );
        while ( st.hasMoreTokens() )
        {
            String warValue = st.nextToken();
            debugMessage( "populateWebAppWarPerContext warValue:" + warValue );
            String warFileName = "";
            String contextValue = "";
            int separatorIndex = warValue.indexOf( "|" );
            if ( separatorIndex >= 0 )
            {
                warFileName = warValue.substring( 0, separatorIndex );
                contextValue = warValue.substring( separatorIndex + 1, warValue.length() );

            }
            else
            {
                warFileName = contextValue;
            }
            debugMessage( "populateWebAppWarPerContext contextValue/warFileName:" + contextValue + "/" + warFileName );
            this.webappWarPerContext.put( contextValue, warFileName );
        }
    }


    /**
     * Expand the specified input stream into the specified file.
     *
     * @param input InputStream to be copied
     * @param file  The file to be created
     * @throws java.io.IOException if an input/output error occurs
     */
    private static void expand( InputStream input, File file )
        throws IOException
    {
        BufferedOutputStream output = null;
        try
        {
            output = new BufferedOutputStream( new FileOutputStream( file ) );
            byte buffer[] = new byte[2048];
            while ( true )
            {
                int n = input.read( buffer );
                if ( n <= 0 )
                {
                    break;
                }
                output.write( buffer, 0, n );
            }
        }
        finally
        {
            if ( output != null )
            {
                try
                {
                    output.close();
                }
                catch ( IOException e )
                {
                    // Ignore
                }
            }
        }
    }

    public boolean useServerXml()
    {
        return Boolean.parseBoolean( runtimeProperties.getProperty( USE_SERVER_XML_KEY, Boolean.FALSE.toString() ) );
    }


    public void debugMessage( String message )
    {
        if ( debug )
        {
            System.out.println( message );
        }
    }


    public boolean enableNaming()
    {
        return Boolean.parseBoolean( runtimeProperties.getProperty( ENABLE_NAMING_KEY, Boolean.FALSE.toString() ) );
    }

    private void installLogger( String loggerName )
        throws SecurityException, NoSuchMethodException, IllegalArgumentException, IllegalAccessException,
        InvocationTargetException
    {
        if ( "slf4j".equals( loggerName ) )
        {

            try
            {
                // Check class is available

                //final Class<?> clazz = Class.forName( "org.slf4j.bridge.SLF4JBridgeHandler" );
                final Class<?> clazz =
                    Thread.currentThread().getContextClassLoader().loadClass( "org.slf4j.bridge.SLF4JBridgeHandler" );

                // Remove all JUL handlers
                java.util.logging.LogManager.getLogManager().reset();

                // Install slf4j bridge handler
                final Method method = clazz.getMethod( "install", null );
                method.invoke( null );
            }
            catch ( ClassNotFoundException e )
            {
                System.out.println( "WARNING: issue configuring slf4j jul bridge, skip it" );
            }
        }
        else
        {
            System.out.println( "WARNING: loggerName " + loggerName + " not supported, skip it" );
        }
    }

    private Properties loadProperties( File file )
        throws FileNotFoundException, IOException
    {
        Properties properties = new Properties();
        if ( file.exists() )
        {

            FileInputStream fileInputStream = new FileInputStream( file );
            try
            {
                properties.load( fileInputStream );
            }
            finally
            {
                fileInputStream.close();
            }

        }
        return properties;
    }

    private void saveProperties( Properties properties, File file )
        throws FileNotFoundException, IOException
    {
        FileOutputStream fileOutputStream = new FileOutputStream( file );
        try
        {
            properties.store( fileOutputStream, "Timestamp file for executable war/jar" );
        }
        finally
        {
            fileOutputStream.close();
        }
    }
}
