package org.etsi.mts.tdl.openapi2tdl;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;

import org.eclipse.emf.ecore.resource.Resource;
import org.etsi.mts.tdl.Annotation;
import org.etsi.mts.tdl.AnnotationType;
import org.etsi.mts.tdl.CollectionDataType;
import org.etsi.mts.tdl.Comment;
import org.etsi.mts.tdl.DataElementMapping;
import org.etsi.mts.tdl.DataResourceMapping;
import org.etsi.mts.tdl.DataType;
import org.etsi.mts.tdl.Element;
import org.etsi.mts.tdl.Member;
import org.etsi.mts.tdl.Package;
import org.etsi.mts.tdl.ParameterMapping;
import org.etsi.mts.tdl.SimpleDataType;
import org.etsi.mts.tdl.StructuredDataType;
import org.etsi.mts.tdl.tdlFactory;

import com.reprezen.jsonoverlay.JsonOverlay;
import com.reprezen.jsonoverlay.MapOverlay;
import com.reprezen.jsonoverlay.PropertiesOverlay;
import com.reprezen.jsonoverlay.Reference;
import com.reprezen.kaizen.oasparser.OpenApi3Parser;
import com.reprezen.kaizen.oasparser.model3.OpenApi3;
import com.reprezen.kaizen.oasparser.model3.Schema;
import com.reprezen.kaizen.oasparser.ovl3.SchemaImpl;


public class Converter {
	private static Collection<String> tdlTokens;
	
	private final OpenApi3 model;
	private Package pkg = null;
	private final AnnotationType warningAnnotationType = createWarningAnnotationType();
	private final static String dataRescourceMappingName = "OpenAPIMapping";
	private String filename;

	public OpenApi3 getModel() {
		return model;
	}
	public Package getPackage() {
		return pkg;
	}

	/**
	 * Number used to name anonymous CollectionDataTypes (-> Collection0, Collection1, ...)
	 */
	private int anonymousCollectionCounter = 0;
	/**
	 * Number used to name anonymous StructuresDataTypes (-> Object0, Object1, ...)
	 */
	private int anonymousObjectCounter = 0;
	
	/**
	 * List of warning messages generated while converting
	 */
	private List<Warning> warningList = new ArrayList<>();
	
	private final Map<String, SimpleDataType> simpleDataTypeByName = new HashMap<>();
	private final Map<SchemaWrapper, CollectionDataType> collectionDataTypeBySchema = new HashMap<>();
	private final Map<SchemaWrapper, StructuredDataType> structuredDataTypeBySchema = new HashMap<>();
	
	public List<Warning> getWarnings() {
		return warningList;
	}
	
	public Converter(OpenApi3 model) {
		this.model = model;
	}
	
	public Converter(String filename) throws FileNotFoundException {
		this.model = loadOpenApi3(filename);
		this.filename = filename;
	}
	//TODO: Time for a complete rewrite!
	private void setName(Element elem, String name) {
		if (tdlTokens.contains("'" + name + "'")) {
			warningList.add(new KeywordEscapedWarning(String.format("Name '%s' is a TDL keyword", name)));
			name = "^" + name;
		}
		name = name.replaceAll("-", "_")
				.replaceAll("^_", "N_")
				.replaceAll(" ", "_")
				.replaceAll("\\.", "_");
		elem.setName(name);
}
		
	public AnnotationType createWarningAnnotationType() {
		AnnotationType wAT = tdlFactory.eINSTANCE.createAnnotationType();
		setName(wAT, "Warning");
		return wAT;
	}

	/**
	 * Convert an OpenApi3 model to a TDL Package
	 */
	public Package exportToTdlPackage() {
		Package p = tdlFactory.eINSTANCE.createPackage();
		setName(p, model.getInfo().getTitle());
		
		p.getPackagedElement().add(warningAnnotationType);
		
		// generate schemas
		for (Schema schema: model.getSchemas().values()) {
			if (schema.getType()==null) {
				//TODO: add warning or handle gracefully
				continue;
			}
			DataType type = createDataTypeFromSchema(schema);
			if (!addDescription(type, schema)) {
				warningList.add(new DescriptionMissingWarning(String.format("Missing description for schema %s", schema.getName())));
			}
		}

		// add data types to package
		p.getPackagedElement().addAll(simpleDataTypeByName.values());
		p.getPackagedElement().addAll(collectionDataTypeBySchema.values());
		p.getPackagedElement().addAll(structuredDataTypeBySchema.values());
		
		// Create OpenAPI data mapping
		DataResourceMapping mapping = tdlFactory.eINSTANCE.createDataResourceMapping();
		setName(mapping, dataRescourceMappingName);
		mapping.setResourceURI(setQuotationMarks(filename));
		p.getPackagedElement().add(mapping);
		
		// Only consider top-level structured data types
		for (Schema schema: model.getSchemas().values()) {
			if (schema.getType()==null) {
				schema.setType("TODO_UNSPECIFIED_SCHEMA_TYPE");
			}
			if (//schema.getType()==null || 
					schema.getType().equals("object")) {
				SchemaWrapper wrappedSchema = new SchemaWrapper(schema);
				StructuredDataType structuredDT = structuredDataTypeBySchema.get(wrappedSchema);
				// Create Mapping Elements
				DataElementMapping mappingElement = tdlFactory.eINSTANCE.createDataElementMapping();
				setName(mappingElement, "OpenAPI" + structuredDT.getName());
				mappingElement.setDataResourceMapping(mapping);
				mappingElement.setElementURI(setQuotationMarks("#/components/schemas/" + structuredDT.getName()));
				mappingElement.setMappableDataElement(structuredDT);
				
				for (Member member : structuredDT.getMember()) {
					// Create Parameter mapping for each member of structured data type
					ParameterMapping parameterMapping = tdlFactory.eINSTANCE.createParameterMapping();
					parameterMapping.setParameter(member);
					parameterMapping.setParameterURI(setQuotationMarks(member.getName()));
					
					mappingElement.getParameterMapping().add(parameterMapping);
				}
				p.getPackagedElement().add(mappingElement);
			}
		}
		return p;
	}

	public DataType createDataTypeFromSchema(Schema schema) {
		if (schema.getType() == null) {
			//TODO: why?
			schema.setType("TODO_UNSPECIFIED_SCHEMA_TYPE");
			//TODO: also add proper hierarchical naming
		}
		if (//schema.getType() == null || 
				schema.getType().equals("object")) {
			return createStructuredDataTypeFromSchema(schema);
		} else if (schema.getType().equals("array")) {
			return createCollectionDataTypeFromSchema(schema);
		} else {
			return createSimpleDataTypeFromSchema(schema);
		}
	}

	private void checkSchemaForExample(Schema schema)  {
		if ((schema.getExample() == null)) {
			warningList.add(new ExampleMissingWarning("Missing example for schema " + schema.getName()));
		}
	}
	
	/**
	 * Adds the schema description to the given type. Returns true on success and false if the schema has no description
	 */
	private boolean addDescription(Element type, Schema schema) {
		if (schema.getDescription() == null || schema.getDescription().equals("")) {
			return false;
		}
		Comment comment = tdlFactory.eINSTANCE.createComment();
		comment.setBody(setQuotationMarks(schema.getDescription()));
		type.getComment().add(comment);
		return true;
	}

	public CollectionDataType createCollectionDataTypeFromSchema(Schema schema) {
		SchemaWrapper wrapped = new SchemaWrapper(schema);
		if (collectionDataTypeBySchema.containsKey(wrapped)) {
			return collectionDataTypeBySchema.get(wrapped);
		}

		boolean isAnonymous = !model.getSchemas().values().stream().anyMatch(e -> e == schema);

		CollectionDataType newCollection = tdlFactory.eINSTANCE.createCollectionDataType();
		setName(newCollection, isAnonymous ? "Collection" + anonymousCollectionCounter++ : schema.getName());

		//check naming conventions
		if(isAnonymous) {
			setName(newCollection, "Collection" + anonymousCollectionCounter++);
			String name = "";
			Object parentSchema = schema;
			while (parentSchema instanceof Schema) {
				//TODO: reflection hack
				JsonOverlay<?> jo = (JsonOverlay<?>) parentSchema;
				try {
					Field parent = JsonOverlay.class.getDeclaredField("parent");
					parent.setAccessible(true);
					parentSchema = parent.get(jo);
					//Skip to previous parent, 
					//TODO: double check this is reliable 
					//TODO: also check if it is applicable for structured data in general
					if (parentSchema instanceof MapOverlay) {
						parent = JsonOverlay.class.getDeclaredField("parent");
						parent.setAccessible(true);
						parentSchema = parent.get((JsonOverlay<?>)parentSchema);
					}

					Field pathInParent = JsonOverlay.class.getDeclaredField("pathInParent");
					pathInParent.setAccessible(true);
					String parentPath = (String) pathInParent.get(jo);

					name = parentPath+"___"+name;
				} catch (Exception e) {
					// TODO Auto-generated catch block
					System.err.println("ERROR: "+e.getMessage());
				}
			}
			setName(newCollection, name.replaceAll("___$", ""));

		}
		else {
			String typeName = schema.getName();
			if(typeName != null) {
				if(Character.isLowerCase(typeName.charAt(0))) {
					Annotation an = tdlFactory.eINSTANCE.createAnnotation();
					an.setKey(warningAnnotationType);
					an.setValue(setQuotationMarks("Naming conventions not followed: First letter should be upper case."));
					newCollection.getAnnotation().add(an);
					warningList.add(new NamingConventionWarning(String.format("The Collection %s is not following naming conventions. First letter should be upper case.", schema.getName())));
				}
			}
			setName(newCollection, schema.getName());
		}
		
		DataType itemType = createDataTypeFromSchema(schema.getItemsSchema());
		newCollection.setItemType(itemType);
		if (isAnonymous) {
			itemType.setName(newCollection.getName()+itemType.getName().replaceFirst("^.+?___", "___"));
		}
		
		Optional<CollectionDataType> optionalReplacement = collectionDataTypeBySchema.values().stream().filter(coll -> coll.getItemType() == itemType && coll.getName().matches("Collection\\d+")).findFirst();
		if (isAnonymous && optionalReplacement.isPresent()) {
			return optionalReplacement.get();
		}

		collectionDataTypeBySchema.put(wrapped, newCollection);

		return newCollection;
	}

	public StructuredDataType createStructuredDataTypeFromSchema(Schema schema) {
		SchemaWrapper wrapped = new SchemaWrapper(schema);
		if (schema.getName() != null && structuredDataTypeBySchema.containsKey(wrapped)) {
			return structuredDataTypeBySchema.get(wrapped);
		}
		
		checkSchemaForExample(schema);
		
		StructuredDataType newStructure = tdlFactory.eINSTANCE.createStructuredDataType();

		String prefixedName = "";
		Object parentSchema = schema;
		while (parentSchema instanceof Schema) {
			//TODO: reflection hack
			JsonOverlay<?> jo = (JsonOverlay<?>) parentSchema;
			try {
				Field parent = JsonOverlay.class.getDeclaredField("parent");
				parent.setAccessible(true);
				parentSchema = parent.get(jo);
				
				Field pathInParent = JsonOverlay.class.getDeclaredField("pathInParent");
				pathInParent.setAccessible(true);
				String parentPath = (String) pathInParent.get(jo);
				
				prefixedName = parentPath+"___"+prefixedName;
				if (parentSchema instanceof MapOverlay) {
					//skip one level
					Field nextParent = JsonOverlay.class.getDeclaredField("parent");
					nextParent.setAccessible(true);
					parentSchema = nextParent.get((JsonOverlay<?>) parentSchema);
				}
			} catch (Exception e) {
				// TODO Auto-generated catch block
				System.err.println("ERROR: "+e.getMessage());
			}
		}
		if(schema.getName()==null) {
			setName(newStructure, "Object" + anonymousObjectCounter++);
			setName(newStructure, prefixedName.replaceAll("___$", ""));
		}
		else {
			String typeName = schema.getName();
			if(typeName != null) {
				if(Character.isLowerCase(typeName.charAt(0))) {
					Annotation an = tdlFactory.eINSTANCE.createAnnotation();
					an.setKey(warningAnnotationType);
					an.setValue(setQuotationMarks("Naming conventions not followed: First letter should be upper case."));
					newStructure.getAnnotation().add(an);
					warningList.add(new NamingConventionWarning(String.format("The StructuredDataType %s is not following naming conventions. First letter should be upper case.", schema.getName())));
				}
			}
			setName(newStructure, schema.getName());
			setName(newStructure, prefixedName.replaceAll("___$", ""));
		}
		
		structuredDataTypeBySchema.put(wrapped, newStructure);
		
		boolean containsCorrectIdProperty = false;
		String idPropName = newStructure.getName().substring(0, 1).toLowerCase() + newStructure.getName().substring(1) + "Id";
		for (String propertyName : schema.getProperties().keySet()) {
			Schema memberSchema = schema.getProperty(propertyName);
			DataType memberType = createDataTypeFromSchema(memberSchema);
			if (!(memberType instanceof SimpleDataType)) {
				System.out.println("structure type:" +prefixedName);
				memberType.setName(newStructure.getName()+"___"+memberType.getName().replaceAll(".+?___", ""));
				System.out.println("member type:" +memberType.getName());
			}

			Member member = tdlFactory.eINSTANCE.createMember();

			setName(member, propertyName);
			if (propertyName.equals(idPropName)) {
				containsCorrectIdProperty = true;
			}
			if(Character.isUpperCase(propertyName.charAt(0))){
				Annotation an = tdlFactory.eINSTANCE.createAnnotation();
				an.setKey(warningAnnotationType);
				an.setValue(setQuotationMarks("Naming conventions not followed: First letter of a property should be lower case."));
				member.getAnnotation().add(an);
				NamingConventionWarning warning = new NamingConventionWarning(String.format("The StructuredDataType %s is not following naming conventions. Properties should start with a lower case letter.", newStructure.getName()));
				if(!warningList.contains(warning)){
					warningList.add(warning);
				}
			}

			member.setDataType(memberType);
			if (!addDescription(member, memberSchema)) {
				warningList.add(new DescriptionMissingWarning(String.format("Missing description for property %s of schema %s", memberSchema.getName(), schema.getName())));
			}
			newStructure.getMember().add(member);
		}
		if(!containsCorrectIdProperty) {
			Annotation an = tdlFactory.eINSTANCE.createAnnotation();
			an.setKey(warningAnnotationType);
			an.setValue(setQuotationMarks("The DataType does not contain an Id-property that follows the naming conventions (DataTypeName+\"Id\")."));
			newStructure.getAnnotation().add(an);
			warningList.add(new NamingConventionWarning(String.format("The StructuredDataType %s does not contain an Id-property that follows the naming conventions (DataTypeName+\"Id\").", schema.getName())));
		}
		
		return newStructure;
	}

	public SimpleDataType createSimpleDataTypeFromSchema(Schema schema) {
		String name = schema.getType();
		
		if (simpleDataTypeByName.containsKey(name)) {
			return simpleDataTypeByName.get(name);
		}
		
		SimpleDataType type = tdlFactory.eINSTANCE.createSimpleDataType();
		setName(type, schema.getType());
		simpleDataTypeByName.put(name, type);
		
		return type;
	}

	/**
	 * Save a TDL package to a file
	 */
//	public static void saveTdlan2(Package p, String filename) throws IOException {
//		Resource resource = TDLHelper.create(filename);
//		
//		resource.getContents().add(p);
//		try {
//			TDLHelper.store(resource);
//		} catch (Exception e) {
//			throw new IOException(String.format("Error while writing tdlan2 file: %s: '%s'", e.getClass().getName(), e.getMessage()), e);
//		}		
//	}

	/**
	 * Load OpenApi3 model from file 
	 */
	public static OpenApi3 loadOpenApi3(String filename) throws FileNotFoundException {
		File file = new File(filename);
		if (!file.exists()) {
			throw new FileNotFoundException(String.format("File '%s' does not exist", filename));
		}
		OpenApi3 model;
		try {
			model = new OpenApi3Parser().parse(file, true);
		} catch (Exception e) {
			throw new RuntimeException(String.format("Error while parsing the yaml file: %s: '%s'", e.getClass().getName(), e.getMessage()), e);
		}
		return model;
	}

	public void runConversion() {
		pkg = exportToTdlPackage();
	}
//	public void saveResult(String filename) throws IllegalStateException, IOException {
//		if (pkg == null) {
//			throw new IllegalStateException("Cannot save result before conversion is executed");
//		}
//		saveTdlan2(pkg, filename);
//	}
	
	private String setQuotationMarks(String s) {
		return 	//TODO: this is only needed for legacy configuration..
				"\"" + 
				s
				.replaceAll("\"", "\\\\\"")
				+ "\""
				;
	}
	public static Collection<String> getTdlTokens() {
		return tdlTokens;
	}
	public static void setTdlTokens(Collection<String> tdlTokens) {
		Converter.tdlTokens = tdlTokens;
	}
}


/**
 * Wrapper for Schema objects to include the name in the equals method.
 * equals(other) now returns true for SchemaWrapper objects wrapping the same Schema object.
 */
class SchemaWrapper {
	private final Schema schema;
	public SchemaWrapper(Schema schema) {
		this.schema = schema;
	}
	
	@Override
	public boolean equals(Object o) {
		if (o instanceof SchemaWrapper) {
			SchemaWrapper other = (SchemaWrapper) o;
			return schema == other.schema;
		}
		return false;
	}

	@Override
	public int hashCode() {
		return schema.hashCode();
	}
	
	public String getName() {
		return schema.getName();
	}
}
