// Copyright 2014 The Bazel Authors. All rights reserved.
//
// Licensed 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 com.google.devtools.build.lib.runtime;

import com.google.common.base.Joiner;
import com.google.common.base.Strings;
import com.google.devtools.build.lib.rules.test.TestLogHelper;
import com.google.devtools.build.lib.rules.test.TestStrategy.TestOutputFormat;
import com.google.devtools.build.lib.util.LoggingUtil;
import com.google.devtools.build.lib.util.io.AnsiTerminalPrinter;
import com.google.devtools.build.lib.util.io.AnsiTerminalPrinter.Mode;
import com.google.devtools.build.lib.vfs.Path;
import com.google.devtools.build.lib.view.test.TestStatus.BlazeTestStatus;
import com.google.devtools.build.lib.view.test.TestStatus.FailedTestCasesStatus;
import com.google.devtools.build.lib.view.test.TestStatus.TestCase;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level;

/**
 * Print test statistics in human readable form.
 */
public class TestSummaryPrinter {

  /**
   * Print the cached test log to the given printer.
   */
  public static void printCachedOutput(TestSummary summary,
      TestOutputFormat testOutput,
      AnsiTerminalPrinter printer) {

    String testName = summary.getTarget().getLabel().toString();
    List<String> allLogs = new ArrayList<>();
    for (Path path : summary.getFailedLogs()) {
      allLogs.add(path.getPathString());
    }
    for (Path path : summary.getPassedLogs()) {
      allLogs.add(path.getPathString());
    }
    printer.printLn("" + TestSummary.getStatusMode(summary.getStatus()) + summary.getStatus() + ": "
        + Mode.DEFAULT + testName + " (see " + Joiner.on(' ').join(allLogs) + ")");
    printer.printLn(Mode.INFO + "INFO: " + Mode.DEFAULT + "From Testing " + testName);

    // Whether to output the target at all was checked by the caller.
    // Now check whether to output failing shards.
    if (TestLogHelper.shouldOutputTestLog(testOutput, false)) {
      for (Path path : summary.getFailedLogs()) {
        try {
          TestLogHelper.writeTestLog(path, testName, printer.getOutputStream());
        } catch (IOException e) {
          printer.printLn("==================== Could not read test output for " + testName);
          LoggingUtil.logToRemote(Level.WARNING, "Error while reading test log", e);
        }
      }
    }

    // And passing shards, independently.
    if (TestLogHelper.shouldOutputTestLog(testOutput, true)) {
      for (Path path : summary.getPassedLogs()) {
        try {
          TestLogHelper.writeTestLog(path, testName, printer.getOutputStream());
        } catch (Exception e) {
          printer.printLn("==================== Could not read test output for " + testName);
          LoggingUtil.logToRemote(Level.WARNING, "Error while reading test log", e);
        }
      }
    }
  }

  private static String statusString(BlazeTestStatus status) {
    return status.toString().replace('_', ' ');
  }

  /**
   * Prints summary status for a single test.
   * @param terminalPrinter The printer to print to
   */
  public static void print(
      TestSummary summary,
      AnsiTerminalPrinter terminalPrinter,
      boolean verboseSummary, boolean printFailedTestCases) {
    BlazeTestStatus status = summary.getStatus();
    // Skip output for tests that failed to build.
    if (status == BlazeTestStatus.FAILED_TO_BUILD
        || status == BlazeTestStatus.BLAZE_HALTED_BEFORE_TESTING) {
      return;
    }
    String message = getCacheMessage(summary) + statusString(summary.getStatus());
    terminalPrinter.print(
        Strings.padEnd(summary.getTarget().getLabel().toString(), 78 - message.length(), ' ')
        + " " + TestSummary.getStatusMode(summary.getStatus()) + message + Mode.DEFAULT
        + (verboseSummary ? getAttemptSummary(summary) + getTimeSummary(summary) : "") + "\n");

    if (printFailedTestCases && summary.getStatus() == BlazeTestStatus.FAILED) {
      if (summary.getFailedTestCasesStatus() == FailedTestCasesStatus.NOT_AVAILABLE) {
        terminalPrinter.print(
            Mode.WARNING + "    (individual test case information not available) "
            + Mode.DEFAULT + "\n");
      } else {
        for (TestCase testCase : summary.getFailedTestCases()) {
          if (testCase.getStatus() != TestCase.Status.PASSED) {
            TestSummaryPrinter.printTestCase(terminalPrinter, testCase);
          }
        }

        if (summary.getFailedTestCasesStatus() != FailedTestCasesStatus.FULL) {
          terminalPrinter.print(
              Mode.WARNING
              + "    (some shards did not report details, list of failed test"
              + " cases incomplete)\n"
              + Mode.DEFAULT);
        }
      }
    }

    if (!printFailedTestCases) {
      for (String warning : summary.getWarnings()) {
        terminalPrinter.print("  " + AnsiTerminalPrinter.Mode.WARNING + "WARNING: "
            + AnsiTerminalPrinter.Mode.DEFAULT + warning + "\n");
      }

      for (Path path : summary.getFailedLogs()) {
        if (path.exists()) {
          // Don't use getPrettyPath() here - we want to print the absolute path,
          // so that it cut and paste into a different terminal, and we don't
          // want to use the blaze-bin etc. symbolic links because they could be changed
          // by a subsequent build with different options.
          terminalPrinter.print("  " + path.getPathString() + "\n");
        }
      }
    }
    for (Path path : summary.getCoverageFiles()) {
      // Print only non-trivial coverage files.
      try {
        if (path.exists() && path.getFileSize() > 0) {
          terminalPrinter.print("  " + path.getPathString() + "\n");
        }
      } catch (IOException e) {
        LoggingUtil.logToRemote(Level.WARNING, "Error while reading coverage data file size",
            e);
      }
    }
  }

  /**
   * Prints the result of an individual test case. It is assumed not to have
   * passed, since passed test cases are not reported.
   */
  static void printTestCase(
      AnsiTerminalPrinter terminalPrinter, TestCase testCase) {
    String timeSummary;
    if (testCase.hasRunDurationMillis()) {
      timeSummary = " ("
          + timeInSec(testCase.getRunDurationMillis(), TimeUnit.MILLISECONDS)
          + ")";
    } else {
      timeSummary = "";
    }

    terminalPrinter.print(
        "    "
        + Mode.ERROR
        + Strings.padEnd(testCase.getStatus().toString(), 8, ' ')
        + Mode.DEFAULT
        + testCase.getClassName()
        + "."
        + testCase.getName()
        + timeSummary
        + "\n");
  }

  /**
   * Return the given time in seconds, to 1 decimal place,
   * i.e. "32.1s".
   */
  static String timeInSec(long time, TimeUnit unit) {
    double ms = TimeUnit.MILLISECONDS.convert(time, unit);
    return String.format("%.1fs", ms / 1000.0);
  }

  static String getAttemptSummary(TestSummary summary) {
    int attempts = summary.getPassedLogs().size() + summary.getFailedLogs().size();
    if (attempts > 1) {
      // Print number of failed runs for failed tests if testing was completed.
      if (summary.getStatus() == BlazeTestStatus.FLAKY) {
        return ", failed in " + summary.getFailedLogs().size() + " out of " + attempts;
      }
      if (summary.getStatus() == BlazeTestStatus.TIMEOUT
          || summary.getStatus() == BlazeTestStatus.FAILED) {
        return " in " + summary.getFailedLogs().size() + " out of " + attempts;
      }
    }
    return "";
  }

  static String getCacheMessage(TestSummary summary) {
    if (summary.getNumCached() == 0 || summary.getStatus() == BlazeTestStatus.INCOMPLETE) {
      return "";
    } else if (summary.getNumCached() == summary.totalRuns()) {
      return "(cached) ";
    } else {
      return String.format("(%d/%d cached) ", summary.getNumCached(), summary.totalRuns());
    }
  }

  static String getTimeSummary(TestSummary summary) {
    if (summary.getTestTimes().isEmpty()) {
      return "";
    } else if (summary.getTestTimes().size() == 1) {
      return " in " + timeInSec(summary.getTestTimes().get(0), TimeUnit.MILLISECONDS);
    } else {
      // We previously used com.google.math for this, which added about 1 MB of deps to the total
      // size. If we re-introduce a dependency on that package, we could revert this change.
      long min = summary.getTestTimes().get(0).longValue(), max = min, sum = 0;
      double sumOfSquares = 0.0;
      for (Long l : summary.getTestTimes()) {
        long value = l.longValue();
        min = Math.min(value, min);
        max = Math.max(value, max);
        sum += value;
        sumOfSquares += ((double) value) * (double) value;
      }
      double mean = ((double) sum) / summary.getTestTimes().size();
      double stddev = Math.sqrt((sumOfSquares - sum * mean) / summary.getTestTimes().size());
      // For sharded tests, we print the max time on the same line as
      // the test, and then print more detailed info about the
      // distribution of times on the next line.
      String maxTime = timeInSec(max, TimeUnit.MILLISECONDS);
      return String.format(
          " in %s\n  Stats over %d runs: max = %s, min = %s, avg = %s, dev = %s",
          maxTime,
          summary.getTestTimes().size(),
          maxTime,
          timeInSec(min, TimeUnit.MILLISECONDS),
          timeInSec((long) mean, TimeUnit.MILLISECONDS),
          timeInSec((long) stddev, TimeUnit.MILLISECONDS));
    }
  }
}
