/*
 * Copyright (c) 2018 Red Hat, Inc.
 *
 * 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 io.vertx.junit5;

import io.vertx.core.AsyncResult;
import io.vertx.core.Future;
import io.vertx.core.Handler;
import io.vertx.core.Promise;
import io.vertx.core.impl.NoStackTraceThrowable;

import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

/**
 * A test context to wait on the outcomes of asynchronous operations.
 *
 * @author <a href="https://julien.ponge.org/">Julien Ponge</a>
 */
public final class VertxTestContext {

  /**
   * Interface for an executable block of assertion code.
   *
   * @see #verify(ExecutionBlock)
   */
  @FunctionalInterface
  public interface ExecutionBlock {

    void apply() throws Throwable;
  }

  // ........................................................................................... //

  private Throwable throwableReference = null;
  private final CountDownLatch releaseLatch = new CountDownLatch(1);
  private final HashSet<CountingCheckpoint> checkpoints = new HashSet<>();

  // ........................................................................................... //

  /**
   * Check if the context has been marked has failed or not.
   *
   * @return {@code true} if the context has failed, {@code false} otherwise.
   */
  public synchronized boolean failed() {
    return throwableReference != null;
  }

  /**
   * Give the cause of failure.
   *
   * @return the cause of failure, or {@code null} if the test context hasn't failed.
   */
  public synchronized Throwable causeOfFailure() {
    return throwableReference;
  }

  /**
   * Check if the context has completed.
   *
   * @return {@code true} if the context has completed, {@code false} otherwise.
   */
  public synchronized boolean completed() {
    return !failed() && releaseLatch.getCount() == 0;
  }

  /**
   * Gives the call sites of all unsatisfied checkpoints.
   *
   * @return a set of {@link StackTraceElement} references pointing to the unsatisfied checkpoint call sites.
   */
  public Set<StackTraceElement> unsatisfiedCheckpointCallSites() {
    return checkpoints
      .stream()
      .filter(checkpoint -> !checkpoint.satisfied())
      .map(CountingCheckpoint::creationCallSite)
      .collect(Collectors.toSet());
  }

  // ........................................................................................... //

  /**
   * Complete the test context immediately, making the corresponding test pass.
   */
  public synchronized void completeNow() {
    releaseLatch.countDown();
  }

  /**
   * Make the test context fail immediately, making the corresponding test fail.
   *
   * @param t the cause of failure.
   */
  public synchronized void failNow(Throwable t) {
    Objects.requireNonNull(t, "The exception cannot be null");
    if (!completed()) {
      throwableReference = t;
      releaseLatch.countDown();
    }
  }

  /**
   * Calls {@link #failNow(Throwable)} with the {@code message}.
   *
   * @param message  the cause of failure
   */
  public synchronized void failNow(String message) {
    failNow(new NoStackTraceThrowable(message));
  }

  // ........................................................................................... //

  private synchronized void checkpointSatisfied(Checkpoint checkpoint) {
    checkpoints.remove(checkpoint);
    if (checkpoints.isEmpty()) {
      completeNow();
    }
  }

  /**
   * Create a lax checkpoint.
   *
   * @return a checkpoint that requires 1 pass; more passes are allowed and ignored.
   */
  public Checkpoint laxCheckpoint() {
    return laxCheckpoint(1);
  }

  /**
   * Create a lax checkpoint.
   *
   * @param requiredNumberOfPasses the required number of passes to validate the checkpoint.
   * @return a checkpoint that requires several passes; more passes than the required number are allowed and ignored.
   */
  public synchronized Checkpoint laxCheckpoint(int requiredNumberOfPasses) {
    CountingCheckpoint checkpoint = CountingCheckpoint.laxCountingCheckpoint(this::checkpointSatisfied, requiredNumberOfPasses);
    checkpoints.add(checkpoint);
    return checkpoint;
  }

  /**
   * Create a strict checkpoint.
   *
   * @return a checkpoint that requires 1 pass, and makes the context fail if it is called more than once.
   */
  public Checkpoint checkpoint() {
    return checkpoint(1);
  }

  /**
   * Create a strict checkpoint.
   *
   * @param requiredNumberOfPasses the required number of passes to validate the checkpoint.
   * @return a checkpoint that requires several passes, but no more or it fails the context.
   */
  public synchronized Checkpoint checkpoint(int requiredNumberOfPasses) {
    CountingCheckpoint checkpoint = CountingCheckpoint.strictCountingCheckpoint(this::checkpointSatisfied, this::failNow, requiredNumberOfPasses);
    checkpoints.add(checkpoint);
    return checkpoint;
  }

  // ........................................................................................... //

  /**
   * Create an asynchronous result handler that expects a success.
   *
   * @param <T> the asynchronous result type.
   * @return the handler.
   * @deprecated Use {@link #succeedingThenComplete()} or {@link #succeeding(Handler)}, for example
   *     <code>succeeding(value -> checkpoint.flag())</code>, <code>succeeding(value -> { more testing code })</code>, or
   *     <code>succeeding(value -> {})</code>.
   */
  @Deprecated
  public <T> Handler<AsyncResult<T>> succeeding() {
    return ar -> {
      if (!ar.succeeded()) {
        failNow(ar.cause());
      }
    };
  }

  /**
   * Create an asynchronous result handler that expects a success, and passes the value to another handler.
   *
   * @param nextHandler the value handler to call on success that is expected not to throw a {@link Throwable}.
   * @param <T>         the asynchronous result type.
   * @return the handler.
   */
  public <T> Handler<AsyncResult<T>> succeeding(Handler<T> nextHandler) {
    Objects.requireNonNull(nextHandler, "The handler cannot be null");
    return ar -> {
      if (ar.failed()) {
        failNow(ar.cause());
        return;
      }
      try {
        nextHandler.handle(ar.result());
      } catch (Throwable e) {
        failNow(e);
      }
    };
  }

  /**
   * Create an asynchronous result handler that expects a failure.
   *
   * @param <T> the asynchronous result type.
   * @return the handler.
   * @deprecated Use {@link #failingThenComplete()} or {@link #failing(Handler)}, for example
   *     <code>failing(e -> checkpoint.flag())</code>, <code>failing(e -> { more testing code })</code>, or
   *     <code>failing(e -> {})</code>.
   */
  @Deprecated
  public <T> Handler<AsyncResult<T>> failing() {
    return ar -> {
      if (ar.succeeded()) {
        failNow(new AssertionError("The asynchronous result was expected to have failed"));
      }
    };
  }

  /**
   * Create an asynchronous result handler that expects a failure, and passes the exception to another handler.
   *
   * @param nextHandler the exception handler to call on failure that is expected not to throw a {@link Throwable}.
   * @param <T>         the asynchronous result type.
   * @return the handler.
   */
  public <T> Handler<AsyncResult<T>> failing(Handler<Throwable> nextHandler) {
    Objects.requireNonNull(nextHandler, "The handler cannot be null");
    return ar -> {
      if (ar.succeeded()) {
        failNow(new AssertionError("The asynchronous result was expected to have failed"));
        return;
      }
      try {
        nextHandler.handle(ar.cause());
      } catch (Throwable e) {
        failNow(e);
      }
    };
  }

  /**
   * Create an asynchronous result handler that expects a success to then complete the test context.
   *
   * @param <T> the asynchronous result type.
   * @return the handler.
   */
  public <T> Handler<AsyncResult<T>> succeedingThenComplete() {
    return ar -> {
      if (ar.succeeded()) {
        completeNow();
      } else {
        failNow(ar.cause());
      }
    };
  }

  /**
   * Create an asynchronous result handler that expects a success to then complete the test context.
   *
   * @param <T> the asynchronous result type.
   * @return the handler.
   * @see #failingThenComplete()
   * @deprecated Use {@link #succeedingThenComplete()} instead.
   */
  @Deprecated
  public <T> Handler<AsyncResult<T>> completing() {
    return succeedingThenComplete();
  }

  /**
   * Create an asynchronous result handler that expects a failure to then complete the test context.
   *
   * @param <T> the asynchronous result type.
   * @return the handler.
   */
  public <T> Handler<AsyncResult<T>> failingThenComplete() {
    return ar -> {
      if (ar.succeeded()) {
        failNow(new AssertionError("The asynchronous result was expected to have failed"));
        return;
      }
      completeNow();
    };
  }

  // ........................................................................................... //

  /**
   * This method allows you to check if a future is completed.
   * It internally creates a checkpoint.
   * You can use it in a chain of `Future`.
   *
   * @param fut The future to assert success
   * @return a future with completion result
   */
  public <T> Future<T> assertComplete(Future<T> fut) {
    Promise<T> newPromise = Promise.promise();
    fut.onComplete(ar -> {
      if (ar.succeeded()) {
        newPromise.complete(ar.result());
      } else {
        Throwable ex = new AssertionError("Future failed with exception: " + ar.cause().getMessage(), ar.cause());
        this.failNow(ex);
        newPromise.fail(ex);
      }
    });
    return newPromise.future();
  }

  /**
   * This method allows you to check if a future is failed.
   * It internally creates a checkpoint.
   * You can use it in a chain of `Future`.
   *
   * @param fut The future to assert failure
   * @return a future with failure result
   */
  public <T> Future<T> assertFailure(Future<T> fut) {
    Promise<T> newPromise = Promise.promise();
    fut.onComplete(ar -> {
      if (ar.succeeded()) {
        Throwable ex = new AssertionError("Future completed with value: " + ar.result());
        this.failNow(ex);
        newPromise.fail(ex);
      } else {
        newPromise.fail(ar.cause());
      }
    });
    return newPromise.future();
  }

  // ........................................................................................... //

  /**
   * Allow verifications and assertions to be made.
   * <p>
   * This method allows any assertion API to be used.
   * The semantic is that the verification is successful when no exception is being thrown upon calling {@code block},
   * otherwise the context fails with that exception.
   *
   * @param block a block of code to execute.
   * @return this context.
   */
  public VertxTestContext verify(ExecutionBlock block) {
    Objects.requireNonNull(block, "The block cannot be null");
    try {
      block.apply();
    } catch (Throwable t) {
      failNow(t);
    }
    return this;
  }

  // ........................................................................................... //

  /**
   * Wait for the completion of the test context.
   * <p>
   * This method is automatically called by the {@link VertxExtension} when using parameter injection of {@link VertxTestContext}.
   * You should only call it when you instantiate this class manually.
   *
   * @param timeout the timeout.
   * @param unit    the timeout unit.
   * @return {@code true} if the completion or failure happens before the timeout has been reached, {@code false} otherwise.
   * @throws InterruptedException when the thread has been interrupted.
   */
  public boolean awaitCompletion(long timeout, TimeUnit unit) throws InterruptedException {
    return failed() || releaseLatch.await(timeout, unit);
  }

  // ........................................................................................... //
}
