/*
 * Copyright (C) 2020 The Android Open Source Project
 *
 * 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.android.exoplayer2.testutil;

import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import com.google.android.exoplayer2.util.Util;
import com.google.common.truth.Truth;
import java.util.concurrent.atomic.AtomicReference;
import org.checkerframework.checker.nullness.compatqual.NullableType;
import org.junit.Rule;
import org.junit.rules.TestRule;
import org.junit.runner.Description;
import org.junit.runners.model.Statement;

/**
 * A JUnit {@link Rule} that attaches additional info to any errors/exceptions thrown by the test.
 *
 * <p>This is useful for tests where the line-number from a stacktrace doesn't provide enough detail
 * about the failure, for example when an assertion fails inside a loop.
 *
 * <p>This can be preferable to many calls to {@link Truth#assertWithMessage(String)} because it
 * will also add info to errors/exceptions that bubble out from the system-under-test.
 *
 * <p>Includes special handling for {@link AssertionError} to ensure that test failures are
 * correctly distinguished from test errors (all other errors/exceptions).
 */
@RequiresApi(19)
public final class AdditionalFailureInfo implements TestRule {

  private final AtomicReference<@NullableType String> info;

  public AdditionalFailureInfo() {
    info = new AtomicReference<>();
  }

  /**
   * Sets the additional info to be added to any test failures. Pass {@code null} to skip adding any
   * additional info.
   *
   * <p>Can be called from any thread.
   */
  public void setInfo(@Nullable String info) {
    this.info.set(info);
  }

  @Override
  public Statement apply(Statement base, Description description) {
    return new Statement() {
      @Override
      public void evaluate() throws Throwable {
        try {
          base.evaluate();
        } catch (Throwable e) {
          @Nullable String resourceInfo = AdditionalFailureInfo.this.info.get();
          if (resourceInfo == null) {
            throw e;
          } else if (e instanceof AssertionError) {
            // Deliberately prune the AssertionError from the causal chain and "replace" it with
            // this new one by copying the stacktrace from old to new (so it looks like the new
            // one was thrown from where the old one was thrown).
            DiscreteAssertionError assertionError =
                new DiscreteAssertionError(resourceInfo + "\n" + e.getMessage(), e.getCause());
            assertionError.setStackTrace(e.getStackTrace());
            throw assertionError;
          } else {
            Exception exception = new Exception(resourceInfo, e);
            StackTraceElement[] stackTrace = exception.getStackTrace();
            // Only include the top line of the stack trace (this method) - the rest will be
            // uninteresting JUnit framework calls that obscure the true test failure.
            if (stackTrace.length > 0) {
              exception.setStackTrace(
                  Util.nullSafeArrayCopyOfRange(stackTrace, /* from= */ 0, /* to= */ 1));
            }
            throw exception;
          }
        }
      }
    };
  }

  /** An {@link AssertionError} that doesn't print its class name in stack traces. */
  @SuppressWarnings("OverrideThrowableToString")
  private static class DiscreteAssertionError extends AssertionError {

    public DiscreteAssertionError(@Nullable String message, @Nullable Throwable cause) {
      super(message, cause);
    }

    // To avoid printing the class name before the message. Inspired by Truth:
    // https://github.com/google/truth/blob/152f3936/core/src/main/java/com/google/common/truth/Platform.java#L186-L192
    @Override
    public String toString() {
      @Nullable String message = getLocalizedMessage();
      return message != null ? message : super.toString();
    }
  }
}
