package eu.dnetlib.r2d2.neo4j.dao;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;

import javax.annotation.Resource;

import org.apache.log4j.Logger;
import org.neo4j.graphdb.Direction;
import org.neo4j.graphdb.GraphDatabaseService;
import org.neo4j.graphdb.Node;
import org.neo4j.graphdb.Relationship;
import org.neo4j.graphdb.RelationshipType;
import org.neo4j.graphdb.ReturnableEvaluator;
import org.neo4j.graphdb.StopEvaluator;
import org.neo4j.graphdb.TraversalPosition;
import org.neo4j.graphdb.Traverser;
import org.neo4j.graphdb.Traverser.Order;
import org.neo4j.index.lucene.LuceneFulltextIndexService;
import org.neo4j.index.lucene.LuceneIndexService;
import org.springframework.transaction.annotation.Transactional;

import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;

import eu.dnetlib.miscutils.collections.MappedCollection;
import eu.dnetlib.miscutils.functional.UnaryFunction;
import eu.dnetlib.miscutils.iterators.IterableIterator;
import eu.dnetlib.r2d2.neo4j.BeanDao;
import eu.dnetlib.r2d2.neo4j.Neo4jBean;
import eu.dnetlib.r2d2.neo4j.SearchResults;
import eu.dnetlib.r2d2.neo4j.util.ChainIterable;
import eu.dnetlib.r2d2.neo4j.util.TransactionStatusHolder;
import eu.dnetlib.r2d2.neo4j.util.UUIDIdBroker;

/**
 * 
 * @author antleb
 *
 * @param <B>
 */

@Transactional
public abstract class Neo4JDao<B extends Neo4jBean> implements BeanDao<B> {
	private Logger logger = Logger.getLogger(this.getClass());
	
	protected GraphDatabaseService graphDb = null;
	private LuceneIndexService index = null;
	private LuceneFulltextIndexService fulltextIndex = null;
	
	private IdBroker idBroker = null;

	private Node subNode = null;
	
	private RelationshipType rootToSubNodeRelation = null;
	private RelationshipType subNodeToNodesRelation = null;
	
	private Class<B> beanClass = null;
	
	private static Map<Class<? extends Neo4jBean>, ExterminationPolicy> policyMap = 
		new HashMap<Class<? extends Neo4jBean>, ExterminationPolicy>();
	
	/**
	 * Since the id index is updated at the end of the transaction, we keep
	 * the new nodes of the current transaction in a temp variable.
	 */
	public static final ThreadLocal<Map<String, Node>> createdNodes = new ThreadLocal<Map<String, Node>>() {
		protected Map<String, Node> initialValue() {
			return new HashMap<String, Node>();
		}
	};
	
	/**
	 * Deleted nodes become unavailable (from the id index) only at transaction
	 * end. We keep the deleted nodes at a temp variable. The key is the noo4j
	 * node id and dhe value of the map is the node prefix.
	 */
	public static final ThreadLocal<Map<Long, String[]>> deletedNodes = new ThreadLocal<Map<Long, String[]>>() {
		protected Map<Long, String[]> initialValue() {
			return new HashMap<Long, String[]>();
		}
	};
	
	/**
	 * Keep track of the deleted relationships...
	 */
	public static final ThreadLocal<List<Long>> deletedRelations = new ThreadLocal<List<Long>>() {
		protected List<Long> initialValue() {
			return new ArrayList<Long>();
		}
	};
	
	public Neo4JDao(RelationshipType rootToSubNodeRelation, RelationshipType subNodeToNodesRelation, Class<B> beanClass) {
		this(rootToSubNodeRelation, subNodeToNodesRelation, beanClass, ExterminationPolicy.DO_NOTHING);
	}
	
	public Neo4JDao(RelationshipType rootToSubNodeRelation, RelationshipType subNodeToNodesRelation, Class<B> beanClass, ExterminationPolicy policy) {
		this.rootToSubNodeRelation = rootToSubNodeRelation;
		this.subNodeToNodesRelation = subNodeToNodesRelation;
		this.beanClass = beanClass;
		policyMap.put(beanClass, policy);
	}
	
	protected abstract B createBean();
	
	public static String getPrefixForClass(Class<? extends Neo4jBean> clazz) {
		return clazz.getName();
	}
	
	private ExterminationPolicy getExterminationPolicy(Class<?  extends Neo4jBean> beanClass) {
		ExterminationPolicy policy = policyMap.get(beanClass);
		
		if (policy == null)
			policy = ExterminationPolicy.DO_NOTHING;
			
			return policy;
	}
	
	@Transactional
	public void init() {
		logger.debug("initializing");

		if (this.idBroker == null) {
			logger.warn("id broker is null, using default one");
			this.idBroker = new UUIDIdBroker();
		}
		
		Relationship rel = graphDb.getReferenceNode().getSingleRelationship(rootToSubNodeRelation, Direction.OUTGOING);

		if (rel == null) {
			subNode = graphDb.createNode();
			
			// the following properties are not used, but are set to avoid
			// adding complex if/then clauses in the transaction event handler.
			subNode.setProperty(Neo4jBean.ID, idBroker.generateId());
			subNode.setProperty(Neo4jBean.PREFIX, rootToSubNodeRelation.toString());
			
			graphDb.getReferenceNode().createRelationshipTo(subNode, rootToSubNodeRelation);
			
			TransactionStatusHolder.setWriteStatus();
		} else {
			subNode = rel.getEndNode();
		}
	}

	@Override
	public B newBean() {
		return newBean(idBroker.generateId());
	}

	@Override
	@SuppressWarnings("unchecked")
	public Class<? extends Neo4jBean> getNodeType(String beanId) {
		Node node = this.getNode(beanId);

		if (node == null)
			return null;
		
		try {
			return (Class<? extends Neo4jBean>) Class.forName((String) node.getProperty(Neo4jBean.PREFIX));
		} catch (ClassNotFoundException e) {
			throw new RuntimeException("Unknown type (" + node.getProperty(Neo4jBean.PREFIX) + ") of node " + beanId, e);
		}
	}

	@Override
	public boolean beanExists(String beanId) {
		return this.getNode(beanId) != null;
	}
	
	@Override
	public B newBean(String id) {
		B b = this.createBean();
		
		b.setNode(this.newNode());
		b.setId(id);
		b.setValue(Neo4jBean.PREFIX, getPrefixForClass(beanClass));
		
		createdNodes.get().put(b.getId(), b.getNode());
		
		TransactionStatusHolder.setWriteStatus();
		
		return b;
	}
	
	@Override
	public void deleteBean(String beanId) {
		logger.debug("deleting bean " + beanId);
		
		Node node = this.getNode(beanId);
		Class<? extends Neo4jBean> beanType = this.getNodeType(beanId);
		
		if (node == null) {
			// node has already been deleted
			return;
		}
		
		String prefix = (String) node.getProperty(Neo4jBean.PREFIX, null);
		
		// it's impossible to delete the node and then update the fulltext index.
		// Updating the index here instead of the TransactionListener.
		index.removeIndex(node, Neo4jBean.ID);
		fulltextIndex.removeIndex(node, prefix + ".*");
		for (String key:node.getPropertyKeys())
			fulltextIndex.removeIndex(node, key);
		
		// update the lists of newly created and deleted nodes during this
		// transaction.
		if (createdNodes.get().containsKey(beanId))
			createdNodes.get().remove(beanId);
		else
			deletedNodes.get().put(node.getId(), new String[]{beanId, prefix});

		// for each relation, check if connected nodes should also be deleted
		// and then delete the relation.
		for (Relationship rel:node.getRelationships()) {
			Node otherNode = rel.getOtherNode(node);
			Direction dir = (otherNode.getId() == rel.getEndNode().getId())?Direction.OUTGOING:Direction.INCOMING;
			
			// if the other end of the relation is a "normal" node (prefix is a
			// class name and not _*), and the policy for this bean class
			// allows it, delete the other end.
			
			logger.debug("Found relation " + rel.getType() + ", dir " + dir + " to/from " + otherNode.getProperty(Neo4jBean.PREFIX));
			
			if (!((String) otherNode.getProperty(Neo4jBean.PREFIX, null)).startsWith("_")) {
				logger.debug("Node is not a sub node. examining...");
				logger.debug("bean type: " + beanType);
				ExterminationPolicy policy = this.getExterminationPolicy(beanType);
				logger.debug("Policy used: " + policy.getName());
				
				if (policy.encounteredRelation(node, otherNode, dir, rel)) { 
					logger.debug("deleting related node");
					this.deleteBean((String) otherNode.getProperty(Neo4jBean.ID, null));
				} else {
					logger.debug("Policy is policy. leaving it to live for another day.");
				}
			} else {
				logger.debug("node is a subnode. leaving it be...");
			}
			
			if (!deletedRelations.get().contains(rel.getId())) {
				deletedRelations.get().add(rel.getId());
				rel.delete();
			}
		}
		
		// finally, delete the node.
		node.delete();
		
		TransactionStatusHolder.setWriteStatus();
	}
	
	@Override
	public B getBean(String beanId) {
		logger.debug("Getting profile with id " + beanId);
		
		Node node = this.getNode(beanId);
		
		if (node != null)
			return newBean(node);
		else
			return null;
	}
	
	@Override
	public String saveBean(B bean) {
		String id = bean.getId();
		
		return id;
	}
	
	@Override
	public Iterable<B> getAll() {
		logger.debug("Returning all users");
		
		Traverser trav = subNode.traverse(
				Order.DEPTH_FIRST, 
				StopEvaluator.DEPTH_ONE,
				ReturnableEvaluator.ALL_BUT_START_NODE, 
				subNodeToNodesRelation,
				Direction.OUTGOING);
		
		return new BeanIterable(trav);
	}
	
	@Override
	public Iterable<B> search(String term, String... fields) {
		if (term == null || term.trim().equals(""))
			return this.getAll();
		
		ChainIterable<B> iter = new ChainIterable<B>();
		
		for (String field:fields) {
			String propertyName = getPrefixForClass(beanClass) + "." + field;
			logger.debug("Searching for " + term + " in " + propertyName);
			
			iter.addIterable(new BeanIterable(fulltextIndex.getNodes(propertyName, term)));
		}

		return iter;
	}
	
	@Override
	public Iterable<B> search(String term) {
		if (term == null || term.trim().equals(""))
			return this.getAll();
		
		String propertyName = getPrefixForClass(beanClass) + ".*";
		
		logger.debug("Searching for " + term + " in " + propertyName);
		
		return new BeanIterable(fulltextIndex.getNodes(propertyName, term));
	}
	
	public SearchResults<B> search(int from, int size, String term) {
		if (term == null || term.trim().equals(""))
			return this.getAll(from, size);
		
		Iterable<B> iter = this.search(term);

		return consumeIterator(from, size, iter.iterator());
	}
	
	public SearchResults<B> search(int from, int size, String term, String... fields) {
		if (term == null || term.trim().equals(""))
			return this.getAll(from, size);
		
		Iterable<B> iter = this.search(term, fields);
		return consumeIterator(from, size, iter.iterator());
	}
	
	public SearchResults<B> getAll(int from, int count) {
		logger.debug("Returning " + count + " beans " + " starting from" + from);
		
		RangedEvaluator re = new RangedEvaluator(from, count);
		Traverser trav = subNode.traverse(
				Order.DEPTH_FIRST, 
				StopEvaluator.DEPTH_ONE,
				re, 
				subNodeToNodesRelation,
				Direction.OUTGOING);

		List<B> res = new ArrayList<B>();
		
		for (Node n:trav)
			res.add(this.newBean(n));
		
		return new SearchResults<B>(res, re.getCount());
	}
	
	private SearchResults<B> consumeIterator(int from, int size,
			Iterator<B> iter) {
		List<B> res = new ArrayList<B>();
		int count = 0;
		
		while (iter.hasNext()) {
			B bean = iter.next();
			
			if (count >= from && count < size + from)
				res.add(bean);
			
			count++;
		}
		
		return new SearchResults<B>(res, count);
	}
	
	private Node newNode() {
		Node node = graphDb.createNode();

		subNode.createRelationshipTo(node, subNodeToNodesRelation);
		
		TransactionStatusHolder.setWriteStatus();
		
		return node;
	}
	
	public Iterable<B> findRelatedNodes(String targetId, RelationshipType rel, Direction direction) {
		Node node = this.getNode(targetId);
		if(node == null)
			return Lists.newArrayList();
		
		Traverser trav = node.traverse(
				Order.DEPTH_FIRST, 
				StopEvaluator.DEPTH_ONE,
				ReturnableEvaluator.ALL_BUT_START_NODE, 
				rel,
				direction);
		
		return new BeanIterable(trav);
	}
	
//	/**
//	 * For backwards compat.
//	 * 
//	 * @param targetId
//	 * @param rel
//	 * @return
//	 */
//	public Iterable<B> findRelatedNodes(String targetId, RelationshipType rel) {
//		return findRelatedNodes(targetId, rel, Direction.INCOMING);
//	}
	
	/**
	 * For symmetry with findRelatedNodesOutgoing.
	 * 
	 * @param targetId
	 * @param rel
	 * @return
	 */
	public Iterable<B> findRelatedNodesIncoming(String targetId, RelationshipType rel) {
		return findRelatedNodes(targetId, rel, Direction.INCOMING);
	}
	
	/**
	 * FIX: ugly name, I don't know if you intended it this way, but I needed it to get all members of a group, since
	 * findRelatedNodes counts only INCOMING arcs.
	 * 
	 * @param targetId
	 * @param rel
	 * @return
	 */
	public Iterable<B> findRelatedNodesOutgoing(String targetId, RelationshipType rel) {
		return findRelatedNodes(targetId, rel, Direction.OUTGOING);
	}
	
	protected void createRelationship(String sourceId, String targetId, RelationshipType relation) {
		logger.debug("Creating relation of type " + relation + " from " + sourceId + " to " + targetId);
		
		Node sourceNode = this.getNode(sourceId);
		Node targetNode = this.getNode(targetId);
		
		if (sourceNode == null)
			throw new IllegalStateException("no such source node: " + sourceId);
		
		if (targetNode == null)
			throw new IllegalStateException("no such target node: " + targetId);
		
		sourceNode.createRelationshipTo(targetNode, relation);
		
		TransactionStatusHolder.setWriteStatus();
	}
	
	protected void removeRelationship(String sourceId, String targetId, RelationshipType relation) {
		logger.debug("Removing relation of type " + relation + " from " + sourceId + " to " + targetId);
		
		Node sourceNode = this.getNode(sourceId);
		
		// search for relations from group node to user nodes
		for (Relationship rel:sourceNode.getRelationships(relation, Direction.OUTGOING)) {
			if (rel.getEndNode().getProperty(Neo4jBean.ID).equals(targetId)) {
				logger.debug(relation + " found. Deleting it");
				
				rel.delete();
				
				TransactionStatusHolder.setWriteStatus();
				
				break;
			}
		}
	}
	
	protected Node getNode(String nodeId) {
		logger.debug("Returning node with id " + nodeId);
		
		if (deletedNodes.get().containsKey(nodeId)) {
			logger.debug("Node is deleted. Returning null");
			return null;
		}
		
		Node n = createdNodes.get().get(nodeId);
		
		if (n == null) {
			n = index.getSingleNode(Neo4jBean.ID, nodeId);
		} else {
			logger.debug("Node was found in the new list");
		}
		
		return n;
	}
	
	private final B newBean(Node node) {
		B b = this.createBean();
		
		b.setNode(node);
		
		return b;
	}
	
	@Resource
	public void setGraphDb(GraphDatabaseService graphDb) {
		this.graphDb = graphDb;
	}
	
	@Resource
	public void setIdBroker(IdBroker idBroker) {
		this.idBroker = idBroker;
	}
	
	@Resource
	public void setIndex(LuceneIndexService index) {
		this.index = index;
	}
	
	@Resource
	public void setFulltextIndex(LuceneFulltextIndexService fulltextIndex) {
		this.fulltextIndex = fulltextIndex;
	}

	/**
	 * Like BeanIterator but implements the UnaryFunction interface so that it can be used with MappedCollection
	 * to return an Iterable instead of a Iterator, which in turn can be used for java 5 foreach syntax.
	 * 
	 * <p>I have the feeling the the neo4j api is built on top of iterator (with reason), so this stuff perhaps is not
	 * needed, but let's keep it here it can be useful.</p>
	 * 
	 * @author marko
	 *
	 */
	class BeanTransformer implements UnaryFunction<B, Node> {
		@Override
		public B evaluate(Node arg) {
			return newBean(arg);
		}
	}
	
	/**
	 * Returns an iterable of beans from an iterable of nodes. The beans are constructed lazyly when consumed.
	 * 
	 * @param nodes
	 * @return
	 */
	public Iterable<B> asBeans(Iterable<Node> nodes) {
		return new MappedCollection<B, Node>(nodes, new BeanTransformer());
	}
	
	/**
	 * Returns an iterator which transforms lazyly nodes to beans.
	 * 
	 * @param nodes
	 * @return
	 */
	public Iterator<B> asBeans(Iterator<Node> nodes) {
		return new MappedCollection<B, Node>(new IterableIterator<Node>(nodes), new BeanTransformer()).iterator();
	}
	
	/**
	 * Wraps the Iterable<Node> returned by neo4j and converts it to Iterable<B>.
	 * 
	 * The IterableIterator<B> has the disadvantage that the iterator it returns
	 * can only be used once and will fail if you call iterable.iterator() a 
	 * second time.
	 * 
	 * @author antleb
	 *
	 */
	class BeanIterable implements Iterable<B> {
		
		private Iterable<Node> iterable = null;
		
		public BeanIterable(Iterable<Node> iterable) {
			this.iterable = iterable;
		}

		@Override
		public Iterator<B> iterator() {
			return new BeanIterator(iterable.iterator());
		}
	}
	
	/**
	 * IMHO we should use "asBeans" helper. Feel free to remove the deprecated annotations if you really don't like it.
	 * 
	 * @author marko
	 *
	 */
	class BeanIterator implements Iterator<B> {
		private Iterator<Node> nodes = null;
		
		public BeanIterator(Iterator<Node> iterator) {
			this.nodes = iterator;
		}

		@Override
		public boolean hasNext() {
			return nodes.hasNext();
		}

		@Override
		public B next() {
			return newBean(nodes.next());
		}

		@Override
		public void remove() {
			nodes.remove();
		}
	}
	
	class RangedEvaluator implements ReturnableEvaluator {
		private int from = 0;
		private int size = 0;
		private int count = 0;

		public RangedEvaluator(int from, int size) {
			this.from = from;
			this.size = size;
		}

		public boolean isReturnableNode(TraversalPosition arg0) {
			boolean ret = false;
			
			if (count >= from && count < size + from && arg0.depth() == 1)
				ret = true;
			
			if (arg0.depth() == 1)
				count++;
			
			return ret;
		}

		public int getCount() {
			return count;
		}
	}
}