/*
 * Copyright (C) 2010 The Guava Authors
 *
 * 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.common.util.concurrent;

import static com.google.common.base.Preconditions.checkNotNull;
import static junit.framework.Assert.assertEquals;
import static junit.framework.Assert.assertNotNull;
import static junit.framework.Assert.assertNull;
import static junit.framework.Assert.assertSame;

import com.google.common.testing.TearDown;

import junit.framework.AssertionFailedError;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.concurrent.SynchronousQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

import javax.annotation.Nullable;

/**
 * A helper for concurrency testing. One or more {@code TestThread} instances are instantiated
 * in a test with reference to the same "lock-like object", and then their interactions with that
 * object are choreographed via the various methods on this class.
 * 
 * <p>A "lock-like object" is really any object that may be used for concurrency control. If the
 * {@link #callAndAssertBlocks} method is ever called in a test, the lock-like object must have a
 * method equivalent to {@link java.util.concurrent.locks.ReentrantLock#hasQueuedThread(Thread)}. If
 * the {@link #callAndAssertWaits} method is ever called in a test, the lock-like object must have a
 * method equivalent to {@link
 * java.util.concurrent.locks.ReentrantLock#hasWaiters(java.util.concurrent.locks.Condition)},
 * except that the method parameter must accept whatever condition-like object is passed into
 * {@code callAndAssertWaits} by the test.
 *
 * @param <L> the type of the lock-like object to be used
 * @author Justin T. Sampson
 */
public final class TestThread<L> extends Thread implements TearDown {

  private static final long DUE_DILIGENCE_MILLIS = 50;
  private static final long TIMEOUT_MILLIS = 5000;

  private final L lockLikeObject;

  private final SynchronousQueue<Request> requestQueue = new SynchronousQueue<Request>();
  private final SynchronousQueue<Response> responseQueue = new SynchronousQueue<Response>();
  
  private Throwable uncaughtThrowable = null;
  
  public TestThread(L lockLikeObject, String threadName) {
    super(threadName);
    this.lockLikeObject = checkNotNull(lockLikeObject);
    start();
  }

  // Thread.stop() is okay because all threads started by a test are dying at the end of the test,
  // so there is no object state put at risk by stopping the threads abruptly. In some cases a test
  // may put a thread into an uninterruptible operation intentionally, so there is no other way to
  // clean up these threads.
  @SuppressWarnings("deprecation")
  @Override public void tearDown() throws Exception {
    stop();
    join();

    if (uncaughtThrowable != null) {
      throw (AssertionFailedError) new AssertionFailedError("Uncaught throwable in " + getName())
          .initCause(uncaughtThrowable);
    }
  }

  /**
   * Causes this thread to call the named void method, and asserts that the call returns normally.
   */
  public void callAndAssertReturns(String methodName, Object... arguments) throws Exception {
    checkNotNull(methodName);
    checkNotNull(arguments);
    sendRequest(methodName, arguments);
    assertSame(null, getResponse(methodName).getResult());
  }
  
  /**
   * Causes this thread to call the named method, and asserts that the call returns the expected
   * boolean value.
   */
  public void callAndAssertReturns(boolean expected, String methodName, Object... arguments)
      throws Exception {
    checkNotNull(methodName);
    checkNotNull(arguments);
    sendRequest(methodName, arguments);
    assertEquals(expected, getResponse(methodName).getResult());
  }
  
  /**
   * Causes this thread to call the named method, and asserts that the call returns the expected
   * int value.
   */
  public void callAndAssertReturns(int expected, String methodName, Object... arguments)
      throws Exception {
    checkNotNull(methodName);
    checkNotNull(arguments);
    sendRequest(methodName, arguments);
    assertEquals(expected, getResponse(methodName).getResult());
  }
  
  /**
   * Causes this thread to call the named method, and asserts that the call throws the expected
   * type of throwable.
   */
  public void callAndAssertThrows(Class<? extends Throwable> expected,
      String methodName, Object... arguments) throws Exception {
    checkNotNull(expected);
    checkNotNull(methodName);
    checkNotNull(arguments);
    sendRequest(methodName, arguments);
    assertEquals(expected, getResponse(methodName).getThrowable().getClass());
  }

  /**
   * Causes this thread to call the named method, and asserts that this thread becomes blocked on
   * the lock-like object. The lock-like object must have a method equivalent to {@link
   * java.util.concurrent.locks.ReentrantLock#hasQueuedThread(Thread)}.
   */
  public void callAndAssertBlocks(String methodName, Object... arguments) throws Exception {
    checkNotNull(methodName);
    checkNotNull(arguments);
    assertEquals(false, invokeMethod("hasQueuedThread", this));
    sendRequest(methodName, arguments);
    Thread.sleep(DUE_DILIGENCE_MILLIS);
    assertEquals(true, invokeMethod("hasQueuedThread", this));
    assertNull(responseQueue.poll());
  }

  /**
   * Causes this thread to call the named method, and asserts that this thread thereby waits on
   * the given condition-like object. The lock-like object must have a method equivalent to {@link
   * java.util.concurrent.locks.ReentrantLock#hasWaiters(java.util.concurrent.locks.Condition)},
   * except that the method parameter must accept whatever condition-like object is passed into
   * this method.
   */
  public void callAndAssertWaits(String methodName, Object conditionLikeObject)
      throws Exception {
    checkNotNull(methodName);
    checkNotNull(conditionLikeObject);
    // TODO: Restore the following line when Monitor.hasWaiters() no longer acquires the lock.
    // assertEquals(false, invokeMethod("hasWaiters", conditionLikeObject));
    sendRequest(methodName, conditionLikeObject);
    Thread.sleep(DUE_DILIGENCE_MILLIS);
    assertEquals(true, invokeMethod("hasWaiters", conditionLikeObject));
    assertNull(responseQueue.poll());
  }

  /**
   * Asserts that a prior call that had caused this thread to block or wait has since returned
   * normally.
   */
  public void assertPriorCallReturns(@Nullable String methodName) throws Exception {
    assertEquals(null, getResponse(methodName).getResult());
  }
  
  /**
   * Asserts that a prior call that had caused this thread to block or wait has since returned
   * the expected boolean value.
   */
  public void assertPriorCallReturns(boolean expected, @Nullable String methodName)
      throws Exception {
    assertEquals(expected, getResponse(methodName).getResult());
  }

  /**
   * Sends the given method call to this thread.
   * 
   * @throws TimeoutException if this thread does not accept the request within a resonable amount
   *         of time
   */
  private void sendRequest(String methodName, Object... arguments) throws Exception {
    if (!requestQueue.offer(
        new Request(methodName, arguments), TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)) {
      throw new TimeoutException();
    }
  }

  /**
   * Receives a response from this thread.
   * 
   * @throws TimeoutException if this thread does not offer a response within a resonable amount of
   *         time
   * @throws AssertionFailedError if the given method name does not match the name of the method
   *         this thread has called most recently
   */
  private Response getResponse(String methodName) throws Exception {
    Response response = responseQueue.poll(TIMEOUT_MILLIS, TimeUnit.MILLISECONDS);
    if (response == null) {
      throw new TimeoutException();
    }
    assertEquals(methodName, response.methodName);
    return response;
  }

  private Object invokeMethod(String methodName, Object... arguments) throws Exception {
    return getMethod(methodName, arguments).invoke(lockLikeObject, arguments);
  }
  
  private Method getMethod(String methodName, Object... arguments) throws Exception {
    METHODS: for (Method method : lockLikeObject.getClass().getMethods()) {
      Class<?>[] parameterTypes = method.getParameterTypes();
      if (method.getName().equals(methodName) && (parameterTypes.length == arguments.length)) {
        for (int i = 0; i < arguments.length; i++) {
          if (!parameterTypes[i].isAssignableFrom(arguments[i].getClass())) {
            continue METHODS;
          }
        }
        return method;
      }
    }
    throw new NoSuchMethodError(methodName);
  }

  @Override public void run() {
    assertSame(this, Thread.currentThread());
    try {
      while (true) {
        Request request = requestQueue.take();
        Object result;
        try {
          result = invokeMethod(request.methodName, request.arguments);
        } catch (ThreadDeath death) {
          return;
        } catch (InvocationTargetException exception) {
          responseQueue.put(
              new Response(request.methodName, null, exception.getTargetException()));
          continue;
        } catch (Throwable throwable) {
          responseQueue.put(new Response(request.methodName, null, throwable));
          continue;
        }
        responseQueue.put(new Response(request.methodName, result, null));
      }
    } catch (ThreadDeath death) {
      return;
    } catch (InterruptedException ignored) {
      // SynchronousQueue sometimes throws InterruptedException while the threads are stopping.
    } catch (Throwable uncaught) {
      this.uncaughtThrowable = uncaught;
    }
  }

  private static class Request {
    final String methodName;
    final Object[] arguments;
    
    Request(String methodName, Object[] arguments) {
      this.methodName = checkNotNull(methodName);
      this.arguments = checkNotNull(arguments);
    }
  }

  private static class Response {
    final String methodName;
    final Object result;
    final Throwable throwable;
    
    Response(String methodName, Object result, Throwable throwable) {
      this.methodName = methodName;
      this.result = result;
      this.throwable = throwable;
    }
    
    Object getResult() {
      if (throwable != null) {
        throw (AssertionFailedError) new AssertionFailedError().initCause(throwable);
      }
      return result;
    }
    
    Throwable getThrowable() {
      assertNotNull(throwable);
      return throwable;
    }
  }
}