/*-
 * ~~~~~~licensing~~~~~~
 * object-producer-core
 * ==========
 * Copyright (C) 2020 - 2023 EmDev LLC
 * ==========
 * You may not use this file except in accordance with the License Terms of the Copyright
 * Holder located at: https://entaxy.ru/eula . All copyrights, all intellectual property
 * rights to the Software and any copies are the property of the Copyright Holder. Unless
 * it is explicitly allowed the Copyright Holder, the User is prohibited from using the
 * Software for commercial purposes to provide services to third parties.
 * 
 * The Copyright Holder hereby declares that the Software is provided on an "AS IS".
 * Under no circumstances does the Copyright Holder guarantee or promise that the
 * Software provided by him will be suitable or not suitable for the specific purposes
 * of the User, that the Software will meet all commercial and personal subjective
 * expectations of the User, that the Software will work properly, without technical
 * errors, quickly and uninterruptedly.
 * 
 * Under no circumstances shall the Copyright Holder or its Affiliates is not liable
 * to the User for any direct or indirect losses of the User, his expenses or actual
 * damage, including, downtime; loss of bussines; lost profit; lost earnings; loss
 * or damage to data, property, etc.
 * ~~~~~~/licensing~~~~~~
 */
package ru.entaxy.platform.core.producer.executor.generationmodel.merge;

import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;

import org.osgi.framework.Bundle;
import org.osgi.service.component.ComponentContext;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.w3c.dom.Document;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;

import com.google.gson.Gson;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;

import ru.entaxy.esb.platform.runtime.base.connecting.generator.Generated;
import ru.entaxy.platform.base.support.CommonUtils;
import ru.entaxy.platform.base.support.JSONUtils;
import ru.entaxy.platform.base.support.xml.CommonXMLUtils;
import ru.entaxy.platform.core.producer.executor.generationmodel.merge.MergingMap.Mapping;
import ru.entaxy.platform.core.producer.executor.support.EntaxyObjectPropertiesHelper;

@Component(service = MergeProcessor.class, immediate = true)
@MergingMap(mappings = {
	@Mapping(source = Generated.GENERATED_TYPE_BLUEPRINT_FRAGMENT
			, targets = {Generated.GENERATED_TYPE_BLUEPRINT, Generated.GENERATED_TYPE_BLUEPRINT_FRAGMENT})	
})
public class BlueprintMergeProcessor extends AbstractMergeProcessor {

	private static final Logger log = LoggerFactory.getLogger(BlueprintMergeProcessor.class);
	
	public static final String ROOT_NODE_NAME = "##root";
	
	public static final String FRAGMENT_NAME = "fragment";
	public static final String FRAGMENT_START = "<" + FRAGMENT_NAME + ">";
	public static final String FRAGMENT_END = "</" + FRAGMENT_NAME + ">";
	
	protected MergeProcessor defaultProcessor;
	
	@Activate
	public void activate(ComponentContext componentContext) {
		Bundle bundle = componentContext.getBundleContext().getBundle();
		URL url = bundle.getEntry("/config/BlueprintMergeProcessor.json");
		defaultProcessor = new MergeProcessor();
		try {
			defaultProcessor.load(url.openStream());
		} catch (Exception e) {
			log.error("Error loading configuration", e);
			defaultProcessor.loadDefaults();
		}
	}
	
	@Override
	public Generated merge(Generated source, Generated target, Map<String, Object> config) {
		
		MergeProcessor currentProcessor;
		
		String mergeConfig = config==null
				?""
				:config.getOrDefault("merge.config", "").toString();
		if (CommonUtils.isValid(mergeConfig)) {
			currentProcessor = new MergeProcessor();
			try {
				currentProcessor.load(mergeConfig);
			} catch (Exception e) {
				log.error("Invalid merge.config: \n" + mergeConfig, e);
				currentProcessor = defaultProcessor.createCopy();
			}
		} else {
			currentProcessor = defaultProcessor.createCopy();
		}
		
		NodeList sourceNodes = null;
		if (source.getType().equals(Generated.GENERATED_TYPE_BLUEPRINT_FRAGMENT)) {
			// remove <?xml .. ?> if present
			String sourceValue = source.getObject().toString().trim();
			if (sourceValue.startsWith("<?xml")) {
				int index = sourceValue.indexOf("?>");
				sourceValue = sourceValue.substring(index+2);
			}
			String toParse = FRAGMENT_START + sourceValue + FRAGMENT_END;
			try {
				Document sourceDoc = CommonXMLUtils.parseString(true, toParse);
				sourceNodes = sourceDoc.getFirstChild().getChildNodes();
				for (int i=0; i<sourceNodes.getLength(); i++) {
					Node node = sourceNodes.item(i);
				}
			} catch (Exception e) {
				log.error("Can't parse: \n" + toParse, e);
			}
		}
		
		if ((sourceNodes == null) || (sourceNodes.getLength()==0))
			return target;
		
		if (target.getType().equals(Generated.GENERATED_TYPE_BLUEPRINT)) {
			return mergeToBlueprint(sourceNodes, source, target, currentProcessor);
		}
		
		if (target.getType().equals(Generated.GENERATED_TYPE_BLUEPRINT_FRAGMENT)) {
			return mergeToFragment(sourceNodes, source, target, currentProcessor);
		}
		
		return null;
	}

	protected Generated mergeToBlueprint(NodeList sourceNodes, Generated source, Generated target, MergeProcessor currentProcessor) {
		Document targetDoc = null;
		Node targetNode = null;
		Node targetNodeBefore = null;
		
		try {
			targetDoc = CommonXMLUtils.parseString(true, target.getObject().toString());
			targetNode = targetDoc.getFirstChild();
	
			currentProcessor.setDocument(targetDoc);
			
			
		} catch (Exception e) {
			log.error("Can't parse: \n" + target.getObject().toString(), e);
		}
	
		if (!currentProcessor.hasDocument())
			return null;
		
		currentProcessor.reset();

		/*for (int i=0; i<sourceNodes.getLength(); i++)
			currentProcessor.mergeNode(sourceNodes.item(i));
		*/
		
		currentProcessor.mergeNodes(sourceNodes);
		
		try {
			String resultBlueprint = CommonXMLUtils.doc2string(targetDoc);
			Generated result = Generated.create(Generated.GENERATED_TYPE_BLUEPRINT, resultBlueprint);
			result.getProperties().putAll(
					EntaxyObjectPropertiesHelper.merge(source.getProperties(), target.getProperties())
					);
			return result;
		} catch (Exception e) {
			log.error("Can't convert to string", e);
		}
		
		return null;
	}

	protected Generated mergeToFragment(NodeList sourceNodes, Generated source, Generated target, MergeProcessor currentProcessor) {
		
		// remove <?xml .. ?> if present
		String targetValue = target.getObject().toString().trim();
		if (targetValue.startsWith("<?xml")) {
			int index = targetValue.indexOf("?>");
			targetValue = targetValue.substring(index+2);
		}
		String toParse = FRAGMENT_START + targetValue + FRAGMENT_END;
		
		Document targetDoc = null;
		
		try {
			targetDoc = CommonXMLUtils.parseString(true, toParse);
			for (int i=0; i<sourceNodes.getLength(); i++) {
				targetDoc.getFirstChild().appendChild(
						targetDoc.adoptNode(sourceNodes.item(i).cloneNode(true))
					);
			}
		} catch (Exception e) {
			log.error("Can't parse: \n" + toParse, e);
		}
		
		try {
			String resultFragment = CommonXMLUtils.doc2string(targetDoc).trim();
			
			if (resultFragment.startsWith(FRAGMENT_START))
				resultFragment = resultFragment.substring(FRAGMENT_START.length());
			if (resultFragment.endsWith(FRAGMENT_END))
				resultFragment = resultFragment.substring(0
						, resultFragment.length() - FRAGMENT_END.length());

			
			
			Generated result = Generated.create(Generated.GENERATED_TYPE_BLUEPRINT_FRAGMENT, resultFragment);
			result.getProperties().putAll(
					EntaxyObjectPropertiesHelper.merge(source.getProperties(), target.getProperties())
					);
			return result;
		} catch (Exception e) {
			log.error("Can't convert to string", e);
		}

		
		return null;
	}	
	
	protected static class MergeProcessor {
		
		public static String DEFAULT_CONFIG = "{\"*\":{}}";
		
		MergeProcessorConfig config = new MergeProcessorConfig();
		
		Document document;
		
		Node rootNode;
		Node manifestNode = null;
		
		boolean trackOrder = true;
		List<Node> processedNodes = new ArrayList<>();
		
		public void load(InputStream inputStream) throws IOException {
			log.info("loading config");
			String text = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
			inputStream.close();
			load(text);
		}
	
		public void loadDefaults() {
			load(DEFAULT_CONFIG);
		}

		public void load(String jsonData) {
			config.load(JSONUtils.getJsonRootObject(jsonData));
		}
		
		public MergeProcessor createCopy() {
			MergeProcessor result = new MergeProcessor();
			result.config.load(this.config.config);
			return result;
		}
		
		public void reset() {
			processedNodes.clear();
		}
		
		public Document getDocument() {
			return document;
		}

		public void setDocument(Document document) {
			this.document = document;
			
			rootNode = this.document.getFirstChild();
			
			NodeList manifests = this.document.getElementsByTagName("manifest");
			for (int i=0; i<manifests.getLength(); i++)
				if ((manifests.item(i).getParentNode() != null) && (manifests.item(i).getParentNode() == rootNode)) {
					manifestNode = manifests.item(i);
					break;
				}
			
		}
		
		public boolean hasDocument() {
			return this.document != null;
		} 
		
		protected NodeConflict checkNodeConflict(Node newNode, Node targetContainerNode, MERGE_POSITION position, MergeConfig mergeConfig) {
			
			if ((mergeConfig.unique == null) || mergeConfig.unique.isEmpty())
				return null;
			
			String nodeName = newNode.getLocalName();
			Map<String, String> attributes = new HashMap<>();
			for (String attrName: mergeConfig.unique) {
				Node attr = newNode.getAttributes().getNamedItem(attrName);
				if (attr == null)
					attributes.put(attrName, null);
				else
					attributes.put(attrName, attr.getNodeValue());
			}
			
			NodeConflict result = null;
			
			NodeList testNodes = targetContainerNode.getChildNodes();
			for (int i=0; i<testNodes.getLength(); i++) {
				Node item = testNodes.item(i);
				if (!nodeName.equalsIgnoreCase(item.getLocalName()))
					continue;
				boolean found = false;
				for (Entry<String, String> attributeEntry: attributes.entrySet()) {
					String attrValue = attributeEntry.getValue();
					Node testAttr = item.getAttributes().getNamedItem(attributeEntry.getKey());
					if ((testAttr == null) && (attrValue!=null)) {
						found = false;
						break;
					}
					if ((testAttr != null) && (attrValue==null)) {
						found = false;
						break;
					}
					if ((testAttr != null) && (attrValue!=null)) {
						String testValue = testAttr.getNodeValue();
						if (testValue==null) {
							found = false;
							break;
						}
						if (testValue.equalsIgnoreCase(attrValue)) {
							found = true;
						}
					}
				}
				if (found) {
					result = new NodeConflict();
					result.newNode = newNode;
					result.oldNode = item;
					result.conflictResolution = 
							CommonUtils.getValid(mergeConfig.conflict, MergeConfig.CONFLICT_INGORE);
					break;
				}
			}
			
			return result;
		}
		
		public List<Node> mergeNodes(NodeList sourceNodes){
			List<Node> result = new ArrayList<>();
			
			mergeFilteredNodes( rootNode, sourceNodes, false, result);
			
			return result;
		}
		
		protected void mergeFilteredNodes(Node defaultTargetNode, NodeList sourceNodes, boolean recoursive, List<Node> toReturn){
			for (int i=0; i<sourceNodes.getLength(); i++) {
				Node current = sourceNodes.item(i);
				MergeConfig mergeConfig = this.config.getConfig(current);
				if (mergeConfig.isIgnore())
					continue;
				if (mergeConfig.isTransparent()) {
					mergeFilteredNodes(defaultTargetNode,current.getChildNodes(), recoursive, toReturn);
					continue;
				}
				toReturn.add(this.mergeNode(defaultTargetNode,recoursive,  current));
			}
		}
		
		public Node mergeNode(Node defaultTargetNode, boolean recoursive, Node sourceNode) {
			
			MergeConfig mergeConfig = config.getConfig(sourceNode);
			Node targetNode = null;
			if (CommonUtils.isValid(mergeConfig.targetNodeName)) {
		
				if (ROOT_NODE_NAME.equalsIgnoreCase(mergeConfig.targetNodeName))
					targetNode = rootNode;
				else {
					String[] nodePath = mergeConfig.targetNodeName.split("/");
					Node currentNode = rootNode;
					for (int i=0; i<nodePath.length; i++) {
						Node nextNode = null;
						NodeList list = currentNode.getChildNodes();
						for (int j=0; j<list.getLength(); j++) {
							Node current = list.item(j);
							if (!nodePath[i].equalsIgnoreCase(current.getLocalName()))
								continue;
							if (CommonUtils.isValid(mergeConfig.targetNodeNamespace)) {
								if (!mergeConfig.targetNodeNamespace.equalsIgnoreCase(current.getNamespaceURI()))
									continue;
							}
							nextNode = current;
							break;
						}
						currentNode = nextNode;
						if (currentNode == null)
							break;
					}
					targetNode = currentNode;
				}	
			} else {
				targetNode = defaultTargetNode;
			}
			
			if (targetNode == null) {
				if (mergeConfig.createTargetNode) {
					if (!CommonUtils.isValid(mergeConfig.targetNodeXML)) {
						log.warn("Can't merge node [{}:{}] : target node not found [{} : {}] and targetNodeXML not set"
								, sourceNode.getLocalName()
								, sourceNode.getNamespaceURI()
								, mergeConfig.targetNodeName
								, mergeConfig.targetNodeNamespace);
					} else {
						try {
							Document d = CommonXMLUtils.parseString(true, mergeConfig.targetNodeXML);
							Node n = d.getFirstChild();
							targetNode = this.mergeNode(defaultTargetNode, false, n);
						} catch (Exception e) {
							log.error("Error creating node from XML:\n" + mergeConfig.targetNodeXML 
									, e);	
						}
					}
				} else {
					log.warn("Can't merge node [{}:{}] : target node not found [{} : {}]"
							, sourceNode.getLocalName()
							, sourceNode.getNamespaceURI()
							, mergeConfig.targetNodeName
							, mergeConfig.targetNodeNamespace);
					return null;
				}
			}
			
			if (targetNode == null) {
				log.warn("Can't merge node [{}:{}] : target node not found [{} : {}]"
						, sourceNode.getLocalName()
						, sourceNode.getNamespaceURI()
						, mergeConfig.targetNodeName
						, mergeConfig.targetNodeNamespace);
				return null;
			}
			
			if (mergeConfig.position == null)
				mergeConfig.position = MERGE_POSITION.INSIDE_FIRST.label;
			
			boolean effectiveRecoursive = mergeConfig.isRecoursiveSet()?mergeConfig.isRecoursive():recoursive;
			
			Node adopted = this.document.adoptNode(sourceNode.cloneNode(!effectiveRecoursive));
			if (targetNode == rootNode)
				// we can't add nodes before or after a root node
				if (mergeConfig.position.equals(MERGE_POSITION.AFTER.label) || mergeConfig.position.equals(MERGE_POSITION.BEFORE.label))
					mergeConfig.position = MERGE_POSITION.INSIDE_FIRST.label;
			
			Node currentRef;
			MERGE_POSITION position = MERGE_POSITION.valueOfLabel(mergeConfig.position);
			
			NodeConflict nodeConflict;
			switch (position) {
			case AFTER:
			case BEFORE:
				nodeConflict = checkNodeConflict(adopted, targetNode.getParentNode(), position, mergeConfig);
				break;
			default:
				nodeConflict = checkNodeConflict(adopted, targetNode, position, mergeConfig);
			}

			if (nodeConflict != null) {
				if (MergeConfig.CONFLICT_REPLACE.equalsIgnoreCase(nodeConflict.conflictResolution)) {
					// replace node
					nodeConflict.oldNode.getParentNode().replaceChild(
							targetNode.getOwnerDocument().adoptNode(nodeConflict.newNode.cloneNode(true))
							, nodeConflict.oldNode);
				} else {
					// ignore
				}
			} else {
			
				try {
					switch (position) {
					case AFTER:
						currentRef = targetNode.getNextSibling();
						
						if (trackOrder) 
							while ((currentRef != null) && processedNodes.contains(currentRef))
								currentRef = currentRef.getNextSibling();
						
						if (currentRef != null)
							targetNode.getParentNode().insertBefore(adopted, currentRef);
						else
							targetNode.getParentNode().appendChild(adopted);
						break;
					case BEFORE:
						targetNode.getParentNode().insertBefore(adopted, targetNode);
						break;
					case INSIDE_FIRST:
						currentRef = null;
						
						if ((targetNode == rootNode) && (manifestNode != null)) 
							// we assume 'first' as 'first after MANIFEST'
							currentRef = manifestNode.getNextSibling();
						else
							currentRef = targetNode.getFirstChild();
						
						if (trackOrder) 
							while ((currentRef != null) && processedNodes.contains(currentRef))
								currentRef = currentRef.getNextSibling();
						
						if (currentRef == null)
							targetNode.appendChild(adopted);
						else
							targetNode.insertBefore(adopted, currentRef);
						
						break;
					case INSIDE_LAST:
						targetNode.appendChild(adopted);
						break;
					default:
						return null;
					}
				} catch (Exception e) {
					log.error("Error merging node", e);
					return null;
				}
			
			}
			
			if (trackOrder)
				this.processedNodes.add(adopted);
			
			if (effectiveRecoursive) {
				mergeFilteredNodes(adopted, sourceNode.getChildNodes(), true, processedNodes);
			}
			
			return adopted;
		}
		
	}

	protected static class NodeConflict {
		
		public Node newNode;
		public Node oldNode;
		
		public String conflictResolution;
		
	}
	
	protected static class MergeProcessorConfig {
		
		JsonObject config;
		
		MergeConfig DEFAULT_CONFIG = new MergeConfig() {{
			// rootNode
			targetNodeName = null;
			// with any namespace
			targetNodeNamespace = null;
			// add to the end
			position = MERGE_POSITION.INSIDE_FIRST.label;
		}};
		
		public void load(JsonObject object) {
			this.config = object.deepCopy();
		}
		
		public MergeConfig getConfig(Node sourceNode) {
			String name = sourceNode.getLocalName();
			String namespace = sourceNode.getNamespaceURI();
			JsonElement configElement = null;
			
			if (config.has(name + ":" + namespace)) {
				configElement = config.get(name + ":" + namespace);
			} else {
				if (config.has(name)) {
					configElement = config.get(name);
				} else {
					if (config.has("*")) {
						configElement = config.get("*");
					}
				}
			}
			
			if (configElement == null)
				return DEFAULT_CONFIG;
			
			try {
				return (new Gson()).fromJson(configElement, MergeConfig.class);
			} catch (Exception e) {
				log.error("Error getting merge config", e);
				return DEFAULT_CONFIG;
			}
			
		}
		
	}  

	protected enum MERGE_POSITION {
		BEFORE("before"),
		AFTER("after"),
		INSIDE_FIRST("inside_first"),
		INSIDE_LAST("inside_last");
		
	    public String label;

	    private MERGE_POSITION(String label) {
	        this.label = label;
	    }
	    
	    public static MERGE_POSITION valueOfLabel(String label) {
	        for (MERGE_POSITION e : values()) {
	            if (e.label.equals(label)) {
	                return e;
	            }
	        }
	        return null;
	    }	    
		
	}
	
	protected static class MergeConfig {
		
		public static final String CONFLICT_INGORE = "ignore";
		public static final String CONFLICT_REPLACE = "replace";
		
		String targetNodeName;
		String targetNodeNamespace;
		
		String position = MERGE_POSITION.INSIDE_FIRST.label;
		
		boolean ignore = false;

		Boolean recoursive = null;
		
		boolean isTransparent = false;
		
		boolean createTargetNode = false;
		String targetNodeXML;
		
		List<String> unique = new ArrayList<>();
		String conflict;
		
		public boolean isRecoursiveSet() {
			return recoursive != null;
		}
		
		public boolean isRecoursive() {
			return isRecoursiveSet()?recoursive:false;
		}

		public void setRecoursive(boolean recoursive) {
			this.recoursive = recoursive;
		}

		public String getTargetNodeName() {
			return targetNodeName;
		}

		public void setTargetNodeName(String targetNodeName) {
			this.targetNodeName = targetNodeName;
		}

		public boolean isCreateTargetNode() {
			return createTargetNode;
		}

		public void setCreateTargetNode(boolean createTargetNode) {
			this.createTargetNode = createTargetNode;
		}

		public String getTargetNodeXML() {
			return targetNodeXML;
		}

		public void setTargetNodXMLe(String targetNodeXML) {
			this.targetNodeXML = targetNodeXML;
		}

		public String getTargetNodeNamespace() {
			return targetNodeNamespace;
		}

		public void setTargetNodeNamespace(String targetNodeNamespace) {
			this.targetNodeNamespace = targetNodeNamespace;
		}

		public MERGE_POSITION getPosition() {
			return MERGE_POSITION.valueOfLabel(position);
		}

		public void setPosition(String position) {
			this.position = position;
		}

		public boolean isIgnore() {
			return ignore;
		}

		public void setIgnore(boolean ignore) {
			this.ignore = ignore;
		}

		public boolean isTransparent() {
			return isTransparent;
		}

		public void setTransparent(boolean isTransparent) {
			this.isTransparent = isTransparent;
		}

		public List<String> getUnique() {
			return unique;
		}

		public void setUnique(List<String> unique) {
			this.unique = unique;
		}

		public String getConflict() {
			return conflict;
		}

		public void setConflict(String conflict) {
			this.conflict = conflict;
		}
		
	}
	
}
