/*-
 * ~~~~~~licensing~~~~~~
 * test-producers
 * ==========
 * Copyright (C) 2020 - 2022 EmDev LLC
 * ==========
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 * 
 *      http://www.apache.org/licenses/LICENSE-2.0
 * 
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 * ~~~~~~/licensing~~~~~~
 */
package ru.entaxy.platform.core.producer.executor.commands;

import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Optional;
import java.util.stream.Collectors;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

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

import ru.entaxy.platform.base.support.CommonUtils;
import ru.entaxy.platform.base.support.JSONUtils;
import ru.entaxy.platform.base.objects.EntaxyObject.FIELDS;
import ru.entaxy.platform.base.objects.factory.EntaxyFactory;
import ru.entaxy.platform.base.objects.factory.EntaxyFactory.CONFIGURATION.OUTPUTS;
import ru.entaxy.platform.base.objects.factory.EntaxyFactory.FieldInfo;
import ru.entaxy.platform.base.objects.factory.EntaxyFactory.RefFieldInfo;
import ru.entaxy.platform.core.producer.api.EntaxyProducerService;
import ru.entaxy.platform.core.producer.api.ExecutionPlan.ExecutionPlanUpdate;
import ru.entaxy.platform.core.producer.api.ProducerResult;
import ru.entaxy.platform.core.producer.api.ProducerResult.CommandResult;
import ru.entaxy.platform.core.producer.executor.AbstractCommandExecutor;
import ru.entaxy.platform.core.producer.executor.CommandExecutor;
import ru.entaxy.platform.core.producer.executor.objectmodel.FactoredObject;
import ru.entaxy.platform.core.producer.executor.objectmodel.FactoredObjectProxy;
import ru.entaxy.platform.core.producer.executor.objectmodel.FactoredObjectRef;
import ru.entaxy.platform.core.producer.executor.objectmodel.ObjectModel;
import ru.entaxy.platform.core.producer.impl.FieldInfoImpl;
import ru.entaxy.platform.core.producer.impl.RefFieldInfoImpl;

@CommandExecutor(id = "enrich", predecessors = {"layout"})
public class Enrich extends AbstractCommandExecutor {

	private static final Logger log = LoggerFactory.getLogger(Enrich.class);
	
	public Enrich(EntaxyProducerService entaxyProducerService) {
		super(entaxyProducerService);
		// TODO Auto-generated constructor stub
	}

	// TODO move to FieldInfo
	protected boolean isLookup(FieldInfo fi) {
		if (fi.getJsonOrigin().has("lookup")) {
			String lookup = fi.getJsonOrigin().get("lookup").getAsString();
			return CommonUtils.isValid(lookup);
		}
		return false;
	}
	
	// TODO move to FieldInfo
	public String getLookup(FieldInfo fi) {
		return fi.getJsonOrigin().has("lookup")
				?fi.getJsonOrigin().get("lookup").getAsString()
				:null;
	}
	
	@Override
	protected boolean doExecute(ProducerResult currentResult, CommandResult commandResult,
			Map<String, Object> instructions) throws Exception {

		int count = 15;
		if (!instructions.containsKey("_count")) {
			instructions.put("_count", count);
		}
		
		if (instructions.get("_count") instanceof Integer)
			count = (Integer)instructions.get("_count");
		else if (instructions.get("_count") instanceof Number)
			count = ((Number)instructions.get("_count")).intValue();
		count--;

		printOutput("\n\n\t== ENRICH == #" + count + " ==\n\n");
		
		ObjectModel objectModel = currentResult.findResultObject(ObjectModel.class);
		objectModel.startTracking();
		
		JsonObject incomingJson = objectModel.getJsonCurrent().deepCopy();
		
		Gson gson = new Gson();
		
		// enrich type to ensure we can resolve refs 

		for (FactoredObject fo: objectModel.objects) {
			
			// skip proxies
			if (fo instanceof FactoredObjectProxy)
				continue;
			
			String factoryId = fo.factoryId;
			
			EntaxyFactory factory = entaxyProducerService.findFactoryById(factoryId);
			if (factory == null)
				throw new Exception("Factory not found: [" + factoryId + "]");
			
			String objectType = fo.getObjectType();
			if (!CommonUtils.isValid(objectType)) {
				fo.setObjectType(factory.getFactoryType());
				objectModel.setDirty();
			}
		}	
		
		// enrich fields
		
		for (FactoredObject fo: objectModel.objects) {
			
			// skip proxies
			if (fo instanceof FactoredObjectProxy)
				continue;

			String factoryId = fo.factoryId;
			
			EntaxyFactory factory = entaxyProducerService.findFactoryById(factoryId);

			List<FieldInfo> delayed = new LinkedList<>();
			
			List<String> processedFieldNames = new LinkedList<>();

			JsonObject objectOrigin = fo.origin;

			if (JSONUtils.setValue(objectOrigin, FIELDS.PROPERTIES, new JsonObject(), true)) {
				printOutput("[DIRTY] CREATED 'properties' for [" + fo.getObjectId() + "/" + fo.getObjectType() + "]");
				objectModel.setDirty();
			}

			JsonObject objectProperties = objectOrigin.get(FIELDS.PROPERTIES).getAsJsonObject();
			
			List<FieldInfo> factoryFields = factory.getFields(OUTPUTS.OUTPUT_TYPE_INIT); 
			
			
			for (FieldInfo fi: factoryFields) {
				
				FieldInfo fieldInfo = fi;
				
				log.info("\n ITERATING FOR FIELD: " + fi.getName() + "\n" + fi.getJsonOrigin());
				
				if (FIELDS.OBJECT_ID.equals(fi.getName())
					|| FIELDS.OBJECT_TYPE.equals(fi.getName()))
					continue;
				
				// check if lookup
				if (isLookup(fi)) {

					String lookup = getLookup(fi);
					log.info("\n LOOKUP FIELD: " + fi.getName() + " -> " + lookup);
					
					// replace lookup with corresponding ref
					Optional<FieldInfo> toLookupOpt = factoryFields.stream()
							.filter(f -> lookup.equals(f.getName()))
							.findFirst();
					if (!toLookupOpt.isPresent()) {
						// field to lookup not found
						log.error("Factory configuration error: loooked up field [{}] referenced by [{}] not found in factory [{}]"
								, lookup
								, fi.getName()
								, factory.getFactoryId());
						continue;
					}
					FieldInfo toLookup = toLookupOpt.get();
					RefFieldInfoImpl fieldInfoImpl = new RefFieldInfoImpl();
					fieldInfoImpl.name(fi.getName())
						.description(fi.getDescription())
						.displayName(fi.getDisplayName())
						.immutable(fi.isImmutable())
						.required(fi.isRequired());
					fieldInfoImpl.refField(
							fi.getJsonOrigin().has(FIELDS.REF_FIELD)
							?fi.getJsonOrigin().get(FIELDS.REF_FIELD).getAsString()
							:""
							)
						.refByValueOnly(true)
						.backRef(false);
					fieldInfoImpl.setConditional(fi.isConditional());
					fieldInfoImpl.setCondition(fi.getCondition());
					fieldInfoImpl.setRef(true);
					fieldInfoImpl.setType(toLookup.getType());
					JsonObject lookupJson = fi.getJsonOrigin().deepCopy();
					JsonObject lookedUpJson = toLookup.getJsonOrigin();
					for (Entry<String, JsonElement> entry: lookedUpJson.entrySet()) {
						if (lookupJson.has(entry.getKey()))
							lookupJson.remove(entry.getKey());
						lookupJson.add(entry.getKey(), entry.getValue());
					}
					fieldInfoImpl.setJsonOrigin(lookupJson);

					log.info("\n LOOKUP FIELD JSON: \n" + lookupJson.toString());

					fieldInfo = fieldInfoImpl;

					if (!processedFieldNames.contains(lookup) || fi.isConditional()) {
						delayed.add(fieldInfo);
						continue;
					}
					
				}

				// check if conditional
				if (fi.isConditional()) {
					delayed.add(fi);
					continue;
				}

				
				processField(objectModel, fieldInfo, fo, gson);
				processedFieldNames.add(fieldInfo.getName());
			}
			
			int delayedCount = 0;
			
			while (delayedCount != delayed.size()) {
				List<FieldInfo> toRemove = new ArrayList<>();
				
				delayedCount = delayed.size();
				
				for (FieldInfo fi: delayed) {
					
					// first check conditionals
					
					if (fi.isConditional()) {
					
						String condition = fi.getCondition();
						if (condition.startsWith("${"))
							condition = condition.substring(2, condition.lastIndexOf("}"));
						
						
						if (!objectProperties.has(condition))
							continue;
						
						Object conditionValue = JSONUtils.element2object(objectProperties.get(condition));
						if (conditionValue == null) {
							continue;
						} else if (conditionValue instanceof String) {
							if (!"true".equalsIgnoreCase((String)conditionValue)) {
								toRemove.add(fi);
								continue;
							}
						} else if (conditionValue instanceof Number) {
							if (((Number)conditionValue).doubleValue()<=0) {
								toRemove.add(fi);
								continue;
							}
						} else if (conditionValue instanceof Boolean) {
							if (!(Boolean)conditionValue) {
								toRemove.add(fi);
								continue;
							}
						}
					}
					
					// then check lookups
					if (isLookup(fi)) {
						if (!processedFieldNames.contains(getLookup(fi))) {
							continue;
						}
					}
					
					// we're still here so process
					processField(objectModel, fi, fo, gson);
					processedFieldNames.add(fi.getName());
					
				}
				
				delayed.removeAll(toRemove);
			}
			
		}
		
		
	
		
		if (objectModel.stopTracking() && count>0) {

			// remove ##embedded
			for (FactoredObject fo: objectModel.objects)
				fo.origin.remove(FactoredObject.EMBEDDED_FIELD);

			
			commandResult.planUpdate = ExecutionPlanUpdate.create()
//					.updateInstructions().target("enrich").value("skip", true).complete()
					.reset().target("analyze").complete();
		}
		
		instructions.put("_count", count);

		JsonObject outgoingJson = objectModel.getJsonCurrent();
		
		printOutput("\n== INCOMING JSON ==\n");
		printOutput(incomingJson.toString());
		printOutput("\n== OUTGOING JSON ==\n");
		printOutput(outgoingJson.toString());
		
		commandResult.resultObject(outgoingJson);
		
		return true;
	}

	protected boolean processField(ObjectModel objectModel, FieldInfo fi, FactoredObject fo, Gson gson) {
		
		log.info("\nPROCESSING FIELD: " + fi.getName());
		
		if (fi.isRef()) {
			log.info("\nPROCESSING REF: " + fi.getName());
			return processRef(objectModel, (RefFieldInfo)fi, fo, gson);
		}
		
		boolean result = false;
		
		JsonObject objectOrigin = fo.origin;
		JsonObject objectProperties = objectOrigin.get(FIELDS.PROPERTIES).getAsJsonObject();
		
		boolean exists = objectProperties.has(fi.getName());
		if (!exists) {
			if (fi.isRequired()) {
				printOutput("[DIRTY] CREATED " + fi.getName() + " for [" + fo.getObjectId() + "/" + fo.getObjectType() + "]");

				objectProperties.add(fi.getName(), fi.getJsonOrigin().get(FIELDS.DEFAULT_VALUE));
				objectModel.setDirty();
				result = true;
			}
		} 
		return result;
	}
	
	protected boolean processRef(ObjectModel objectModel, RefFieldInfo fi, FactoredObject fo, Gson gson) {
		boolean result = false;
		
		JsonObject objectOrigin = fo.origin;
		JsonObject objectProperties = objectOrigin.get(FIELDS.PROPERTIES).getAsJsonObject();
		
		boolean exists = objectProperties.has(fi.getName());
		if (!exists) {
			if (fi.isRequired()) {
				
				if (fi.isBackRef()) {
					// it's a backRef, so we'll try to find object of the given type
					// referencing current object
					List<FactoredObject> candidates = fo.references.stream()
							.filter(ref->!ref.isBackRef())
							.map(ref->ref.definedIn)
							.filter(obj->fi.getType().equals(obj.getObjectType()))
							.collect(Collectors.toList());
					if (candidates.size()>0) {
						// we found candidates, so just add ref to first one
						// no matter what object is there
						// 'cause the ref being backRef won't be ever generated
						FactoredObjectRef ref = new FactoredObjectRef(objectModel, candidates.get(0));
						ref.definedIn = fo;
						fo.embed(ref);
						ref.setBackRef(true);
						ref.setResolved(true);
						printOutput("[DIRTY] CREATED " + fi.getName() + " for [" + fo.getObjectId() + "/" + fo.getObjectType() + "]");
						objectProperties.add(fi.getName(), ref.origin);
						result = true;
					} else {
						// try to use default value
						printOutput("[DIRTY] CREATED " + fi.getName() + " for [" + fo.getObjectId() + "/" + fo.getObjectType() + "]");
						objectProperties.add(fi.getName(), fi.getJsonOrigin().get(FIELDS.DEFAULT_VALUE));
						objectModel.setDirty();
						result = true;
					}
					
				} else {
					// it's a direct ref
					
					FactoredObject candidate = null;
					
					// try to find by backRef
					List<FactoredObjectRef> backrefs = objectModel.refs.stream()
						.filter(ref->
							(ref.isBackRef() 
									&& (ref.getTargetObject().equals(fo))
									&& (ref.definedIn.getObjectType().equals(fi.getType()))
							)
						)
						.collect(Collectors.toList());
					if (backrefs.size()>0) {
						// we can use any
						candidate = backrefs.get(0).definedIn;
					} else {
						// try to find objects of required type defined in current
						List<FactoredObject> candidates = objectModel.objects
							.stream()
							.filter(
								obj->((obj != fo) && (obj.getObjectType().equals(fi.getType())))
									)
							.collect(Collectors.toList());
						
						// we can use the only one
						if (candidates.size() == 1)
							candidate = candidates.get(0);
						else if (candidates.size()>1) {
							// search for defined in current
							candidates = candidates.stream().filter(c->c.isDefinedIn(fo))
									.collect(Collectors.toList());
							if (candidates.size()==1)
								candidate = candidates.get(0);
						}
					}
					
					if (candidate != null) {
					
						FactoredObjectRef ref = new FactoredObjectRef(objectModel, candidate);
						ref.definedIn = fo;
						fo.embed(ref);
						ref.origin.addProperty(FIELDS.IS_REF_BY_VALUE_ONLY, fi.isRefByValueOnly());
						ref.origin.addProperty(FIELDS.REF_FIELD, fi.getRefField());
						printOutput("[DIRTY] CREATED " + fi.getName() + " for [" + fo.getObjectId() + "/" + fo.getObjectType() + "]");
						objectProperties.add(fi.getName(), ref.origin);
						objectModel.setDirty();
						result = true;
						
					} else {
						/*
						if (fi.getJsonOrigin().has(FIELDS.DEFAULT_VALUE)) {
							printOutput("[DIRTY] CREATED " + fi.getName() + " for [" + fo.getObjectId() + "/" + fo.getObjectType() + "]");
							objectProperties.add(fi.getName(), fi.getJsonOrigin().get(FIELDS.DEFAULT_VALUE));
							objectModel.setDirty();
							result = true;
						} else { */
							// we just create a ref to nothing
							// hoping it will be enriched next time
							JsonObject refCopy = fi.getJsonOrigin().deepCopy();
							if (isLookup(fi)) {
								// remove default targetId, 'cause it will be resolved on next enrich
								refCopy.remove(FactoredObjectRef.TARGET_ID_FIELD);
							}
							printOutput("[DIRTY] CREATED " + fi.getName() + " for [" + fo.getObjectId() + "/" + fo.getObjectType() + "]");
							printOutput("\t" + refCopy.toString());
							objectProperties.add(fi.getName(), refCopy);
							objectModel.setDirty();
							result = true;
						//}
					}
					
				}
				
				
			}
		} else {
			// we found something where must be ref
			JsonElement currentValue = objectProperties.get(fi.getName());
			JsonObject refCopy = fi.getJsonOrigin().deepCopy();
			
			if (currentValue.isJsonObject()) {
				// this is some object
				printOutput("CURRENT VALUE [" + currentValue.toString() + "] IS OBJECT IN " + fi.getName() + " for [" + fo.getObjectId() + "/" + fo.getObjectType() + "]");
				JsonObject currentObject = currentValue.getAsJsonObject();
				if (currentObject.has(FIELDS.IS_REF)) {
					String targetType = currentObject.has(FactoredObjectRef.TARGET_TYPE_FIELD)
							?currentObject.get(FactoredObjectRef.TARGET_TYPE_FIELD).getAsString()
							:null;
					if (!CommonUtils.isValid(targetType)){
						String refType = fi.getType();
						targetType = CommonUtils.isValid(refType)?refType:"*";
						currentObject.remove(FactoredObjectRef.TARGET_TYPE_FIELD);
						currentObject.addProperty(FactoredObjectRef.TARGET_TYPE_FIELD, targetType);
						objectModel.setDirty();
					}
					if (!currentObject.has(FIELDS.IS_REF_BY_VALUE_ONLY)
							|| !currentObject.has(FIELDS.REF_FIELD)
							|| !currentObject.has(FIELDS.IS_BACK_REF)) 
					{
						currentObject.addProperty(FIELDS.IS_REF_BY_VALUE_ONLY, fi.isRefByValueOnly());
						currentObject.addProperty(FIELDS.REF_FIELD, fi.getRefField());
						currentObject.addProperty(FIELDS.IS_BACK_REF, fi.isBackRef());
						printOutput("[DIRTY] UPDATED " + fi.getName() + " for [" + fo.getObjectId() + "/" + fo.getObjectType() + "]");
						objectModel.setDirty();
					}
					if (isLookup(fi)) {
						String lookup = getLookup(fi);
						JsonElement lookupValue = objectProperties.get(lookup);
						if (lookupValue.isJsonObject()) {
							JsonObject lookupObject = lookupValue.getAsJsonObject();
							if (lookupObject.has(FactoredObjectRef.TARGET_ID_FIELD)) {
								String lookupTargetId = lookupObject.get(FactoredObjectRef.TARGET_ID_FIELD).getAsString();
								String lookupTargetType = lookupObject.get(FactoredObjectRef.TARGET_TYPE_FIELD).getAsString();
								String currentTargetId = currentObject.has(FactoredObjectRef.TARGET_ID_FIELD)
										?currentObject.get(FactoredObjectRef.TARGET_ID_FIELD).getAsString()
										:"";
								String currentTargetType = currentObject.has(FactoredObjectRef.TARGET_TYPE_FIELD)
										?currentObject.get(FactoredObjectRef.TARGET_TYPE_FIELD).getAsString()
										:"";
								if (
										(!lookupTargetId.equals(currentTargetId) && currentTargetType.equals(lookupTargetType))
										|| !CommonUtils.isValid(currentTargetId)
									) {
									currentObject.remove(FactoredObjectRef.TARGET_ID_FIELD);
									currentObject.add(FactoredObjectRef.TARGET_ID_FIELD, lookupObject.get(FactoredObjectRef.TARGET_ID_FIELD));
									printOutput("[DIRTY] UPDATED " + fi.getName() + " for [" + fo.getObjectId() + "/" + fo.getObjectType() + "]"
											+ "\n\tSET LOOKUP TARGET ID TO ["
											+ lookupObject.get(FactoredObjectRef.TARGET_ID_FIELD).toString()
											+ "]");
									objectModel.setDirty();
								}
							}
						}
					}
					result = true;
				} else if (currentObject.has(FIELDS.FACTORY_ID)) {
					// it will be replaced with reference on next layout
					printOutput("[DIRTY] FOUND OBJECT " + fi.getName() + " for [" + fo.getObjectId() + "/" + fo.getObjectType() + "]");
					objectModel.setDirty();
					result = true;
				}
			} else {
				printOutput("CURRENT VALIE [" + currentValue.toString() + "] IS NOT OBJECT IN " + fi.getName() + " for [" + fo.getObjectId() + "/" + fo.getObjectType() + "]");
				
			}
			
			if (!result) {
				// we have something which is not a ref and not a object to produce
				// we'll treat it as resolved ref value
				refCopy.remove(FIELDS.DEFAULT_VALUE);
				refCopy.add(FactoredObjectRef.TARGET_VALUE_FIELD, currentValue);
				refCopy.addProperty(FactoredObjectRef.IS_RESOLVED_FIELD, true);
				printOutput("[DIRTY] CONSIDER VALUE [" + currentValue.toString() + "] AS RESOLVED " + fi.getName() + " for [" + fo.getObjectId() + "/" + fo.getObjectType() + "]");
				objectModel.setDirty();
				result = true;
			}
		}
		return result;
	}
}
