/*
 * 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.lucene.gradle;

import java.io.BufferedReader;
import java.io.Closeable;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;

import org.gradle.api.internal.tasks.testing.logging.FullExceptionFormatter;
import org.gradle.api.internal.tasks.testing.logging.TestExceptionFormatter;
import org.gradle.api.logging.Logger;
import org.gradle.api.logging.Logging;
import org.gradle.api.tasks.testing.TestDescriptor;
import org.gradle.api.tasks.testing.TestListener;
import org.gradle.api.tasks.testing.TestOutputEvent;
import org.gradle.api.tasks.testing.TestOutputListener;
import org.gradle.api.tasks.testing.TestResult;
import org.gradle.api.tasks.testing.logging.TestLogging;

/**
 * An error reporting listener that queues test output streams and displays them
 * on failure.
 * <p>
 * Heavily inspired by Elasticsearch's ErrorReportingTestListener (ASL 2.0 licensed).
 */
public class ErrorReportingTestListener implements TestOutputListener, TestListener {
   private static final Logger LOGGER = Logging.getLogger(ErrorReportingTestListener.class);

   private final TestExceptionFormatter formatter;
   private final Map<TestKey, OutputHandler> outputHandlers = new ConcurrentHashMap<>();
   private final Path spillDir;
   private final Path outputsDir;
   private final boolean verboseMode;

   public ErrorReportingTestListener(TestLogging testLogging, Path spillDir, Path outputsDir, boolean verboseMode) {
      this.formatter = new FullExceptionFormatter(testLogging);
      this.spillDir = spillDir;
      this.outputsDir = outputsDir;
      this.verboseMode = verboseMode;
   }

   @Override
   public void onOutput(TestDescriptor testDescriptor, TestOutputEvent outputEvent) {
      handlerFor(testDescriptor).write(outputEvent);
   }

   @Override
   public void beforeSuite(TestDescriptor suite) {
      // noop.
   }

   @Override
   public void beforeTest(TestDescriptor testDescriptor) {
      // Noop.
   }

   @Override
   public void afterSuite(final TestDescriptor suite, TestResult result) {
      if (suite.getParent() == null || suite.getName().startsWith("Gradle")) {
         return;
      }

      TestKey key = TestKey.of(suite);
      try {
         OutputHandler outputHandler = outputHandlers.get(key);
         if (outputHandler != null) {
            long length = outputHandler.length();
            if (length > 1024 * 1024 * 10) {
               LOGGER.warn(String.format(Locale.ROOT, "WARNING: Test %s wrote %,d bytes of output.",
                   suite.getName(),
                   length));
            }
         }

         boolean echoOutput = Objects.equals(result.getResultType(), TestResult.ResultType.FAILURE);
         boolean dumpOutput = echoOutput;

         // If the test suite failed, report output.
         if (dumpOutput || echoOutput) {
            Files.createDirectories(outputsDir);
            Path outputLog = outputsDir.resolve(getOutputLogName(suite));

            // Save the output of a failing test to disk.
            try (Writer w = Files.newBufferedWriter(outputLog, StandardCharsets.UTF_8)) {
               if (outputHandler != null) {
                  outputHandler.copyTo(w);
               }
            }

            if (echoOutput && !verboseMode) {
               synchronized (this) {
                  System.out.println("");
                  System.out.println(suite.getClassName() + " > test suite's output saved to " + outputLog + ", copied below:");
                  try (BufferedReader reader = Files.newBufferedReader(outputLog, StandardCharsets.UTF_8)) {
                     char[] buf = new char[1024];
                     int len;
                     while ((len = reader.read(buf)) >= 0) {
                        System.out.print(new String(buf, 0, len));
                     }
                     System.out.println();
                  }
               }
            }
         }
      } catch (IOException e) {
         throw new UncheckedIOException(e);
      } finally {
         OutputHandler handler = outputHandlers.remove(key);
         if (handler != null) {
            try {
               handler.close();
            } catch (IOException e) {
               LOGGER.error("Failed to close output handler for: " + key, e);
            }
         }
      }
   }

   private static Pattern SANITIZE = Pattern.compile("[^a-zA-Z .\\-_0-9]+");

   public static String getOutputLogName(TestDescriptor suite) {
      return SANITIZE.matcher("OUTPUT-" + suite.getName() + ".txt").replaceAll("_");
   }

   @Override
   public void afterTest(TestDescriptor testDescriptor, TestResult result) {
      // Include test failure exception stacktrace(s) in test output log.
      if (result.getResultType() == TestResult.ResultType.FAILURE) {
         if (result.getExceptions().size() > 0) {
            String message = formatter.format(testDescriptor, result.getExceptions());
            handlerFor(testDescriptor).write(message);
         }
      }
   }

   private OutputHandler handlerFor(TestDescriptor descriptor) {
      // Attach output of leaves (individual tests) to their parent.
      if (!descriptor.isComposite()) {
         descriptor = descriptor.getParent();
      }
      return outputHandlers.computeIfAbsent(TestKey.of(descriptor), (key) -> new OutputHandler());
   }

   public static class TestKey {
      private final String key;

      private TestKey(String key) {
         this.key = key;
      }

      public static TestKey of(TestDescriptor d) {
         StringBuilder key = new StringBuilder();
         key.append(d.getClassName());
         key.append("::");
         key.append(d.getName());
         key.append("::");
         key.append(d.getParent() == null ? "-" : d.getParent().toString());
         return new TestKey(key.toString());
      }

      @Override
      public boolean equals(Object o) {
         return o != null &&
             o.getClass() == this.getClass() &&
             Objects.equals(((TestKey) o).key, key);
      }

      @Override
      public int hashCode() {
         return key.hashCode();
      }

       @Override
       public String toString() {
           return key;
       }
   }

   private class OutputHandler implements Closeable {
      // Max single-line buffer before automatic wrap occurs.
      private static final int MAX_LINE_WIDTH = 1024 * 4;

      private final SpillWriter buffer;

      // internal stream.
      private final PrefixedWriter sint;
      // stdout
      private final PrefixedWriter sout;
      // stderr
      private final PrefixedWriter serr;

      // last used stream (so that we can flush it properly and prefixes are not screwed up).
      private PrefixedWriter last;

      public OutputHandler() {
         buffer = new SpillWriter(() -> {
            try {
               return Files.createTempFile(spillDir, "spill-", ".tmp");
            } catch (IOException e) {
               throw new UncheckedIOException(e);
            }
         });

         Writer sink = buffer;
         if (verboseMode) {
            sink = new StdOutTeeWriter(buffer);
         }

         sint = new PrefixedWriter("   > ", sink, MAX_LINE_WIDTH);
         sout = new PrefixedWriter("  1> ", sink, MAX_LINE_WIDTH);
         serr = new PrefixedWriter("  2> ", sink, MAX_LINE_WIDTH);
         last = sint;
      }

      public void write(TestOutputEvent event) {
         write((event.getDestination() == TestOutputEvent.Destination.StdOut ? sout : serr), event.getMessage());
      }

      public void write(String message) {
         write(sint, message);
      }

      public long length() throws IOException {
         return buffer.length();
      }

      private void write(PrefixedWriter out, String message) {
         try {
            if (out != last) {
               last.completeLine();
               last = out;
            }
            out.write(message);
         } catch (IOException e) {
            throw new UncheckedIOException("Unable to write to test output.", e);
         }
      }

      public void copyTo(Writer out) throws IOException {
         flush();
         buffer.copyTo(out);
      }

      public void flush() throws IOException {
         sout.completeLine();
         serr.completeLine();
         buffer.flush();
      }

      @Override
      public void close() throws IOException {
         buffer.close();
      }
   }
}
