package org.infinispan.test.hibernate.cache.commons.functional;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;

import java.io.PrintWriter;
import java.io.StringWriter;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;

import org.hibernate.LockMode;
import org.hibernate.stat.CacheRegionStatistics;
import org.infinispan.commons.time.ControlledTimeService;
import org.infinispan.hibernate.cache.commons.util.InfinispanMessageLogger;
import org.infinispan.test.hibernate.cache.commons.functional.entities.Contact;
import org.infinispan.test.hibernate.cache.commons.functional.entities.Customer;
import org.infinispan.test.hibernate.cache.commons.util.TestRegionFactory;
import org.junit.Ignore;
import org.junit.Test;

/**
 * @author nikita_tovstoles@mba.berkeley.edu
 * @author Galder Zamarreño
 */
public class ConcurrentWriteTest extends SingleNodeTest {
	private static final InfinispanMessageLogger log = InfinispanMessageLogger.Provider.getLog( ConcurrentWriteTest.class );
	/**
	 * when USER_COUNT==1, tests pass, when >4 tests fail
	 */
	private static final int USER_COUNT = 5;
	private static final int ITERATION_COUNT = 150;
	private static final int THINK_TIME_MILLIS = 10;
	private static final long LAUNCH_INTERVAL_MILLIS = 10;
	private static final Random random = new Random();
	private static final ControlledTimeService TIME_SERVICE = new ControlledTimeService();

	/**
	 * kill switch used to stop all users when one fails
	 */
	private static volatile boolean TERMINATE_ALL_USERS = false;

	/**
	 * collection of IDs of all customers participating in this test
	 */
	private final Set<Integer> customerIDs = new HashSet<Integer>();

	@Override
	public List<Object[]> getParameters() {
		return getParameters(true, true, false, true, true);
	}

	@Override
	protected void prepareTest() throws Exception {
		super.prepareTest();
		TERMINATE_ALL_USERS = false;
	}

	@Override
	protected void addSettings(Map settings) {
		super.addSettings(settings);
		settings.put(TestRegionFactory.TIME_SERVICE, TIME_SERVICE);
	}

	@Override
	protected void cleanupTest() throws Exception {
		try {
			super.cleanupTest();
		}
		finally {
			cleanup();
		}
	}

	@Test
	public void testPingDb() throws Exception {
		withTxSession(s -> TEST_SESSION_ACCESS.execQueryList(s, "from " + Customer.class.getName()));
	}

	@Test
	public void testSingleUser() throws Exception {
		// setup
		sessionFactory().getStatistics().clear();
		// wait a while to make sure that timestamp comparison works after invalidateRegion
		TIME_SERVICE.advance(1);

		Customer customer = createCustomer( 0 );
		final Integer customerId = customer.getId();
		getCustomerIDs().add( customerId );

		// wait a while to make sure that timestamp comparison works after collection remove (during insert)
		TIME_SERVICE.advance(1);

		assertNull( "contact exists despite not being added", getFirstContact( customerId ) );

		// check that cache was hit
		CacheRegionStatistics customerSlcs = sessionFactory()
				.getStatistics()
				.getCacheRegionStatistics(Customer.class.getName());
		assertEquals( 1, customerSlcs.getPutCount() );
		assertEquals( 1, customerSlcs.getElementCountInMemory() );
		assertEquals( 1, TEST_SESSION_ACCESS.getRegion(sessionFactory(), Customer.class.getName()).getElementCountInMemory());

		log.infof( "Add contact to customer {0}", customerId );
		String contactsRegionName = Customer.class.getName() + ".contacts";
		CacheRegionStatistics contactsCollectionSlcs = sessionFactory()
				.getStatistics()
				.getCacheRegionStatistics(contactsRegionName);
		assertEquals( 1, contactsCollectionSlcs.getPutCount() );
		assertEquals( 1, contactsCollectionSlcs.getElementCountInMemory() );
		assertEquals( 1, TEST_SESSION_ACCESS.getRegion(sessionFactory(), contactsRegionName).getElementCountInMemory());

		final Contact contact = addContact( customerId );
		assertNotNull( "contact returned by addContact is null", contact );
		assertEquals(
				"Customer.contacts cache was not invalidated after addContact", 0,
				contactsCollectionSlcs.getElementCountInMemory()
		);

		assertNotNull( "Contact missing after successful add call", getFirstContact( customerId ) );

		// read everyone's contacts
		readEveryonesFirstContact();

		removeContact( customerId );
		assertNull( "contact still exists after successful remove call", getFirstContact( customerId ) );

	}

	// Ignoring the test as it's more of a stress-test: this should be enabled manually
	@Ignore
	@Test
	public void testManyUsers() throws Throwable {
		try {
			// setup - create users
			for ( int i = 0; i < USER_COUNT; i++ ) {
				Customer customer = createCustomer( 0 );
				getCustomerIDs().add( customer.getId() );
			}
			assertEquals( "failed to create enough Customers", USER_COUNT, getCustomerIDs().size() );

			final ExecutorService executor = Executors.newFixedThreadPool( USER_COUNT );

			CyclicBarrier barrier = new CyclicBarrier( USER_COUNT + 1 );
			List<Future<Void>> futures = new ArrayList<Future<Void>>( USER_COUNT );
			for ( Integer customerId : getCustomerIDs() ) {
				Future<Void> future = executor.submit( new UserRunner( customerId, barrier ) );
				futures.add( future );
				Thread.sleep( LAUNCH_INTERVAL_MILLIS ); // rampup
			}
			barrier.await( 2, TimeUnit.MINUTES ); // wait for all threads to finish
			log.info( "All threads finished, let's shutdown the executor and check whether any exceptions were reported" );
			for ( Future<Void> future : futures ) {
				future.get();
			}
			executor.shutdown();
			log.info( "All future gets checked" );
		}
		catch (Throwable t) {
			log.error( "Error running test", t );
			throw t;
		}
	}

	public void cleanup() throws Exception {
		getCustomerIDs().clear();
		String deleteContactHQL = "delete from Contact";
		String deleteCustomerHQL = "delete from Customer";
		withTxSession(s -> {
         TEST_SESSION_ACCESS.execQueryUpdateAutoFlush(s, deleteContactHQL);
         TEST_SESSION_ACCESS.execQueryUpdateAutoFlush(s, deleteCustomerHQL);
		});
	}

	private Customer createCustomer(int nameSuffix) throws Exception {
		return withTxSessionApply(s -> {
			Customer customer = new Customer();
			customer.setName( "customer_" + nameSuffix );
			customer.setContacts( new HashSet<Contact>() );
			s.persist( customer );
			return customer;
		});
	}

	/**
	 * read first contact of every Customer participating in this test. this forces concurrent cache
	 * writes of Customer.contacts Collection cache node
	 *
	 * @return who cares
	 * @throws java.lang.Exception
	 */
	private void readEveryonesFirstContact() throws Exception {
		withTxSession(s -> {
			for ( Integer customerId : getCustomerIDs() ) {
				if ( TERMINATE_ALL_USERS ) {
					markRollbackOnly(s);
					return;
				}
				Customer customer = s.getReference( Customer.class, customerId );
				Set<Contact> contacts = customer.getContacts();
				if ( !contacts.isEmpty() ) {
					contacts.iterator().next();
				}
			}
		});
	}

	/**
	 * -load existing Customer -get customer's contacts; return 1st one
	 *
	 * @param customerId
	 * @return first Contact or null if customer has none
	 */
	private Contact getFirstContact(Integer customerId) throws Exception {
		assert customerId != null;
		return withTxSessionApply(s -> {
			Customer customer = s.getReference(Customer.class, customerId);
			Set<Contact> contacts = customer.getContacts();
			Contact firstContact = contacts.isEmpty() ? null : contacts.iterator().next();
			if (TERMINATE_ALL_USERS) {
				markRollbackOnly(s);
			}
			return firstContact;
		});
	}

	/**
	 * -load existing Customer -create a new Contact and add to customer's contacts
	 *
	 * @param customerId
	 * @return added Contact
	 */
	private Contact addContact(Integer customerId) throws Exception {
		assert customerId != null;
		return withTxSessionApply(s -> {
			final Customer customer = s.getReference(Customer.class, customerId);
			Contact contact = new Contact();
			contact.setName("contact name");
			contact.setTlf("wtf is tlf?");
			contact.setCustomer(customer);
			customer.getContacts().add(contact);
			s.persist(contact);
			// assuming contact is persisted via cascade from customer
			if (TERMINATE_ALL_USERS) {
				markRollbackOnly(s);
			}
			return contact;
		});
	}

	/**
	 * remove existing 'contact' from customer's list of contacts
	 *
	 * @param customerId
	 * @throws IllegalStateException
	 *            if customer does not own a contact
	 */
	private void removeContact(Integer customerId) throws Exception {
		assert customerId != null;

		withTxSession(s -> {
			Customer customer = s.getReference( Customer.class, customerId );
			Set<Contact> contacts = customer.getContacts();
			if ( contacts.size() != 1 ) {
				throw new IllegalStateException(
						"can't remove contact: customer id=" + customerId
								+ " expected exactly 1 contact, " + "actual count=" + contacts.size()
				);
			}

			Contact contact = contacts.iterator().next();
			// H2 version 1.3 (without MVCC fails with deadlock on Contacts/Customers modification, therefore,
			// we have to enforce locking Contacts first
			s.lock(contact, LockMode.PESSIMISTIC_WRITE);
			contacts.remove( contact );
			contact.setCustomer( null );

			// explicitly delete Contact because hbm has no 'DELETE_ORPHAN' cascade?
			// getEnvironment().getSessionFactory().getCurrentSession().delete(contact); //appears to
			// not be needed

			// assuming contact is persisted via cascade from customer

			if ( TERMINATE_ALL_USERS ) {
				markRollbackOnly(s);
			}
		});
	}

	/**
	 * @return the customerIDs
	 */
	public Set<Integer> getCustomerIDs() {
		return customerIDs;
	}

	private String statusOfRunnersToString(Set<UserRunner> runners) {
		assert runners != null;

		StringBuilder sb = new StringBuilder(
				"TEST CONFIG [userCount=" + USER_COUNT
						+ ", iterationsPerUser=" + ITERATION_COUNT + ", thinkTimeMillis="
						+ THINK_TIME_MILLIS + "] " + " STATE of UserRunners: "
		);

		for ( UserRunner r : runners ) {
			sb.append( r.toString() ).append( System.lineSeparator() );
		}
		return sb.toString();
	}

	class UserRunner implements Callable<Void> {
		private final CyclicBarrier barrier;
		private final Integer customerId;
		private int completedIterations = 0;
		private Throwable causeOfFailure;

		public UserRunner(Integer cId, CyclicBarrier barrier) {
			assert cId != null;
			this.customerId = cId;
			this.barrier = barrier;
		}

		private boolean contactExists() throws Exception {
			return getFirstContact( customerId ) != null;
		}

		public Void call() throws Exception {
			// name this thread for easier log tracing
			Thread.currentThread().setName( "UserRunnerThread-" + getCustomerId() );
			log.info( "Wait for all executions paths to be ready to perform calls" );
			try {
				for ( int i = 0; i < ITERATION_COUNT && !TERMINATE_ALL_USERS; i++ ) {
					contactExists();
					log.trace( "Add contact for customer " + customerId );
					addContact( customerId );
					log.trace( "Added contact" );
					thinkRandomTime();
					contactExists();
					thinkRandomTime();
					log.trace( "Read all customers' first contact" );
					// read everyone's contacts
					readEveryonesFirstContact();
					log.trace( "Read completed" );
					thinkRandomTime();
					log.trace( "Remove contact of customer" + customerId );
					removeContact( customerId );
					log.trace( "Removed contact" );
					contactExists();
					thinkRandomTime();
					++completedIterations;
					log.tracef( "Iteration completed %d", completedIterations );
				}
			}
			catch (Throwable t) {
				TERMINATE_ALL_USERS = true;
				log.error( "Error", t );
				throw new Exception( t );
			}
			finally {
				log.info( "Wait for all execution paths to finish" );
				barrier.await();
			}
			return null;
		}

		public boolean isSuccess() {
			return ITERATION_COUNT == getCompletedIterations();
		}

		public int getCompletedIterations() {
			return completedIterations;
		}

		public Throwable getCauseOfFailure() {
			return causeOfFailure;
		}

		public Integer getCustomerId() {
			return customerId;
		}

		@Override
		public String toString() {
			return super.toString() + "[customerId=" + getCustomerId() + " iterationsCompleted="
					+ getCompletedIterations() + " completedAll=" + isSuccess() + " causeOfFailure="
					+ (this.causeOfFailure != null ? getStackTrace( causeOfFailure ) : "") + "] ";
		}
	}

	public static String getStackTrace(Throwable throwable) {
		StringWriter sw = new StringWriter();
		PrintWriter pw = new PrintWriter( sw, true );
		throwable.printStackTrace( pw );
		return sw.getBuffer().toString();
	}

	/**
	 * sleep between 0 and THINK_TIME_MILLIS.
	 *
	 * @throws RuntimeException if sleep is interrupted or TERMINATE_ALL_USERS flag was set to true i n the
	 * meantime
	 */
	private void thinkRandomTime() {
		try {
			Thread.sleep( random.nextInt( THINK_TIME_MILLIS ) );
		}
		catch (InterruptedException ex) {
			throw new RuntimeException( "sleep interrupted", ex );
		}

		if ( TERMINATE_ALL_USERS ) {
			throw new RuntimeException( "told to terminate (because a UserRunner had failed)" );
		}
	}

}
