/**
 * This software is released as part of the Pumpernickel project.
 * 
 * All com.pump resources in the Pumpernickel project are distributed under the
 * MIT License:
 * https://github.com/mickleness/pumpernickel/raw/master/License.txt
 * 
 * More information about the Pumpernickel project is available here:
 * https://mickleness.github.io/pumpernickel/
 */
package com.pump.data.branch;

import java.lang.Thread.UncaughtExceptionHandler;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.TreeMap;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock.WriteLock;

import com.pump.data.BeanState;
import com.pump.data.Key;

/**
 * This is an implementation of {@link Branch} that stores all its revisions in
 * memory.
 * <p>
 * This should not be used for a large-scale implementation of hundreds of
 * thousands of beans, but it can generally accommodate thousands of beans.
 */
public class MemoryBranch<K> extends AbstractBranch<K> {

	private static Object NULL = new Object();
	private static final Key<Boolean> FIELD_DELETED = new Key<>(Boolean.class,
			MemoryBranch.class.getName() + "#isDeleted");
	private static final Key<Boolean> FIELD_CREATED = new Key<>(Boolean.class,
			MemoryBranch.class.getName() + "#isCreated");

	protected ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
	protected Map<K, Map<String, TreeMap<Revision, Object>>> dataByBeanId = new HashMap<>();
	protected Collection<Revision> keyRevisions = new HashSet<>();
	protected Revision currentRevision = initialRevision.clone();
	protected Revision lastCommittedRevision = null;

	protected UncaughtExceptionHandler uncaughtExceptionHandler = new UncaughtExceptionHandler() {

		@Override
		public void uncaughtException(Thread t, Throwable e) {
			e.printStackTrace();
		}
	};

	public MemoryBranch(String name) {
		super(null, null, name);
	}

	protected MemoryBranch(Branch<K> parent, Revision parentRevision,
			String name) {
		super(parent, parentRevision, name);
	}

	@Override
	public Branch<K> createBranch(String name) {
		Object lock = acquireWriteLock();
		try {
			keyRevisions.add(getRevision());
			return new MemoryBranch<K>(this, currentRevision, name);
		} finally {
			releaseLock(lock);
		}
	}

	@Override
	public Revision getRevision() {
		Object lock = acquireWriteLock();
		try {
			keyRevisions.add(currentRevision);
			return currentRevision;
		} finally {
			releaseLock(lock);
		}
	}

	@Override
	public Lock acquireReadLock() {
		Lock returnValue = readWriteLock.readLock();
		returnValue.lock();
		return returnValue;
	}

	@Override
	public Lock acquireWriteLock() {
		Lock returnValue = readWriteLock.writeLock();
		returnValue.lock();
		return returnValue;
	}

	@Override
	public void releaseLock(Object lock) {
		Lock l = (Lock) lock;
		if (l instanceof WriteLock) {
			WriteLock w = (WriteLock) l;
			if (w.getHoldCount() == 1) {
				currentRevision = currentRevision.increment();
			}
		}
		l.unlock();
	}

	@Override
	public BeanState getState(K beanId, Revision revision) {
		if (beanId == null)
			throw new NullPointerException();
		if (revision != null && revision.getBranch() != this)
			throw new IllegalRevisionBranchException(
					"The revision provided relates to branch \""
							+ revision.getBranch().getName() + "\" (not \""
							+ getName() + "\"", this, revision);

		Lock lock = acquireReadLock();
		try {
			Map<String, TreeMap<Revision, Object>> beanData = dataByBeanId
					.get(beanId);
			TreeMap<Revision, Object> deletedField = beanData == null ? null
					: beanData.get(FIELD_DELETED.toString());
			TreeMap<Revision, Object> createdField = beanData == null ? null
					: beanData.get(FIELD_CREATED.toString());

			if (revision == null)
				revision = currentRevision;

			int thresholdSize = beanData == null ? 0 : beanData.size();
			if (deletedField != null)
				thresholdSize--;
			if (createdField != null)
				thresholdSize--;
			boolean exists = beanData != null
					&& beanData.size() > thresholdSize;

			Entry<Revision, Object> deletedFloor = deletedField == null ? null
					: deletedField.floorEntry(revision);
			Entry<Revision, Object> createdFloor = createdField == null ? null
					: createdField.floorEntry(revision);

			if (deletedFloor == null && createdFloor == null) {
				// this branch neither created nor deleted this bean
				if (exists)
					return BeanState.EXISTS;
				if (parent != null) {
					BeanState returnValue = parent.getState(beanId,
							parentRevision);
					if (returnValue == BeanState.CREATED)
						returnValue = BeanState.EXISTS;
					return returnValue;
				} else {
					return BeanState.UNDEFINED;
				}
			} else if (deletedFloor == null && createdFloor != null) {
				return BeanState.CREATED;
			} else if (deletedFloor != null && createdFloor == null) {
				return BeanState.DELETED;
			} else {
				if (deletedFloor.getKey().compareTo(createdFloor.getKey()) < 0) {
					return BeanState.CREATED;
				}
				return BeanState.DELETED;
			}

		} finally {
			releaseLock(lock);
		}
	}

	@Override
	public Object getField(K beanId, String fieldName, Revision revision)
			throws MissingBeanException {
		return doGetField(beanId, fieldName, revision)[0];
	}

	private Object[] doGetField(K beanId, String fieldName, Revision revision)
			throws MissingBeanException {
		if (beanId == null)
			throw new NullPointerException();
		if (fieldName == null)
			throw new NullPointerException();
		if (revision != null && revision.getBranch() != this)
			throw new IllegalRevisionBranchException(
					"The revision provided relates to branch \""
							+ revision.getBranch().getName() + "\" (not \""
							+ getName() + "\"", this, revision);

		Lock lock = acquireReadLock();
		try {
			BeanState state = getState(beanId, revision);
			if (state == BeanState.DELETED)
				throw new DeletedBeanException(this, beanId);
			if (state == BeanState.UNDEFINED)
				throw new MissingBeanException(this, beanId);

			Map<String, TreeMap<Revision, Object>> beanData = dataByBeanId
					.get(beanId);
			TreeMap<Revision, Object> fieldRevisions = beanData == null ? null
					: beanData.get(fieldName);

			if (fieldRevisions == null) {
				boolean isRoot = parent == null;
				if (isRoot || state == BeanState.CREATED) {
					return new Object[] { null, null };
				}
				Object value = parent.getField(beanId, fieldName,
						parentRevision);
				return new Object[] { value, null };
			}

			if (revision == null)
				revision = currentRevision;

			Entry<Revision, Object> floorRevision = fieldRevisions
					.floorEntry(revision);
			Object value = floorRevision == null ? null : floorRevision
					.getValue();

			if (value == NULL) {
				value = null;
			}

			return new Object[] { value,
					floorRevision == null ? null : floorRevision.getKey() };
		} finally {
			releaseLock(lock);
		}
	}

	@Override
	public Object setField(K beanId, String fieldName, Object newValue)
			throws MissingBeanException {
		if (beanId == null)
			throw new NullPointerException();
		if (fieldName == null)
			throw new NullPointerException();

		Lock lock = acquireWriteLock();
		try {
			Object[] oldValue = doGetField(beanId, fieldName, null);
			Map<String, TreeMap<Revision, Object>> beanData = dataByBeanId
					.get(beanId);
			if (beanData == null) {
				beanData = new HashMap<>();
				dataByBeanId.put(beanId, beanData);
			}

			TreeMap<Revision, Object> fieldRevisionMap = beanData
					.get(fieldName);
			if (fieldRevisionMap == null) {
				fieldRevisionMap = new TreeMap<>();
				beanData.put(fieldName, fieldRevisionMap);
			}

			Object newAssignment = newValue == null ? NULL : newValue;
			fieldRevisionMap.put(currentRevision, newAssignment);

			return oldValue[0];
		} finally {
			releaseLock(lock);
		}
	}

	@Override
	public void createBean(K beanId) throws DuplicateBeanIdException {
		if (beanId == null)
			throw new NullPointerException();

		Lock lock = acquireWriteLock();
		try {
			BeanState state = getState(beanId, null);
			if (state == BeanState.CREATED || state == BeanState.EXISTS)
				throw new DuplicateBeanIdException(this, beanId);

			Map<String, TreeMap<Revision, Object>> beanData = dataByBeanId
					.get(beanId);

			if (beanData == null) {
				beanData = new HashMap<>();
				dataByBeanId.put(beanId, beanData);
			}

			TreeMap<Revision, Object> fieldRevisionMap = beanData
					.get(FIELD_CREATED.toString());
			if (fieldRevisionMap == null) {
				fieldRevisionMap = new TreeMap<>();
				beanData.put(FIELD_CREATED.toString(), fieldRevisionMap);
			}

			fieldRevisionMap.put(currentRevision, Boolean.TRUE);
		} finally {
			releaseLock(lock);
		}
	}

	@Override
	public void deleteBean(K beanId) throws MissingBeanException {
		if (beanId == null)
			throw new NullPointerException();

		Lock lock = acquireWriteLock();
		try {
			BeanState state = getState(beanId, null);
			if (state == BeanState.DELETED || state == BeanState.UNDEFINED)
				throw new MissingBeanException(this, beanId);

			Map<String, TreeMap<Revision, Object>> beanData = dataByBeanId
					.get(beanId);

			if (beanData == null) {
				beanData = new HashMap<>();
				dataByBeanId.put(beanId, beanData);
			}

			TreeMap<Revision, Object> fieldRevisionMap = beanData
					.get(FIELD_DELETED.toString());
			if (fieldRevisionMap == null) {
				fieldRevisionMap = new TreeMap<>();
				beanData.put(FIELD_DELETED.toString(), fieldRevisionMap);
			}
			fieldRevisionMap.put(currentRevision, Boolean.TRUE);
		} finally {
			releaseLock(lock);
		}
	}

	@Override
	public Revision getLastRevision(K beanId) {
		if (beanId == null)
			throw new NullPointerException();

		Object lock = acquireReadLock();
		try {
			Map<String, TreeMap<Revision, Object>> fieldData = dataByBeanId
					.get(beanId);
			Revision lastRevision = null;
			if (fieldData != null) {
				for (Entry<String, TreeMap<Revision, Object>> entry : fieldData
						.entrySet()) {
					TreeMap<Revision, Object> revisionValueMap = entry
							.getValue();
					Revision fieldLastRevision = revisionValueMap.lastKey();
					if (lastRevision == null
							|| fieldLastRevision.compareTo(lastRevision) > 0) {
						lastRevision = fieldLastRevision;
					}
				}
			}

			return lastRevision;
		} finally {
			releaseLock(lock);
		}
	}

	@Override
	public Revision getLastRevision(K beanId, String fieldName) {
		if (beanId == null)
			throw new NullPointerException();
		if (fieldName == null)
			throw new NullPointerException();

		Object lock = acquireReadLock();
		try {
			Map<String, TreeMap<Revision, Object>> fieldData = dataByBeanId
					.get(beanId);
			TreeMap<Revision, Object> revisionValueMap = fieldData == null ? null
					: fieldData.get(fieldName);
			Revision lastRevision = revisionValueMap == null ? null
					: revisionValueMap.lastKey();
			return lastRevision;
		} finally {
			releaseLock(lock);
		}
	}

	@Override
	public Collection<K> getModifiedBeans() {
		Collection<K> returnValue = new HashSet<>();

		Object lock = acquireReadLock();
		try {
			returnValue.addAll(dataByBeanId.keySet());
			returnValue.removeAll(getIgnorableBeans());
			return returnValue;
		} finally {
			releaseLock(lock);
		}
	}

	@Override
	public void save() throws SaveException {
		if (parent == null)
			throw new RuntimeException(
					"This branch has no parent to save data to.");
		if (readWriteLock.getWriteHoldCount() > 0)
			throw new IllegalStateException(
					"The write lock for this branch is still reserved. Release this lock before saving.");

		BranchListener<K>[] myListeners = getListeners();

		Object myReadLock = acquireReadLock();
		Object parentWriteLock = parent.acquireWriteLock();
		Object parentReadLock = parent.acquireReadLock();
		try {
			try {
				Collection<K> ignoredBeans = getIgnorableBeans();

				List<SaveException> allProblems = validateCommit(ignoredBeans);
				for (BranchListener<K> listener : myListeners) {
					try {
						listener.beforeSave(parent, this);
					} catch (MultipleSaveException mme) {
						allProblems.addAll(mme.getSaveExceptions());
					} catch (SaveException me) {
						allProblems.add(me);
					}
					// any other exceptions: we can throw those to abort the
					// commit
				}

				if (allProblems.size() == 1)
					throw allProblems.get(0);

				if (allProblems.size() > 1)
					throw new MultipleSaveException(this, allProblems);

				Revision r = initialRevision;
				while (r.compareTo(currentRevision) < 0) {
					try {
						commitRevision(r, ignoredBeans);
					} catch (SaveException e) {
						allProblems.add(e);
					} catch (BranchException e) {
						allProblems.add(new SaveException(this, r, e));
					}
					r = r.increment();
				}
			} finally {
				lastCommittedRevision = currentRevision.clone();
				parentRevision = parent.getRevision();

				releaseLock(myReadLock);
				parent.releaseLock(parentWriteLock);
			}

			// we've released the parent's write lock, but we've retained
			// the parent's read lock while our listeners are being notified:

			for (BranchListener<K> listener : myListeners) {
				try {
					listener.branchSaved(parent, this);
				} catch (Exception e) {
					uncaughtExceptionHandler.uncaughtException(
							Thread.currentThread(), e);
				}
			}
		} finally {
			parent.releaseLock(parentReadLock);
		}
	}

	/**
	 * This handler will be notified if a BranchListener throws an exception
	 * during {@link BranchListener#branchSaved(Branch, Branch)}.
	 * <p>
	 * The default handler just called <code>ex.printStackTrace()</code>.
	 * 
	 * @param handler
	 *            the
	 */
	public void setUncaughtExceptionHandler(UncaughtExceptionHandler handler) {
		if (handler == null)
			throw new NullPointerException();

		uncaughtExceptionHandler = handler;
	}

	protected List<SaveException> validateCommit(Collection<K> ignoredBeans) {
		Object myLock = acquireReadLock();
		try {
			List<SaveException> allProblems = new ArrayList<>();
			for (Entry<K, Map<String, TreeMap<Revision, Object>>> beanIdToFieldInfo : dataByBeanId
					.entrySet()) {
				K beanId = beanIdToFieldInfo.getKey();
				if (!ignoredBeans.contains(beanId)) {
					Map<String, TreeMap<Revision, Object>> beanDataByField = beanIdToFieldInfo
							.getValue();
					for (Entry<String, TreeMap<Revision, Object>> fieldNameToRevisions : beanDataByField
							.entrySet()) {
						String fieldName = fieldNameToRevisions.getKey();

						Revision myLastFieldRevision = fieldNameToRevisions
								.getValue().lastKey();
						if (lastCommittedRevision != null
								&& myLastFieldRevision
										.compareTo(lastCommittedRevision) < 0) {
							continue;
						}

						if (FIELD_CREATED.toString().equals(fieldName)
								|| FIELD_DELETED.toString().equals(fieldName)) {
							BeanState myState = getState(beanId);
							BeanState parentState = parent.getState(beanId);
							Revision parentBeanRevision = parent
									.getLastRevision(beanId);
							if (parentBeanRevision != null
									&& parentBeanRevision
											.compareTo(parentRevision) < 0) {
								// no matter what, this is OK: because the
								// parent hasn't further modified the bean
							} else {
								if (myState == parentState) {
									// weird, both branches did the same
									// thing... but OK.
								} else if (myState == BeanState.CREATED
										&& parentState == BeanState.UNDEFINED) {
									// this is fine
								} else if (myState == BeanState.DELETED
										&& (parentState == BeanState.CREATED || parentState == BeanState.EXISTS)) {
									// this is fine
								} else {
									allProblems
											.add(new SaveException(
													this,
													beanId,
													parentRevision,
													"The bean \""
															+ beanId
															+ "\" is classified as "
															+ myState
															+ " in this branch, but it is classified as "
															+ parentState
															+ " in the parent branch."));
								}
							}
						} else {
							Revision parentFieldRevision = parent
									.getLastRevision(beanId, fieldName);
							if (parentFieldRevision != null
									&& parentFieldRevision
											.compareTo(parentRevision) > 0) {
								Object parentValue;
								try {
									parentValue = parent.getField(beanId,
											fieldName);
									Object myValue = fieldNameToRevisions
											.getValue().lastEntry().getValue();
									if (Objects.equals(parentValue, myValue)) {
										// both the parent and this branch set
										// the value to the same thing, so this
										// is OK.
									} else {
										allProblems
												.add(new SaveException(
														this,
														beanId,
														parentFieldRevision,
														"The field \""
																+ fieldName
																+ "\" for bean \""
																+ beanId
																+ "\" was modified in the parent branch (revision "
																+ parentFieldRevision
																+ ")."));
									}
								} catch (MissingBeanException e) {
									// this shouldn't happen since we just
									// established that the bean/field combo
									// exists, right?
									throw new RuntimeException(e);
								}
							}
						}
					}
				}
			}
			return allProblems;
		} finally {
			releaseLock(myLock);
		}
	}

	private Collection<K> getIgnorableBeans() {
		Collection<K> returnValue = new HashSet<>();
		for (Entry<K, Map<String, TreeMap<Revision, Object>>> entry : dataByBeanId
				.entrySet()) {
			Map<String, TreeMap<Revision, Object>> fieldMap = entry.getValue();
			TreeMap<Revision, Object> creation = fieldMap.get(FIELD_CREATED
					.toString());
			TreeMap<Revision, Object> deletion = fieldMap.get(FIELD_DELETED
					.toString());
			if (creation != null && deletion != null) {
				Revision lastCreation = creation.lastKey();
				Revision lastDeletion = deletion.lastKey();
				if (lastDeletion.compareTo(lastCreation) > 0) {
					K bean = entry.getKey();
					returnValue.add(bean);
				}
			}
		}
		return returnValue;
	}

	private void commitRevision(Revision r, Collection<K> ignorableBeans)
			throws DuplicateBeanIdException, MissingBeanException,
			SaveException {
		for (Entry<K, Map<String, TreeMap<Revision, Object>>> beanEntry : dataByBeanId
				.entrySet()) {
			K beanId = beanEntry.getKey();
			if (!ignorableBeans.contains(beanId)) {
				for (Entry<String, TreeMap<Revision, Object>> fieldRevisionEntry : beanEntry
						.getValue().entrySet()) {
					TreeMap<Revision, Object> revisionMap = fieldRevisionEntry
							.getValue();

					Object newValue = revisionMap.get(r);
					boolean isDefined = newValue != null;
					if (isDefined) {
						String fieldName = fieldRevisionEntry.getKey();

						Revision lastFieldRevision = revisionMap.lastKey();

						if (lastCommittedRevision != null
								&& lastFieldRevision != null
								&& lastFieldRevision
										.compareTo(lastCommittedRevision) < 0) {
							continue;
						}

						if (FIELD_CREATED.toString().equals(fieldName)) {
							parent.createBean(beanId);
						} else if (FIELD_DELETED.toString().equals(fieldName)) {
							parent.deleteBean(beanId);
						} else {
							if (newValue == NULL)
								newValue = null;
							try {
								Revision lastFieldParentRevision = parent
										.getLastRevision(beanId, fieldName);
								if (parentRevision != null
										&& lastFieldParentRevision != null
										&& parentRevision
												.compareTo(lastFieldParentRevision) < 0)
									throw new SaveException(parent, beanId,
											lastFieldParentRevision,
											"The field \""
													+ fieldName
													+ "\" on bean \""
													+ beanId
													+ "\" was modified after "
													+ parentRevision.toString()
															.toLowerCase());

								parent.setField(beanId, fieldName, newValue);
							} catch (SaveException e) {
								throw new SaveException(this, beanId, r,
										"The parent branch contained a conflicting value for field \""
												+ fieldName + "\" for bean \""
												+ beanId + "\".");
							}
						}
					}
				}
			}
		}
	}

	@Override
	public Map<String, Object> getBean(K beanId) {
		if (beanId == null)
			throw new NullPointerException();

		Object lock = acquireReadLock();
		try {
			Map<String, Object> returnValue;
			if (parent == null) {
				returnValue = null;
			} else {
				returnValue = parent.getBean(beanId);
			}

			Map<String, TreeMap<Revision, Object>> beanDataByField = dataByBeanId
					.get(beanId);

			if (beanDataByField == null) {
				return returnValue;
			}

			TreeMap<Revision, Object> deletionMap = beanDataByField
					.get(FIELD_DELETED.toString());
			TreeMap<Revision, Object> creationMap = beanDataByField
					.get(FIELD_CREATED.toString());
			Revision lastDeletion = deletionMap == null ? null : deletionMap
					.lastKey();
			Revision lastCreation = creationMap == null ? null : creationMap
					.lastKey();
			if (lastDeletion != null
					&& (lastCreation == null || lastCreation
							.compareTo(lastDeletion) < 0)) {
				return null;
			}

			if (lastCreation != null) {
				returnValue = new HashMap<>();
			}

			for (Entry<String, TreeMap<Revision, Object>> entry : beanDataByField
					.entrySet()) {
				String fieldName = entry.getKey();
				if (!(FIELD_DELETED.toString().equals(fieldName) || FIELD_CREATED
						.toString().equals(fieldName))) {
					Revision fieldRevision = entry.getValue().lastKey();
					if (lastCreation == null
							|| lastCreation.compareTo(fieldRevision) < 0) {
						if (returnValue == null)
							throw new IllegalStateException(
									"Bean data was detected for \""
											+ beanId
											+ "\" (field \""
											+ fieldName
											+ "\", but there is no record of that bean being created.");
						Object value = entry.getValue().lastEntry().getValue();
						if (value == NULL)
							value = null;
						returnValue.put(fieldName, value);
					}
				}
			}

			return returnValue;
		} finally {
			releaseLock(lock);
		}
	}

	@Override
	public String toString() {
		return getName();
	}
}