package org.apache.maven.plugin.surefire.booterclient.output;

/*
 * 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.maven.plugin.surefire.booterclient.lazytestprovider.NotifiableTestStream;
import org.apache.maven.plugin.surefire.log.api.ConsoleLogger;
import org.apache.maven.plugin.surefire.report.DefaultReporterFactory;
import org.apache.maven.shared.utils.cli.StreamConsumer;
import org.apache.maven.surefire.report.ConsoleOutputReceiver;
import org.apache.maven.surefire.report.ReportEntry;
import org.apache.maven.surefire.report.RunListener;
import org.apache.maven.surefire.report.StackTraceWriter;
import org.apache.maven.surefire.report.TestSetReportEntry;

import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.StringReader;
import java.nio.ByteBuffer;
import java.util.Collections;
import java.util.Map;
import java.util.Queue;
import java.util.Set;
import java.util.StringTokenizer;
import java.util.TreeSet;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;

import static java.lang.Integer.decode;
import static java.lang.System.currentTimeMillis;
import static java.util.Collections.unmodifiableMap;
import static org.apache.maven.surefire.booter.ForkingRunListener.BOOTERCODE_BYE;
import static org.apache.maven.surefire.booter.ForkingRunListener.BOOTERCODE_CONSOLE;
import static org.apache.maven.surefire.booter.ForkingRunListener.BOOTERCODE_DEBUG;
import static org.apache.maven.surefire.booter.ForkingRunListener.BOOTERCODE_ERROR;
import static org.apache.maven.surefire.booter.ForkingRunListener.BOOTERCODE_NEXT_TEST;
import static org.apache.maven.surefire.booter.ForkingRunListener.BOOTERCODE_STDERR;
import static org.apache.maven.surefire.booter.ForkingRunListener.BOOTERCODE_STDOUT;
import static org.apache.maven.surefire.booter.ForkingRunListener.BOOTERCODE_STOP_ON_NEXT_TEST;
import static org.apache.maven.surefire.booter.ForkingRunListener.BOOTERCODE_SYSPROPS;
import static org.apache.maven.surefire.booter.ForkingRunListener.BOOTERCODE_TESTSET_COMPLETED;
import static org.apache.maven.surefire.booter.ForkingRunListener.BOOTERCODE_TESTSET_STARTING;
import static org.apache.maven.surefire.booter.ForkingRunListener.BOOTERCODE_TEST_ASSUMPTIONFAILURE;
import static org.apache.maven.surefire.booter.ForkingRunListener.BOOTERCODE_TEST_ERROR;
import static org.apache.maven.surefire.booter.ForkingRunListener.BOOTERCODE_TEST_FAILED;
import static org.apache.maven.surefire.booter.ForkingRunListener.BOOTERCODE_TEST_SKIPPED;
import static org.apache.maven.surefire.booter.ForkingRunListener.BOOTERCODE_TEST_STARTING;
import static org.apache.maven.surefire.booter.ForkingRunListener.BOOTERCODE_TEST_SUCCEEDED;
import static org.apache.maven.surefire.booter.ForkingRunListener.BOOTERCODE_WARNING;
import static org.apache.maven.surefire.booter.Shutdown.KILL;
import static org.apache.maven.surefire.report.CategorizedReportEntry.reportEntry;
import static org.apache.maven.surefire.util.internal.StringUtils.isNotBlank;
import static org.apache.maven.surefire.util.internal.StringUtils.unescapeBytes;
import static org.apache.maven.surefire.util.internal.StringUtils.unescapeString;

// todo move to the same package with ForkStarter

/**
 * Knows how to reconstruct *all* the state transmitted over stdout by the forked process.
 *
 * @author Kristian Rosenvold
 */
public class ForkClient
     implements StreamConsumer
{
    private static final String PRINTABLE_JVM_NATIVE_STREAM = "Listening for transport dt_socket at address:";
    private static final long START_TIME_ZERO = 0L;
    private static final long START_TIME_NEGATIVE_TIMEOUT = -1L;

    private final DefaultReporterFactory defaultReporterFactory;

    private final Map<String, String> testVmSystemProperties = new ConcurrentHashMap<>();

    private final NotifiableTestStream notifiableTestStream;

    private final Queue<String> testsInProgress = new ConcurrentLinkedQueue<>();

    /**
     * {@code testSetStartedAt} is set to non-zero after received
     * {@link org.apache.maven.surefire.booter.ForkingRunListener#BOOTERCODE_TESTSET_STARTING test-set}.
     */
    private final AtomicLong testSetStartedAt = new AtomicLong( START_TIME_ZERO );

    private final ConsoleLogger log;

    /**
     * prevents from printing same warning
     */
    private final AtomicBoolean printedErrorStream;

    private final int forkNumber;

    /**
     * Used by single Thread started by {@link ThreadedStreamConsumer} and therefore does not need to be volatile.
     */
    private RunListener testSetReporter;

    /**
     * Written by one Thread and read by another: Main Thread and ForkStarter's Thread.
     */
    private volatile boolean saidGoodBye;

    private volatile StackTraceWriter errorInFork;

    public ForkClient( DefaultReporterFactory defaultReporterFactory, NotifiableTestStream notifiableTestStream,
                       ConsoleLogger log, AtomicBoolean printedErrorStream, int forkNumber )
    {
        this.defaultReporterFactory = defaultReporterFactory;
        this.notifiableTestStream = notifiableTestStream;
        this.log = log;
        this.printedErrorStream = printedErrorStream;
        this.forkNumber = forkNumber;
    }

    protected void stopOnNextTest()
    {
    }

    public void kill()
    {
        if ( !saidGoodBye )
        {
            notifiableTestStream.shutdown( KILL );
        }
    }

    /**
     * Called in concurrent Thread.
     * Will shutdown if timeout was reached.
     *
     * @param currentTimeMillis    current time in millis seconds
     * @param forkedProcessTimeoutInSeconds timeout in seconds given by MOJO
     */
    public final void tryToTimeout( long currentTimeMillis, int forkedProcessTimeoutInSeconds )
    {
        if ( forkedProcessTimeoutInSeconds > 0 )
        {
            final long forkedProcessTimeoutInMillis = 1000 * forkedProcessTimeoutInSeconds;
            final long startedAt = testSetStartedAt.get();
            if ( startedAt > START_TIME_ZERO && currentTimeMillis - startedAt >= forkedProcessTimeoutInMillis )
            {
                testSetStartedAt.set( START_TIME_NEGATIVE_TIMEOUT );
                notifiableTestStream.shutdown( KILL );
            }
        }
    }

    public final DefaultReporterFactory getDefaultReporterFactory()
    {
        return defaultReporterFactory;
    }

    @Override
    public final void consumeLine( String s )
    {
        if ( isNotBlank( s ) )
        {
            processLine( s );
        }
    }

    private void setCurrentStartTime()
    {
        if ( testSetStartedAt.get() == START_TIME_ZERO ) // JIT can optimize <= no JNI call
        {
            // Not necessary to call JNI library library #currentTimeMillis
            // which may waste 10 - 30 machine cycles in callback. Callbacks should be fast.
            testSetStartedAt.compareAndSet( START_TIME_ZERO, currentTimeMillis() );
        }
    }

    public final boolean hadTimeout()
    {
        return testSetStartedAt.get() == START_TIME_NEGATIVE_TIMEOUT;
    }

    private RunListener getTestSetReporter()
    {
        if ( testSetReporter == null )
        {
            testSetReporter = defaultReporterFactory.createReporter();
        }
        return testSetReporter;
    }

    private void processLine( String event )
    {
        final OperationalData op;
        try
        {
            op = new OperationalData( event );
        }
        catch ( RuntimeException e )
        {
            logStreamWarning( e, event );
            return;
        }
        final String remaining = op.getData();
        switch ( op.getOperationId() )
        {
            case BOOTERCODE_TESTSET_STARTING:
                getTestSetReporter().testSetStarting( createReportEntry( remaining ) );
                setCurrentStartTime();
                break;
            case BOOTERCODE_TESTSET_COMPLETED:
                testsInProgress.clear();

                getTestSetReporter().testSetCompleted( createReportEntry( remaining, testVmSystemProperties ) );
                break;
            case BOOTERCODE_TEST_STARTING:
                ReportEntry reportEntry = createReportEntry( remaining );
                testsInProgress.offer( reportEntry.getSourceName() );

                getTestSetReporter().testStarting( createReportEntry( remaining ) );
                break;
            case BOOTERCODE_TEST_SUCCEEDED:
                reportEntry = createReportEntry( remaining );
                testsInProgress.remove( reportEntry.getSourceName() );

                getTestSetReporter().testSucceeded( createReportEntry( remaining ) );
                break;
            case BOOTERCODE_TEST_FAILED:
                reportEntry = createReportEntry( remaining );
                testsInProgress.remove( reportEntry.getSourceName() );

                getTestSetReporter().testFailed( createReportEntry( remaining ) );
                break;
            case BOOTERCODE_TEST_SKIPPED:
                reportEntry = createReportEntry( remaining );
                testsInProgress.remove( reportEntry.getSourceName() );

                getTestSetReporter().testSkipped( createReportEntry( remaining ) );
                break;
            case BOOTERCODE_TEST_ERROR:
                reportEntry = createReportEntry( remaining );
                testsInProgress.remove( reportEntry.getSourceName() );

                getTestSetReporter().testError( createReportEntry( remaining ) );
                break;
            case BOOTERCODE_TEST_ASSUMPTIONFAILURE:
                reportEntry = createReportEntry( remaining );
                testsInProgress.remove( reportEntry.getSourceName() );

                getTestSetReporter().testAssumptionFailure( createReportEntry( remaining ) );
                break;
            case BOOTERCODE_SYSPROPS:
                int keyEnd = remaining.indexOf( "," );
                StringBuilder key = new StringBuilder();
                StringBuilder value = new StringBuilder();
                unescapeString( key, remaining.substring( 0, keyEnd ) );
                unescapeString( value, remaining.substring( keyEnd + 1 ) );
                testVmSystemProperties.put( key.toString(), value.toString() );
                break;
            case BOOTERCODE_STDOUT:
                writeTestOutput( remaining, true );
                break;
            case BOOTERCODE_STDERR:
                writeTestOutput( remaining, false );
                break;
            case BOOTERCODE_CONSOLE:
                getOrCreateConsoleLogger()
                        .info( createConsoleMessage( remaining ) );
                break;
            case BOOTERCODE_NEXT_TEST:
                notifiableTestStream.provideNewTest();
                break;
            case BOOTERCODE_ERROR:
                errorInFork = deserializeStackTraceWriter( new StringTokenizer( remaining, "," ) );
                break;
            case BOOTERCODE_BYE:
                saidGoodBye = true;
                notifiableTestStream.acknowledgeByeEventReceived();
                break;
            case BOOTERCODE_STOP_ON_NEXT_TEST:
                stopOnNextTest();
                break;
            case BOOTERCODE_DEBUG:
                getOrCreateConsoleLogger()
                        .debug( createConsoleMessage( remaining ) );
                break;
            case BOOTERCODE_WARNING:
                getOrCreateConsoleLogger()
                        .warning( createConsoleMessage( remaining ) );
                break;
            default:
                logStreamWarning( event );
        }
    }

    private void logStreamWarning( String event )
    {
        logStreamWarning( null, event );
    }

    private void logStreamWarning( Throwable e, String event )
    {
        if ( event == null || !event.contains( PRINTABLE_JVM_NATIVE_STREAM ) )
        {
            String msg = "Corrupted STDOUT by directly writing to native stream in forked JVM " + forkNumber + ".";

            InPluginProcessDumpSingleton util = InPluginProcessDumpSingleton.getSingleton();
            File reportsDir = defaultReporterFactory.getReportsDirectory();
            File dump =
                    e == null
                    ? util.dumpStreamText( msg + " Stream '" + event + "'.", reportsDir, forkNumber )
                    : util.dumpStreamException( e, msg + " Stream '" + event + "'.", reportsDir, forkNumber );

            if ( printedErrorStream.compareAndSet( false, true ) )
            {
                log.warning( msg + " See FAQ web page and the dump file " + dump.getAbsolutePath() );
            }

            if ( log.isDebugEnabled() && event != null )
            {
                log.debug( event );
            }
        }
        else
        {
            if ( log.isDebugEnabled() )
            {
                log.debug( event );
            }
            else if ( log.isInfoEnabled() )
            {
                log.info( event );
            }
            else
            {
                // In case of debugging forked JVM, see PRINTABLE_JVM_NATIVE_STREAM.
                System.out.println( event );
            }
        }
    }

    private void writeTestOutput( String remaining, boolean isStdout )
    {
        int csNameEnd = remaining.indexOf( ',' );
        String charsetName = remaining.substring( 0, csNameEnd );
        String byteEncoded = remaining.substring( csNameEnd + 1 );
        ByteBuffer unescaped = unescapeBytes( byteEncoded, charsetName );

        if ( unescaped.hasArray() )
        {
            byte[] convertedBytes = unescaped.array();
            getOrCreateConsoleOutputReceiver()
                .writeTestOutput( convertedBytes, unescaped.position(), unescaped.remaining(), isStdout );
        }
        else
        {
            byte[] convertedBytes = new byte[unescaped.remaining()];
            unescaped.get( convertedBytes, 0, unescaped.remaining() );
            getOrCreateConsoleOutputReceiver()
                .writeTestOutput( convertedBytes, 0, convertedBytes.length, isStdout );
        }
    }

    public final void consumeMultiLineContent( String s )
            throws IOException
    {
        BufferedReader stringReader = new BufferedReader( new StringReader( s ) );
        for ( String s1 = stringReader.readLine(); s1 != null; s1 = stringReader.readLine() )
        {
            consumeLine( s1 );
        }
    }

    private String createConsoleMessage( String remaining )
    {
        return unescape( remaining );
    }

    private TestSetReportEntry createReportEntry( String untokenized )
    {
        return createReportEntry( untokenized, Collections.<String, String>emptyMap() );
    }

    private TestSetReportEntry createReportEntry( String untokenized, Map<String, String> systemProperties )
    {
        StringTokenizer tokens = new StringTokenizer( untokenized, "," );
        try
        {
            String source = nullableCsv( tokens.nextToken() );
            String name = nullableCsv( tokens.nextToken() );
            String group = nullableCsv( tokens.nextToken() );
            String message = nullableCsv( tokens.nextToken() );
            String elapsedStr = tokens.nextToken();
            Integer elapsed = "null".equals( elapsedStr ) ? null : decode( elapsedStr );
            final StackTraceWriter stackTraceWriter =
                    tokens.hasMoreTokens() ? deserializeStackTraceWriter( tokens ) : null;

            return reportEntry( source, name, group, stackTraceWriter, elapsed, message, systemProperties );
        }
        catch ( RuntimeException e )
        {
            throw new RuntimeException( untokenized, e );
        }
    }

    private StackTraceWriter deserializeStackTraceWriter( StringTokenizer tokens )
    {
        String stackTraceMessage = nullableCsv( tokens.nextToken() );
        String smartStackTrace = nullableCsv( tokens.nextToken() );
        String stackTrace = tokens.hasMoreTokens() ? nullableCsv( tokens.nextToken() ) : null;
        boolean hasTrace = stackTrace != null;
        return hasTrace ? new DeserializedStacktraceWriter( stackTraceMessage, smartStackTrace, stackTrace ) : null;
    }

    private String nullableCsv( String source )
    {
        return "null".equals( source ) ? null : unescape( source );
    }

    private String unescape( String source )
    {
        StringBuilder stringBuffer = new StringBuilder( source.length() );
        unescapeString( stringBuffer, source );
        return stringBuffer.toString();
    }

    public final Map<String, String> getTestVmSystemProperties()
    {
        return unmodifiableMap( testVmSystemProperties );
    }

    /**
     * Used when getting reporters on the plugin side of a fork.
     * Used by testing purposes only. May not be volatile variable.
     *
     * @return A mock provider reporter
     */
    public final RunListener getReporter()
    {
        return getTestSetReporter();
    }

    private ConsoleOutputReceiver getOrCreateConsoleOutputReceiver()
    {
        return (ConsoleOutputReceiver) getTestSetReporter();
    }

    private ConsoleLogger getOrCreateConsoleLogger()
    {
        return (ConsoleLogger) getTestSetReporter();
    }

    public void close( boolean hadTimeout )
    {
        // no op
    }

    public final boolean isSaidGoodBye()
    {
        return saidGoodBye;
    }

    public final StackTraceWriter getErrorInFork()
    {
        return errorInFork;
    }

    public final boolean isErrorInFork()
    {
        return errorInFork != null;
    }

    public Set<String> testsInProgress()
    {
        return new TreeSet<>( testsInProgress );
    }

    public boolean hasTestsInProgress()
    {
        return !testsInProgress.isEmpty();
    }

    private static final class OperationalData
    {
        private final byte operationId;
        private final String data;

        OperationalData( String event )
        {
            operationId = (byte) event.charAt( 0 );
            int comma = event.indexOf( ",", 3 );
            if ( comma < 0 )
            {
                throw new IllegalArgumentException( "Stream stdin corrupted. Expected comma after third character "
                                                            + "in command '" + event + "'." );
            }
            int rest = event.indexOf( ",", comma );
            data = event.substring( rest + 1 );
        }

        byte getOperationId()
        {
            return operationId;
        }

        String getData()
        {
            return data;
        }
    }
}
