/*******************************************************************************
 * Copyright (c) 2018 Aston University.
 * 
 * This program and the accompanying materials are made available under the
 * terms of the Eclipse Public License 2.0 which is available at
 * http://www.eclipse.org/legal/epl-2.0.
 *
 * This Source Code may also be made available under the following Secondary
 * Licenses when the conditions for such availability set forth in the Eclipse
 * Public License, v. 2.0 are satisfied: GNU General Public License, version 3.
 *
 * SPDX-License-Identifier: EPL-2.0 OR GPL-3.0
 *
 * Contributors:
 *     Antonio Garcia-Dominguez - initial API and implementation
 ******************************************************************************/
package org.eclipse.hawk.greycat;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.function.Supplier;
import java.util.stream.IntStream;
import java.util.stream.Stream;

import org.eclipse.hawk.core.graph.EmptyIGraphIterable;
import org.eclipse.hawk.core.graph.IGraphEdge;
import org.eclipse.hawk.core.graph.IGraphIterable;
import org.eclipse.hawk.core.graph.IGraphDatabase.Mode;
import org.eclipse.hawk.core.graph.timeaware.ITimeAwareGraphNode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.google.common.collect.BiMap;
import com.google.common.collect.HashBiMap;
import com.google.common.collect.Iterators;

import greycat.Constants;
import greycat.Graph;
import greycat.Node;
import greycat.Type;
import greycat.plugin.NodeState;
import greycat.plugin.Resolver;
import greycat.struct.DoubleArray;
import greycat.struct.IntArray;
import greycat.struct.LongArray;
import greycat.struct.Relation;
import greycat.struct.StringArray;
import greycat.struct.StringIntMap;

public class GreycatNode implements ITimeAwareGraphNode {

	protected static class StreamIterable<T> implements Iterable<T> {
		private Stream<T> stream;

		protected StreamIterable(Stream<T> stream) {
			this.stream = stream;
		}

		@Override
		public Iterator<T> iterator() {
			return stream.iterator();
		}
	}

	private enum Direction {
		IN {
			@Override
			public String getPrefix() {
				return "in_";
			}

			public IGraphEdge convertToEdge(String type, GreycatNode current, GreycatNode other) {
				if (GreycatHeavyEdge.NODETYPE.equals(other.getNodeLabel())) {
					return new GreycatHeavyEdge(other, type);
				}
				return new GreycatLightEdge(other, current, type);
			}
		}, OUT {
			@Override
			public String getPrefix() {
				return "out_";
			}

			@Override
			public IGraphEdge convertToEdge(String type, GreycatNode current, GreycatNode other) {
				if (GreycatHeavyEdge.NODETYPE.equals(other.getNodeLabel())) {
					return new GreycatHeavyEdge(other, type);
				}
				return new GreycatLightEdge(current, other, type);
			}
		};

		public abstract String getPrefix();
		public abstract IGraphEdge convertToEdge(String type, GreycatNode current, GreycatNode other);
	}

	private final class NodeProvider {
		private Graph _graph;
		private Node _node;

		public NodeProvider(Graph graph, Node node) {
			this._graph = graph;
			this._node = node;
		}

		public Node get() {
			if (db.getGraph() == _graph) {
				return _node;
			} else {
				// graph in DB changed: need to free and refetch the node
				_node.free();

				CompletableFuture<Node> result = new CompletableFuture<>();
				db.getGraph().lookup(world, time, id, node -> result.complete(node));
				try {
					_node = result.get();
					_graph = db.getGraph();
				} catch (InterruptedException | ExecutionException e) {
					LOGGER.error(String.format("Could not refetch node %d:%d:%d", world, time, id), e);
				}

				return _node;
			}
		}

		public void markDirty() {
			db.markDirty(GreycatNode.this);
		}

		public void free() {
			if (_node != null) {
				_node.free();
				_node = null;
				_graph = null;
			}
		}
	}

	/**
	 * Encapsulates an access to the underlying node, with implicit free unless
	 * changed since the last save, and implicit saves upon full closing of all
	 * nodes open so far. Allows for nested calls: should be safe as long as we
	 * stick to try-with-resources.
	 */
	private int nestingLevel = 0;
	protected final class NodeReader implements AutoCloseable, Supplier<Node> {
		private NodeReader() {
			++nestingLevel;
			db.markOpen(GreycatNode.this);
		}

		public Node get() {
			return nodeProvider.get();
		}

		public void markDirty() {
			nodeProvider.markDirty();
		}

		@Override
		public void close() {
			if (--nestingLevel <= 0) {
				db.markClosed(GreycatNode.this);
			}
		}
	}

	/** Prefix for all attribute names. Prevents clashes with reserved names. */
	private static final String ATTRIBUTE_PREFIX = "a_";

	/**
	 * Special property used to record actual Java types. Greycat V11 only stores
	 * int, long, double and String scalars and arrays. We have to keep the real
	 * type here and then convert back.
	 * 
	 * Only problem is that we can't really modify arrays in place - we have to
	 * replace them entirely with {@link #setProperty(String, Object)}.
	 */
	private static final String JAVATYPE_PROPERTY = "h_javatypes";
	private static final BiMap<String, Integer> javaTypes = HashBiMap.create();
	static {
		int counter = 0;
		for (Class<?> klass : Arrays.asList(Float.class,
			Double[].class, Float[].class, Long[].class, Integer[].class, Short[].class, Byte[].class,
			double[].class, float[].class, long[].class, int[].class, short[].class, byte[].class)) {
			javaTypes.put(klass.getSimpleName(), counter++);
		}
	}

	/** Greycat does not save empty strings into nodes, instead returning just null for them. We replace them with this placeholder value and map it back afterwards. */
	private static final String EMPTY_STRING_MARKER = "_@_h_empty_@_";

	private static final Logger LOGGER = LoggerFactory.getLogger(GreycatNode.class);

	private final AbstractGreycatDatabase db;
	private final long world, time, id;
	private final NodeProvider nodeProvider;

	/** lazily initialized node label */
	private String nodeLabel;

	protected static int getValueType(Object value) {
		if (value == null) {
			return Type.STRING;
		}

		switch (value.getClass().getSimpleName()) {
		case "boolean":
		case "Boolean":
			return Type.BOOL;
		case "Short":
		case "Byte":
		case "Integer":
			return Type.INT;
		case "Long":
			return Type.LONG;
		case "Float":
		case "Double":
			return Type.DOUBLE;
		case "String":
			return Type.STRING;
		}

		LOGGER.warn("Unknown type: {}, returning Type.STRING", value.getClass().getSimpleName());
		return Type.STRING;
	}

	/**
	 * For nodes that have been newly created and still need to be saved for the first time.
	 */
	public GreycatNode(AbstractGreycatDatabase db, long world, long time, long id, Node node) {
		this.db = db;
		this.world = world;
		this.time = time;
		this.id = id;

		this.nodeProvider = new NodeProvider(db.getGraph(), node);
	}

	@Override
	public Long getId() {
		return id;
	}

	public long getWorld() {
		return world;
	}

	@Override
	public long getTime() {
		return time;
	}

	@Override
	public void end() {
		try (NodeReader rn = getNodeReader()) {
			// 1. Unlink node from next endpoint
			// 2. Unindex node from next endpoint
			// 3. End node lifespan in Greycat

			/*
			 * end() means that the edges and node should still be available at *this*
			 * precise timepoint, but not from the next timepoint onwards.
			 */
			for (IGraphEdge out : travelInTime(time + 1).getOutgoing()) {
				out.delete();
			}
			for (IGraphEdge in : travelInTime(time + 1).getIncoming()) {
				in.delete();
			}

			db.luceneIndexer.remove(travelInTime(time + 1));
			rn.get().end();
		}
	}

	@Override
	public GreycatNode travelInTime(long time) {
		if (time == NO_SUCH_INSTANT) {
			return null;
		}
		return db.lookup(world, time, id);
	}

	public String getNodeLabel() {
		if (nodeLabel == null) {
			try (NodeReader rn = getNodeReader()) {
				nodeLabel = rn.get().get(AbstractGreycatDatabase.NODE_LABEL_IDX).toString();
			}
		}

		return nodeLabel;
	}

	@Override
	public Set<String> getPropertyKeys() {
		try (NodeReader rn = getNodeReader()) {
			final Node n = rn.get();
			final Resolver resolver = n.graph().resolver();
			NodeState state = resolver.resolveState(n);

			final Set<String> results = new HashSet<>();
			state.each((attributeKey, elemType, elem) -> {
				final String resolveName = resolver.hashToString(attributeKey);
				if (resolveName != null && resolveName.startsWith(ATTRIBUTE_PREFIX)) {
					results.add(resolveName.substring(ATTRIBUTE_PREFIX.length()));
				}
			});

			return results;
		}
	}

	protected NodeReader getNodeReader() {
		return new NodeReader();
	}

	@Override
	public boolean isAlive() {
		try (NodeReader rn = getNodeReader()) {
			return rn.get() != null;
		}
	}

	@Override
	public Object getProperty(String name) {
		Object rawValue;
		try (NodeReader rn = getNodeReader()) {
			rawValue = rn.get().get(ATTRIBUTE_PREFIX + name);

			if (rawValue instanceof StringArray) {
				return ((StringArray) rawValue).extract();
			} else if (rawValue instanceof LongArray) {
				final int javaTypeID = getJavaTypesMap(rn).getValue(name);
				final String javaType = javaTypes.inverse().get(javaTypeID);
				final LongArray lArray = (LongArray) rawValue;

				if ("Long[]".equals(javaType)) {
					Long[] ret = new Long[lArray.size()];
					for (int i = 0; i < lArray.size(); i++) {
						ret[i] = lArray.get(i);
					}
					return ret;
				}

				return lArray.extract();
			} else if (rawValue instanceof DoubleArray) {
				final int javaTypeID = getJavaTypesMap(rn).getValue(name);
				final String javaType = javaTypes.inverse().get(javaTypeID);
				final DoubleArray dArray = (DoubleArray) rawValue;

				switch (javaType) {
				case "Double[]": {
					Double[] ret = new Double[dArray.size()];
					for (int i = 0; i < dArray.size(); i++) {
						ret[i] = dArray.get(i);
					}
					return ret;
				}
				case "Float[]": {
					Float[] ret = new Float[dArray.size()];
					for (int i = 0; i < dArray.size(); i++) {
						ret[i] = (float) dArray.get(i);
					}
					return ret;
				}
				case "float[]": {
					float[] ret = new float[dArray.size()];
					for (int i = 0; i < dArray.size(); i++) {
						ret[i] = (float) dArray.get(i);
					}
					return ret;
				}
				}

				return dArray.extract();
			} else if (rawValue instanceof IntArray) {
				final int javaTypeID = getJavaTypesMap(rn).getValue(name);
				final String javaType = javaTypes.inverse().get(javaTypeID);
				final IntArray iArray = (IntArray) rawValue;

				switch (javaType) {
				case "Integer[]": {
					Integer[] ret = new Integer[iArray.size()];
					for (int i = 0; i < iArray.size(); i++) {
						ret[i] = iArray.get(i);
					}
					return ret;
				}
				case "Short[]": {
					Short[] ret = new Short[iArray.size()];
					for (int i = 0; i < iArray.size(); i++) {
						ret[i] = (short) iArray.get(i);
					}
					return ret;
				}
				case "short[]": {
					short[] ret = new short[iArray.size()];
					for (int i = 0; i < iArray.size(); i++) {
						ret[i] = (short) iArray.get(i);
					}
					return ret;
				}
				case "Byte[]": {
					Byte[] ret = new Byte[iArray.size()];
					for (int i = 0; i < iArray.size(); i++) {
						ret[i] = (byte) iArray.get(i);
					}
					return ret;
				}
				case "byte[]": {
					byte[] ret = new byte[iArray.size()];
					for (int i = 0; i < iArray.size(); i++) {
						ret[i] = (byte) iArray.get(i);
					}
					return ret;
				}
				}

				return iArray.extract();
			} else if (rawValue instanceof Double) {
				final int javaTypeID = getJavaTypesMap(rn).getValue(name);
				final String javaType = javaTypes.inverse().get(javaTypeID);

				if ("Float".equals(javaType)) {
					return ((Double) rawValue).floatValue();
				}
			} else if (EMPTY_STRING_MARKER.equals(rawValue)) {
				return "";
			}

			return rawValue;
		}
	}

	@Override
	public void setProperty(String name, Object value) {
		try (NodeReader rn = getNodeReader()) {
			setPropertyRaw(rn, name, value);
		}
	}

	/**
	 * Allows for setting multiple properties at once, in a slightly more efficient way.
	 */
	public void setProperties(Map<String, Object> props) {
		try (NodeReader rn = getNodeReader()) {
			setPropertiesRaw(rn, props);
		}
	}

	protected void setPropertiesRaw(NodeReader rn, Map<String, Object> props) {
		for (Entry<String, Object> entry : props.entrySet()) {
			setPropertyRaw(rn, entry.getKey(), entry.getValue());
		}
	}

	/**
	 * Saves the property, marking as dirty the node but without saving.
	 */
	protected void setPropertyRaw(NodeReader rn, String name, Object value) {
		if (value != null && value.getClass().isArray()) {
			setArrayPropertyRaw(rn, name, value);
		} else if (value instanceof Float) {
			getJavaTypesMap(rn).put(name, javaTypes.get(value.getClass().getSimpleName()));
			rn.get().set(ATTRIBUTE_PREFIX + name, Type.DOUBLE, value);
		} else {
			getJavaTypesMap(rn).remove(name);
			if ("".equals(value)) {
				value = EMPTY_STRING_MARKER;
			}
			rn.get().set(ATTRIBUTE_PREFIX + name, getValueType(value), value);
		}
		rn.markDirty();
	}

	protected void setArrayPropertyRaw(NodeReader rn, String name, Object value) {
		// Save real array type here, so we can convert back in getProperty()
		final String  javaType = value.getClass().getSimpleName();
		final Integer javaTypeID = javaTypes.get(javaType);
		if (javaTypeID != null) {
			final StringIntMap arrayTypes = getJavaTypesMap(rn);
			arrayTypes.put(name, javaTypeID);
		}
		final Node n = rn.get();

		switch (javaType) {
		case "Double[]":
		case "double[]":
		case "Float[]":
		case "float[]":
			DoubleArray dArray = (DoubleArray) n.getOrCreate(ATTRIBUTE_PREFIX + name, Type.DOUBLE_ARRAY);
			dArray.clear();

			switch (javaType) {
			case "Double[]":
				for (Double d : (Double[]) value) {
					if (d != null) {
						dArray.addElement(d);
					}
				}
				break;
			case "double[]":
				for (double d : (double[]) value) {
					dArray.addElement(d);
				}
				break;
			case "Float[]":
				for (Float f : (Float[]) value) {
					if (f != null) {
						dArray.addElement(f);
					}
				}
				break;
			case "float[]":
				for (float f : (float[]) value) {
					dArray.addElement(f);
				}
				break;
			}

			break;
		case "Long[]":
		case "long[]":
			LongArray lArray = (LongArray) n.getOrCreate(ATTRIBUTE_PREFIX + name, Type.LONG_ARRAY);
			lArray.clear();

			if ("Long".equals(javaType)) {
				for (long l : (Long[]) value) {
					lArray.addElement(l);
				}
			} else {
				for (long l : (long[]) value) {
					lArray.addElement(l);
				}
			}

			break;
		case "Integer[]":
		case "int[]":
		case "Short[]":
		case "short[]":
		case "Byte[]":
		case "byte[]":
			IntArray iArray = (IntArray) n.getOrCreate(ATTRIBUTE_PREFIX + name, Type.INT_ARRAY);
			iArray.clear();

			switch (javaType) {
			case "Integer[]":
				for (Integer i : (Integer[])value) {
					if (i != null) {
						iArray.addElement(i);
					}
				}
				break;
			case "int[]":
				for (int i : (int[])value) {
					iArray.addElement(i);
				}
				break;
			case "Short[]":
				for (Short i : (Short[])value) {
					if (i != null) {
						iArray.addElement(i);
					}
				}
				break;
			case "short[]":
				for (short i : (short[])value) {
					iArray.addElement(i);
				}
				break;
			case "Byte[]":
				for (Byte i : (Byte[])value) {
					if (i != null) {
						iArray.addElement(i);
					}
				}
				break;
			case "byte[]":
				for (byte i : (byte[])value) {
					iArray.addElement(i);
				}
				break;
			}

			break;
		case "String[]": {
			StringArray sArray = (StringArray) n.getOrCreate(ATTRIBUTE_PREFIX + name, Type.STRING_ARRAY);
			sArray.clear();
			sArray.addAll((String[]) value);
			break;
		}
		}
	}

	protected StringIntMap getJavaTypesMap(NodeReader rn) {
		StringIntMap value = (StringIntMap) rn.get().get(JAVATYPE_PROPERTY);
		if (value == null) {
			value = (StringIntMap) rn.get().getOrCreate(JAVATYPE_PROPERTY, Type.STRING_TO_INT_MAP);
			rn.markDirty();
		}
		return value;
	}

	@Override
	public Iterable<IGraphEdge> getEdges() {
		try (NodeReader rn = getNodeReader()) {
			return getAllEdges(rn, getAllEdges(rn, new ArrayList<>(), Direction.OUT), Direction.IN);
		}
	}

	@Override
	public IGraphIterable<IGraphEdge> getEdgesWithType(String type) {
		try (NodeReader rn = getNodeReader()) {
			final IGraphIterable<IGraphEdge> inEdges = getEdgesWithType(rn, Direction.IN, type);
			final IGraphIterable<IGraphEdge> outEdges = getEdgesWithType(rn, Direction.OUT, type);

			return new IGraphIterable<IGraphEdge>() {
				@Override
				public Iterator<IGraphEdge> iterator() {
					return Iterators.concat(inEdges.iterator(), outEdges.iterator());
				}

				@Override
				public int size() {
					return inEdges.size() + outEdges.size();
				}

				@Override
				public IGraphEdge getSingle() {
					if (inEdges.size() > 0) {
						return inEdges.getSingle();
					} else {
						return outEdges.getSingle();
					}
				}
			};
		}
	}

	@Override
	public IGraphIterable<IGraphEdge> getOutgoingWithType(String type) {
		try (NodeReader rn = getNodeReader()) {
			return getEdgesWithType(rn, Direction.OUT, type);
		}
	}

	@Override
	public IGraphIterable<IGraphEdge> getIncomingWithType(String type) {
		try (NodeReader rn = getNodeReader()) {
			return getEdgesWithType(rn, Direction.IN, type);
		}
	}

	protected IGraphIterable<IGraphEdge> getEdgesWithType(final NodeReader rn, final Direction dir, String type) {
		final int relationPosition = db.getGraph().resolver().stringToHash(dir.getPrefix() + type, false);
		final Relation relation = (Relation) rn.get().getAt(relationPosition);
		if (relation == null) {
			return new EmptyIGraphIterable<>();
		}

		/*
		 * Do NOT preload all target nodes, unlike traverse - doing so balloons memory
		 * usage in some cases (e.g. going from the file node to the instance nodes).
		 *
		 * Streaming the results should reduce the impact of this call - we may need only
		 * the first few elements anyway.
		 */
		final int nEdges = relation.size();

		return new IGraphIterable<IGraphEdge>() {
			@Override
			public Iterator<IGraphEdge> iterator() {
				return Iterators.transform(
					IntStream.range(0, nEdges).iterator(),
					i -> {
						final long nodeId = relation.get(i);
						final GreycatNode target = db.lookup(world, time, nodeId);
						return dir.convertToEdge(type, GreycatNode.this, target);
					}
				);
			}

			@Override
			public int size() {
				return relation.size();
			}

			@Override
			public IGraphEdge getSingle() {
				final long nodeId = relation.get(0);
				final GreycatNode target = db.lookup(world, time, nodeId);
				return dir.convertToEdge(type, GreycatNode.this, target);
			}
		};
	}

	@Override
	public Iterable<IGraphEdge> getIncoming() {
		try (NodeReader rn = getNodeReader()) {
			return getAllEdges(rn, new ArrayList<>(), Direction.IN);
		}
	}

	@Override
	public Iterable<IGraphEdge> getOutgoing() {
		try (NodeReader rn = getNodeReader()) {
			return getAllEdges(rn, new ArrayList<>(), Direction.OUT);
		}
	}

	protected List<IGraphEdge> getAllEdges(final NodeReader rn, final List<IGraphEdge> results, final Direction dir) {
		final Node n = rn.get();
		final Resolver resolver = n.graph().resolver();
		final NodeState state = resolver.resolveState(n);
		final String prefix = dir.getPrefix();

		state.each((attributeKey, elemType, elem) -> {
			if (elemType == Type.RELATION) {
				final String resolveName = resolver.hashToString(attributeKey);
				if (resolveName.startsWith(prefix)) {
					final String edgeType = resolveName.substring(prefix.length());
					Relation castedRelArr = (Relation) elem;
					for (int j = 0; j < castedRelArr.size(); j++) {
						final GreycatNode targetNode = db.lookup(world, time, castedRelArr.get(j));
						results.add(dir.convertToEdge(edgeType, this, targetNode));
					}
				}
			}
		});
		return results;
	}

	@Override
	public void delete() {
		if (db.currentMode() == Mode.NO_TX_MODE) {
			CompletableFuture<Boolean> cSaved = new CompletableFuture<>();
			db.hardDelete(this, dropped -> cSaved.complete(true));
			cSaved.join();
		} else {
			db.softDelete(this);
		}
	}

	@Override
	public AbstractGreycatDatabase getGraph() {
		return db;
	}

	@Override
	public void removeProperty(String name) {
		try (NodeReader rn = getNodeReader()) {
			rn.get().remove(ATTRIBUTE_PREFIX + name);
			rn.markDirty();
		}
	}

	@Override
	public void finalize() throws Throwable {
		super.finalize();
		nodeProvider.free();
	}

	/**
	 * Returns <code>true</code> if this element has been soft deleted.
	 * If so, it should be ignored by any queries and iterables.
	 */
	protected boolean isSoftDeleted() {
		try (NodeReader rn = getNodeReader()) {
			final Boolean softDeleted = (Boolean) rn.get().get(AbstractGreycatDatabase.SOFT_DELETED_KEY);
			return softDeleted == Boolean.TRUE;
		}
	}

	protected IGraphEdge addEdge(String type, GreycatNode end, Map<String, Object> props) {
		if (props == null || props.isEmpty()) {
			return GreycatLightEdge.create(type, this, end);
		} else {
			return GreycatHeavyEdge.create(type, this, end, props);
		}
	}

	protected static void addOutgoing(String type, final NodeReader rn, final NodeReader ro) {
		rn.get().addToRelation(Direction.OUT.getPrefix() + type, ro.get());
		rn.markDirty();
	}

	protected static void addIncoming(String type, final NodeReader rn, final NodeReader ro) {
		rn.get().addToRelation(Direction.IN.getPrefix() + type, ro.get());
		rn.markDirty();
	}

	protected static void removeOutgoing(String type, final NodeReader rn, final NodeReader ro) {
		rn.get().removeFromRelation(Direction.OUT.getPrefix() + type, ro.get());
		rn.markDirty();
	}

	protected static void removeIncoming(String type, final NodeReader rn, final NodeReader ro) {
		rn.get().removeFromRelation(Direction.IN.getPrefix() + type, ro.get());
		rn.markDirty();
	}

	@Override
	public String toString() {
		return "GreycatNode [world=" + world + ", time=" + time + ", id=" + id + "]";
	}

	@Override
	public int hashCode() {
		final int prime = 31;
		int result = 1;
		result = prime * result + (int) (id ^ (id >>> 32));
		result = prime * result + (int) (time ^ (time >>> 32));
		result = prime * result + (int) (world ^ (world >>> 32));
		return result;
	}

	@Override
	public boolean equals(Object obj) {
		if (this == obj)
			return true;
		if (obj == null)
			return false;
		if (getClass() != obj.getClass())
			return false;
		GreycatNode other = (GreycatNode) obj;
		if (id != other.id)
			return false;
		if (time != other.time)
			return false;
		if (world != other.world)
			return false;
		return true;
	}

	@Override
	public long getLatestInstant() throws Exception {
		try (NodeReader rn = getNodeReader()) {
			final Node n = rn.get();

			CompletableFuture<long[]> result = new CompletableFuture<>();
			n.timepoints(Constants.BEGINNING_OF_TIME, Constants.END_OF_TIME, (value) -> {
				result.complete(value);
			});

			long latest = Constants.BEGINNING_OF_TIME;
			for (long timepoint : result.get()) {
				latest = Math.max(latest, timepoint);
			}
			return latest;
		}
	}

	@Override
	public List<Long> getInstantsBetween(long fromInclusive, long toInclusive) {
		try (NodeReader rn = getNodeReader()) {
			final Node n = rn.get();

			CompletableFuture<long[]> result = new CompletableFuture<>();

			/*
			 * FIXME Greycat says that in timepoints(...), both ends are inclusive, but our
			 * unit tests say otherwise. This is a temporal fix, with some caution around
			 * overflow.
			 */
			n.timepoints(fromInclusive, toInclusive < Constants.END_OF_TIME ? toInclusive + 1 : toInclusive, (value) -> {
				result.complete(value);
			});

			List<Long> instants = new ArrayList<>();
			for (long instant : result.get()) {
				instants.add(instant);
			}
			return instants;
		} catch (InterruptedException e) {
			LOGGER.error(e.getMessage(), e);
			Thread.currentThread().interrupt();
			return Collections.emptyList();
		} catch (ExecutionException e) {
			LOGGER.error(e.getMessage(), e);
			return Collections.emptyList();
		}
	}

	@Override
	public long getEarliestInstant() throws Exception {
		try (NodeReader rn = getNodeReader()) {
			final Node n = rn.get();

			CompletableFuture<long[]> result = new CompletableFuture<>();
			n.timepoints(Constants.BEGINNING_OF_TIME, Constants.END_OF_TIME, (value) -> {
				result.complete(value);
			});

			long earliest = Constants.END_OF_TIME;
			for (long timepoint : result.get()) {
				earliest = Math.min(earliest, timepoint);
			}
			return earliest;
		}
	}

	@Override
	public long getPreviousInstant() throws Exception {
		try (NodeReader rn = getNodeReader()) {
			final Node n = rn.get();

			CompletableFuture<long[]> result = new CompletableFuture<>();

			/*
			 * While the GreyCat javadocs say that both ends of the timepoints range are
			 * inclusive, the TimeAwareBackendTest#nextPrev method showed that the end of
			 * the range is exclusive.
			 */
			n.timepoints(Constants.BEGINNING_OF_TIME, getTime(),
				(value) -> result.complete(value));

			/*
			 * We assume timepoints(...) produces elements from newest to oldest. The
			 * previous instant is the most recent moment before the current one.
			 */
			final long[] instants = result.get();
			if (instants.length == 0) {
				return NO_SUCH_INSTANT;
			}
			return instants[0];
		}
	}

	@Override
	public long getNextInstant() throws Exception {
		try (NodeReader rn = getNodeReader()) {
			final Node n = rn.get();

			CompletableFuture<long[]> result = new CompletableFuture<>();
			n.timepoints(getTime() + 1, Constants.END_OF_TIME, (value) -> {
				result.complete(value);
			});

			// See getPreviousInstant() for our assumptions
			final long[] instants = result.get();
			if (instants.length == 0) {
				return NO_SUCH_INSTANT;
			}
			return instants[instants.length - 1];
		}
	}

	@Override
	public List<Long> getInstantsFrom(long fromInclusive) {
		return getInstantsBetween(fromInclusive, Constants.END_OF_TIME);
	}

	@Override
	public List<Long> getInstantsUpTo(long toInclusive) {
		return getInstantsBetween(Constants.BEGINNING_OF_TIME, toInclusive);
	}

	@Override
	public List<Long> getAllInstants() throws Exception {
		return getInstantsBetween(Constants.BEGINNING_OF_TIME, Constants.END_OF_TIME);
	}

}
