/*******************************************************************************
 * Copyright (c) 2011-2019 The University of York, 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:
 *     Konstantinos Barmpis - initial API and implementation
 *     Antonio Garcia-Dominguez - collect errors in list instead of printing them
 ******************************************************************************/
package org.eclipse.hawk.graph.syncValidationListener;

import java.io.File;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URLDecoder;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
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 org.eclipse.hawk.core.IModelIndexer;
import org.eclipse.hawk.core.VcsCommitItem;
import org.eclipse.hawk.core.graph.IGraphChangeListener;
import org.eclipse.hawk.core.graph.IGraphDatabase;
import org.eclipse.hawk.core.graph.IGraphEdge;
import org.eclipse.hawk.core.graph.IGraphNode;
import org.eclipse.hawk.core.graph.IGraphNodeIndex;
import org.eclipse.hawk.core.graph.IGraphTransaction;
import org.eclipse.hawk.core.model.IHawkAttribute;
import org.eclipse.hawk.core.model.IHawkClass;
import org.eclipse.hawk.core.model.IHawkModelResource;
import org.eclipse.hawk.core.model.IHawkObject;
import org.eclipse.hawk.core.model.IHawkPackage;
import org.eclipse.hawk.core.model.IHawkReference;
import org.eclipse.hawk.core.runtime.BaseModelIndexer;
import org.eclipse.hawk.graph.ModelElementNode;
import org.eclipse.hawk.graph.ProxyReferenceList;
import org.eclipse.hawk.graph.ProxyReferenceList.ProxyReference;
import org.eclipse.hawk.graph.ProxyReferenceTarget;
import org.eclipse.hawk.graph.updater.GraphModelBatchInjector;
import org.eclipse.hawk.graph.updater.GraphModelInserter;
import org.eclipse.hawk.graph.updater.GraphModelUpdater;

public class SyncValidationListener implements IGraphChangeListener {

	private static final double THRESHOLD_DIFFERENT_DOUBLES = 1e-4;

	public class ValidationError {
		private final String message;

		public ValidationError(String message) {
			this.message = message;
		}

		public String getMessage() {
			return message;
		}
	}

	private BaseModelIndexer hawk;

	// stats
	private List<ValidationError> errors = new ArrayList<>();
	private int removedProxies, deleted, malformed, singletonCount, totalGraphSize, totalResourceSizes;

	private Set<String> changed = new HashSet<>();

	private IGraphNodeIndex singletonIndex;
	private boolean singletonIndexIsEmpty;

	public SyncValidationListener() {
		// osgi constructor
	}

	@Override
	public void setModelIndexer(IModelIndexer hawk) {
		this.hawk = (BaseModelIndexer) hawk;

		if (this.hawk != null) {
			System.err.println("SyncValidationListener: hawk.setSyncMetricsEnabled(true) called, performance will suffer!");
			hawk.setSyncMetricsEnabled(true);
		}
	}

	@Override
	public String getName() {
		return "Sync Validation Listener";
	}

	@Override
	public void synchroniseStart() {
		// nothing to do
	}

	@Override
	public void synchroniseEnd() {
		try {
			if (hawk != null) {
				validateChanges();
			}
		} catch (URISyntaxException e) {
			e.printStackTrace();
		}

		deleted = 0;
		changed.clear();
	}

	public List<ValidationError> getErrors() {
		return errors;
	}

	public int getTotalErrors() {
		return errors.size();
	}

	private void validateChanges() throws URISyntaxException {
		assert this.hawk != null : "validateChanges() should only be called if the indexer has been set";

		System.err.println("sync metrics:");
		System.err.println("interesting\t" + hawk.getInterestingFiles());
		System.err.println("deleted\t\t" + hawk.getDeletedFiles());
		System.err.println("changed\t\t" + hawk.getCurrChangedItems());
		System.err.println("loaded\t\t" + hawk.getLoadedResources());
		System.err.println("c elems\t\t" + latestChangedElements());
		System.err.println("d elems\t\t" + latestDeletedElements());
		System.err.println("time\t\t~" + hawk.getLatestSynctime() / 1000 + "s");

		System.err.println("validating changes...");

		errors.clear();
		removedProxies = 0;
		malformed = 0;
		singletonCount = 0;
		totalResourceSizes = 0;
		totalGraphSize = 0;

		final URI tempURI = new File(hawk.getGraph().getTempDir()).toURI();

		// for all non-null resources
		if (hawk.getFileToResourceMap() != null) {
			for (VcsCommitItem c : hawk.getFileToResourceMap().keySet()) {
				validateChanges(tempURI, c);
			}
		}

		System.err.println("changed resource size: " + totalResourceSizes);
		System.err.println("relevant graph size: "
				+ totalGraphSize
				+ (singletonCount > 0 ? (" + singleton count: " + singletonCount) : ""));

		if (totalGraphSize + singletonCount != totalResourceSizes) {
			errors.add(new ValidationError(
				String.format("Mismatched resource size:  %d + %d != %d", totalGraphSize, singletonCount, totalResourceSizes)
			));
		}

		System.err.println("validated changes... "
				+ (errors.isEmpty() ? "true"
						: ((errors.size() == malformed) + " (with "
								+ errors.size() + " total and "
								+ malformed + " malformed errors)"))
				+ (removedProxies == 0 ? "" : " [" + removedProxies
						+ "] unresolved hawk proxies matched"));
	}

	protected void validateChanges(final URI tempURI, final VcsCommitItem c) throws URISyntaxException {
		final String repository = c.getCommit().getDelta().getManager().getLocation();
		final String repoURL = repository;

		final IHawkModelResource r = hawk.getFileToResourceMap().get(c);
		if (r == null) {
			/*
			 * file didnt get parsed so no changes are made -- any way to verify this
			 * further?
			 */
			return;
		}

		System.out.println("validating file " + c.getChangeType() + " for " + c.getPath());

		final IGraphDatabase graph = hawk.getGraph();
		try (IGraphTransaction t = graph.beginTransaction()) {
			singletonIndex = graph.getOrCreateNodeIndex(GraphModelBatchInjector.FRAGMENT_DICT_NAME);
			singletonIndexIsEmpty = !singletonIndex.query("*", "*").iterator().hasNext();

			String file = null;
			IGraphNode filenode = null;
			try {
				file = repository + GraphModelUpdater.FILEINDEX_REPO_SEPARATOR + c.getPath();
				filenode = graph.getFileIndex().get("id", file).getSingle();
			} catch (Exception ee) {
				errors.add(new ValidationError(
					String.format(
						"Expected file %s but it did not exist "
						+ "(maybe metamodel not registered, if so expect +1 errors)",
						file)
				));
				return;
			}

			// cache model elements in current resource
			Map<String, IHawkObject> eObjectCache = new HashMap<>();
			// cache of malformed object identifiers (to ignore references)
			Set<String> malformedObjectCache = new HashSet<>();
			cacheModelElements(c, r, eObjectCache, malformedObjectCache);

			// go through all nodes in graph from the file the resource is in
			for (IGraphEdge instanceEdge : filenode.getIncomingWithType(ModelElementNode.EDGE_LABEL_FILE)) {
				final IGraphNode instance = instanceEdge.getStartNode();
				totalGraphSize++;

				final IHawkObject eobject = eObjectCache.get(instance.getProperty(IModelIndexer.IDENTIFIER_PROPERTY));

				// if a node cannot be found in the model cache
				if (eobject == null) {
					// this triggers when a malformed model has 2 identical identifiers
					errors.add(new ValidationError(
						String.format("Graph contains node with identifier: %s but resource does not!", instance.getProperty(IModelIndexer.IDENTIFIER_PROPERTY))
					));
				} else {
					eObjectCache.remove(instance.getProperty(IModelIndexer.IDENTIFIER_PROPERTY));

					if (!malformedObjectCache.contains(eobject.getUri())) {
						compareAttributes(instance, eobject);
						compareReferences(tempURI, repoURL, instance, eobject);
					}
				}
			}

			// if there are model elements not found in nodes
			if (eObjectCache.size() > 0) {
				errors.add(new ValidationError(
					String.format("The following objects were not found in the graph:\n%s", eObjectCache.keySet())
				));
			}

			t.success();
		} catch (Exception e) {
			System.err.println("syncValidationListener transaction error:");
			e.printStackTrace();
		}
	}

	protected void cacheModelElements(VcsCommitItem commitItem, IHawkModelResource modelResource,
			Map<String, IHawkObject> eobjectCache, Set<String> malformedObjectCache) {
		for (IHawkObject content : modelResource.getAllContents()) {
			IHawkObject old = eobjectCache.put(content.getUriFragment(), content);
			if (old != null) {
				if (!singletonIndexIsEmpty && singletonIndex.get("id", content.getUriFragment()).iterator().hasNext()) {
					singletonCount++;
				} else {
					System.err.println("warning (" + commitItem.getPath() + ") eobjectCache replaced:");
					System.err.println(old.getUri() + " | " + old.getUriFragment() + " | ofType: " + old.getType().getName());
					System.err.println("with:");
					System.err.println(content.getUri() + " | " + content.getUriFragment() + " | ofType: " + content.getType().getName());

					malformedObjectCache.add(old.getUri());
					malformed++;

					System.err
							.println("WARNING: MALFORMED MODEL RESOURCE (multiple identical identifiers for:\n"
									+ old.getUri()
									+ "),\nexpect "
									+ malformed
									+ " objects in validation.");

				}
			}

			totalResourceSizes++;
		}
	}

	protected void compareReferences(final URI tempURI, final String repoURL, final IGraphNode instance, final IHawkObject eobject) throws URISyntaxException {
		final Map<String, Set<String>> modelReferences = computeModelReferences(tempURI, repoURL, eobject);
		final Map<String, Set<String>> nodereferences = computeNodeReferences(repoURL, instance);

		// compare model and graph reference maps
		final Iterator<Entry<String, Set<String>>> rci = modelReferences.entrySet().iterator();
		while (rci.hasNext()) {
			final Entry<String, Set<String>> modelRef = rci.next();
			final String modelRefName = modelRef.getKey();
			if (!nodereferences.containsKey(modelRefName)) {
				continue;
			}

			final Set<String> noderefvalues = new HashSet<>(nodereferences.get(modelRefName));
			final Set<String> modelrefvalues = new HashSet<>(modelReferences.get(modelRefName));

			final Set<String> noderefvaluesclone = new HashSet<>(noderefvalues);
			noderefvaluesclone.removeAll(modelrefvalues);

			Set<String> modelrefvaluesclone = new HashSet<>(modelrefvalues);
			modelrefvaluesclone.removeAll(noderefvalues);
			modelrefvaluesclone = removeHawkProxies(instance, modelrefvaluesclone);

			filterFragmentBasedReferences(noderefvaluesclone, modelrefvaluesclone);

			if (noderefvaluesclone.size() > 0) {
				final IGraphNode fileNode = instance.getOutgoingWithType(ModelElementNode.EDGE_LABEL_FILE).iterator().next().getEndNode();
				errors.add(new ValidationError(
					String.format(
						"Reference %s of node: %s\n"
						+ "Located: %s\n%s\n"
						+ "The above IDs were found in the graph but not the model",
						modelRefName, instance.getProperty(IModelIndexer.IDENTIFIER_PROPERTY),
						fileNode, noderefvaluesclone)
				));
			}

			if (modelrefvaluesclone.size() > 0) {
				errors.add(new ValidationError(
					String.format("Reference %s of model element: %s\n"
							+ "Located: %s\n%s\n"
							+ "The above IDs were found in the model but not the graph",
							modelRefName, eobject.getUriFragment(),
							eobject.getUri(), modelrefvaluesclone)
				));
			}

			nodereferences.remove(modelRefName);
			// rci.remove();
		}

		if (nodereferences.size() > 0) {
			errors.add(new ValidationError(String.format(
				"References %s had targets in the graph but not in the model:\n%s",
				nodereferences.keySet(), nodereferences)
			));
		}
	}

	protected void filterFragmentBasedReferences(final Set<String> noderefvaluesclone, Set<String> modelrefvaluesclone) {
		// Take into account fragment-based references (Modelio)
		modelRefs: for (Iterator<String> itModelRefs = modelrefvaluesclone.iterator(); itModelRefs.hasNext();) {
			final String modelref = itModelRefs.next();

			final int idxHash = modelref.indexOf("#");
			final String path = modelref.substring(modelref.indexOf(GraphModelUpdater.FILEINDEX_REPO_SEPARATOR)
					+ GraphModelUpdater.FILEINDEX_REPO_SEPARATOR.length(), idxHash);
			if (path.equals("/*")) {
				final String fragment = modelref.substring(idxHash + 1);
				for (Iterator<String> itNodeRefs = noderefvaluesclone.iterator(); itNodeRefs.hasNext();) {
					final String noderef = itNodeRefs.next();
					if (noderef.endsWith("#" + fragment)) {
						itModelRefs.remove();
						itNodeRefs.remove();
						continue modelRefs;
					}
				}
			}
		}
	}

	protected Map<String, Set<String>> computeNodeReferences(String repoURL, final IGraphNode instance) {
		Map<String, Set<String>> nodeReferences = new HashMap<>();

		for (IGraphEdge reference : instance.getOutgoing()) {
			if (reference.getType().equals(ModelElementNode.EDGE_LABEL_FILE)
				|| reference.getType().equals(ModelElementNode.EDGE_LABEL_OFTYPE)
				|| reference.getType().equals(ModelElementNode.EDGE_LABEL_OFKIND)
				|| reference.getPropertyKeys().contains(GraphModelInserter.DERIVED_FEATURE_EDGEPROP)) {
				continue;
			}

			final Set<String> refvals = nodeReferences.getOrDefault(reference.getType(), new HashSet<>());
			final IGraphNode refEndNode = reference.getEndNode();
			final String refEndNodeId = refEndNode.getProperty(IModelIndexer.IDENTIFIER_PROPERTY).toString();

			if (!singletonIndexIsEmpty && singletonIndex.get("id", refEndNodeId).iterator().hasNext()) {
				refvals.add(refEndNodeId);
			} else {
				final IGraphNode targetFileNode = refEndNode.getOutgoingWithType(ModelElementNode.EDGE_LABEL_FILE).iterator().next().getEndNode();
				final Object targetFileID = targetFileNode.getProperty(IModelIndexer.IDENTIFIER_PROPERTY);
				refvals.add(repoURL	+ GraphModelUpdater.FILEINDEX_REPO_SEPARATOR + targetFileID + "#" + refEndNodeId);
			}

			nodeReferences.put(reference.getType(), refvals);
		}

		// Take into account proxies as well (since the EMF side reports them)
		ModelElementNode men = new ModelElementNode(instance);
		for (ProxyReferenceList l : men.getProxies()) {
			for (ProxyReference proxyRef : l.getReferences()) {
				Set<String> refvals = nodeReferences.getOrDefault(proxyRef.getEdgeLabel(), new HashSet<>());
				ProxyReferenceTarget target = proxyRef.getTarget();
				refvals.add(target.getRepositoryURL() + GraphModelUpdater.FILEINDEX_REPO_SEPARATOR + "/" + target.getFilePath() + "#" + target.getFragment());
			}
		}

		return nodeReferences;
	}

	@SuppressWarnings("unchecked")
	protected Map<String, Set<String>> computeModelReferences(final URI tempURI, final String repoURL, final IHawkObject eobject) throws URISyntaxException {
		final URI repoURI = new URI(repoURL);

		// full reference uri in order to properly compare with hawk proxies
		Map<String, Set<String>> modelReferences = new HashMap<>();

		for (IHawkReference ref : ((IHawkClass) eobject.getType()).getAllReferences()) {
			if (eobject.isSet(ref)) {
				final Object refval = eobject.get(ref, false);
				final Set<String> vals = new HashSet<>();

				if (refval instanceof Iterable<?>) {
					for (Object val : ((Iterable<IHawkObject>) refval)) {
						vals.add(parseValue(val, repoURI, tempURI));
					}
				} else {
					vals.add(parseValue(refval, repoURI, tempURI));
				}

				if (!vals.isEmpty()) {
					modelReferences.put(ref.getName(), vals);
				}
			}
		}

		return modelReferences;
	}

	protected void compareAttributes(final IGraphNode node, final IHawkObject modelElement) {
		// cache model element attributes and references by name
		final Map<String, Object> modelAttributes = new HashMap<>();
		for (IHawkAttribute a : ((IHawkClass) modelElement.getType()).getAllAttributes()) {
			if (modelElement.isSet(a)) {
				modelAttributes.put(a.getName(), modelElement.get(a));
			}
		}

		for (String propertykey : node.getPropertyKeys()) {
			if (!propertykey.equals(IModelIndexer.SIGNATURE_PROPERTY)
					&& !propertykey.equals(IModelIndexer.IDENTIFIER_PROPERTY)
					&& !propertykey.startsWith(GraphModelUpdater.PROXY_REFERENCE_PREFIX)
					&& !propertykey.equals(GraphModelInserter.LAST_DERIVED_TSTAMP_NODEPROP)) {

				Object dbattr = node.getProperty(propertykey);
				Object attr = modelAttributes.get(propertykey);

				if (!flattenedStringEquals(dbattr, attr)) {
					final String dbJavaType = dbattr != null ? dbattr.getClass().toString() : "null attr";
					final Object dbValue = dbattr instanceof Object[] ? (Arrays.asList((Object[]) dbattr)) : dbattr;
					final String modelJavaType = attr != null ? attr.getClass().toString() : "null attr";
					final Object modelValue = attr instanceof Object[] ? (Arrays.asList((Object[]) attr)) : attr;

					errors.add(new ValidationError(String.format(
						"Attribute %s has mismatched values:\n"
							+ " * database:\t%s JAVATYPE: %s IN NODE: %s WITH ID: %s\n"
							+ " * model:\t\t%s JAVATYPE: %s IN ELEMENT WITH ID %s",
						propertykey,
						dbValue, dbJavaType, node.getId(), node.getProperty(IModelIndexer.IDENTIFIER_PROPERTY),
						modelValue, modelJavaType, modelElement.getUriFragment()
					)));
				}

				modelAttributes.remove(propertykey);
			}
		}
		if (modelAttributes.size() > 0) {
			errors.add(new ValidationError(String.format(
				"The following attributes were not found in the graph node %s: %s", node.getId(), modelAttributes.keySet()
			)));
		}
	}

	private String parseValue(Object val, URI repo, URI temp) throws URISyntaxException {
		String ret;
		IHawkObject o = (IHawkObject) val;

		// System.err.println("-checking uniqueness of " + o.getUriFragment());
		if (!singletonIndexIsEmpty
				&& singletonIndex.get("id", o.getUriFragment()).iterator()
						.hasNext()) {

			// System.err.println("-singleton: " + o.getUriFragment()
			// + " (isfragunique: " + o.isFragmentUnique() + ")");
			ret = o.getUriFragment();

		} else {

			final URI objURI = new URI(o.getUri());
			ret = objURI.getPath().replace(repo.getPath(), "").replace(temp.getPath(), "").replace("+", "%2B");
			if (objURI.getFragment() != null) {
				ret += "#" + objURI.getFragment();
			}
			if (!ret.startsWith("/")) {
				ret = "/" + ret;
			}

			try {
				ret = URLDecoder.decode(ret, "UTF-8");
			} catch (Exception ex) {
				// might not be decodable that way (Modelio can produce something like '#//%Objing%')
			}

			ret = (repo + GraphModelUpdater.FILEINDEX_REPO_SEPARATOR + ret);

		}

		return ret;
	}

	private int latestChangedElements() {
		return changed.size();
	}

	private int latestDeletedElements() {
		return deleted;
	}

	private Set<String> removeHawkProxies(IGraphNode instance,
			Set<String> modelrefvaluesclone) {

		// String repoURL = "";
		// String destinationObjectRelativePathURI = "";
		//
		// String destinationObjectRelativeFileURI =
		// destinationObjectRelativePathURI
		// .substring(0, destinationObjectRelativePathURI.indexOf("#"));
		// String destinationObjectFullFileURI = repoURL
		// + GraphModelUpdater.FILEINDEX_REPO_SEPARATOR
		// + destinationObjectRelativeFileURI;

		for (String propertykey : instance.getPropertyKeys()) {

			if (propertykey.startsWith(GraphModelUpdater.PROXY_REFERENCE_PREFIX)) {

				String[] proxies = (String[]) instance.getProperty(propertykey);

				for (int i = 0; i < proxies.length; i = i + 4)
					if (modelrefvaluesclone.remove(proxies[i]))
						removedProxies++;
			}
		}

		return modelrefvaluesclone;
	}

	private boolean flattenedStringEquals(Object dbattr, Object attr) {
		// Use approximate comparison for float vs double (e.g. some backends can only represent double and/or float, not both)
		if (attr instanceof Float || attr instanceof Double) {
			return Math.abs(((Number)attr).doubleValue() - ((Number)dbattr).doubleValue()) < THRESHOLD_DIFFERENT_DOUBLES;
		}
		
		String newdbattr = dbattr == null ? "null" : dbattr.toString();
		if (dbattr instanceof int[])
			newdbattr = Arrays.toString((int[]) dbattr);
		else if (dbattr instanceof long[])
			newdbattr = Arrays.toString((long[]) dbattr);
		else if (dbattr instanceof String[])
			newdbattr = Arrays.toString((String[]) dbattr);
		else if (dbattr instanceof boolean[])
			newdbattr = Arrays.toString((boolean[]) dbattr);
		else if (dbattr instanceof Object[])
			newdbattr = Arrays.toString((Object[]) dbattr);

		String newattr = attr == null ? "null" : attr.toString();
		if (attr instanceof int[])
			newattr = Arrays.toString((int[]) attr);
		else if (attr instanceof long[])
			newattr = Arrays.toString((long[]) attr);
		else if (attr instanceof String[])
			newattr = Arrays.toString((String[]) attr);
		else if (attr instanceof boolean[])
			newattr = Arrays.toString((boolean[]) attr);
		else if (attr instanceof Object[])
			newattr = Arrays.toString((Object[]) attr);

		return newdbattr.equals(newattr);

	}

	@Override
	public void changeStart() {

	}

	@Override
	public void changeSuccess() {

	}

	@Override
	public void changeFailure() {

	}

	@Override
	public void metamodelAddition(IHawkPackage pkg, IGraphNode pkgNode) {

	}

	@Override
	public void classAddition(IHawkClass cls, IGraphNode clsNode) {

	}

	@Override
	public void fileAddition(VcsCommitItem s, IGraphNode fileNode) {

	}

	@Override
	public void fileRemoval(VcsCommitItem s, IGraphNode fileNode) {

	}

	@Override
	public void modelElementAddition(VcsCommitItem s, IHawkObject element,
			IGraphNode elementNode, boolean isTransient) {
		changed.add(elementNode.getId().toString());
	}

	@Override
	public void modelElementRemoval(VcsCommitItem s, IGraphNode elementNode,
			boolean isTransient) {
		deleted++;
	}

	@Override
	public void modelElementAttributeUpdate(VcsCommitItem s,
			IHawkObject eObject, String attrName, Object oldValue,
			Object newValue, IGraphNode elementNode, boolean isTransient) {
		changed.add(elementNode.getId().toString());
	}

	@Override
	public void modelElementAttributeRemoval(VcsCommitItem s,
			IHawkObject eObject, String attrName, IGraphNode elementNode,
			boolean isTransient) {
		changed.add(elementNode.getId().toString());
	}

	@Override
	public void referenceAddition(VcsCommitItem s, IGraphNode source,
			IGraphNode destination, String edgelabel, boolean isTransient) {
		changed.add(source.getId().toString());
	}

	@Override
	public void referenceRemoval(VcsCommitItem s, IGraphNode source,
			IGraphNode destination, String edgelabel, boolean isTransient) {
		changed.add(source.getId().toString());
	}

}
