/**
 * Copyright (c) 2016 NumberFour AG.
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License v1.0
 * which accompanies this distribution, and is available at
 * http://www.eclipse.org/legal/epl-v10.html
 *
 * Contributors:
 *   NumberFour AG - Initial API and implementation
 */
package org.eclipse.n4js.jsdoc2spec;

import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.function.Function;

import org.eclipse.emf.common.util.EList;
import org.eclipse.emf.common.util.URI;
import org.eclipse.emf.ecore.EObject;
import org.eclipse.emf.ecore.resource.Resource;
import org.eclipse.emf.ecore.resource.ResourceSet;
import org.eclipse.emf.ecore.util.EcoreUtil;
import org.eclipse.n4js.AnnotationDefinition;
import org.eclipse.n4js.jsdoc.N4JSDocHelper;
import org.eclipse.n4js.jsdoc.N4JSDocletParser;
import org.eclipse.n4js.jsdoc.dom.ContentNode;
import org.eclipse.n4js.jsdoc.dom.Doclet;
import org.eclipse.n4js.jsdoc.dom.FullMemberReference;
import org.eclipse.n4js.jsdoc.dom.LineTag;
import org.eclipse.n4js.jsdoc.tags.LineTagWithFullElementReference;
import org.eclipse.n4js.jsdoc2spec.adoc.RepoRelativePathHolder;
import org.eclipse.n4js.n4JS.FunctionOrFieldAccessor;
import org.eclipse.n4js.n4JS.Script;
import org.eclipse.n4js.projectModel.IN4JSCore;
import org.eclipse.n4js.projectModel.IN4JSProject;
import org.eclipse.n4js.projectModel.IN4JSSourceContainer;
import org.eclipse.n4js.projectModel.locations.FileURI;
import org.eclipse.n4js.resource.N4JSResource;
import org.eclipse.n4js.scoping.N4JSGlobalScopeProvider;
import org.eclipse.n4js.ts.types.SyntaxRelatedTElement;
import org.eclipse.n4js.ts.types.TClassifier;
import org.eclipse.n4js.ts.types.TMember;
import org.eclipse.n4js.ts.types.TMethod;
import org.eclipse.n4js.ts.types.TModule;
import org.eclipse.n4js.ts.types.TVariable;
import org.eclipse.n4js.ts.types.Type;
import org.eclipse.n4js.ts.types.util.MemberList;
import org.eclipse.n4js.utils.ContainerTypesHelper;
import org.eclipse.xtext.EcoreUtil2;

import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Iterables;
import com.google.inject.Inject;

/**
 */
public class N4JSDReader {

	@Inject
	ContainerTypesHelper containerTypesHelper;

	@Inject
	N4JSDocHelper n4jsDocHelper;

	@Inject
	IN4JSCore n4jsCore;

	@Inject
	N4JSGlobalScopeProvider globalScopeProvider;

	@Inject
	RepoRelativePathHolder rrph;

	IJSDoc2SpecIssueAcceptor issueAcceptor = IJSDoc2SpecIssueAcceptor.NULL_ACCEPTOR;

	/**
	 * Reads all N4JSD files in project, scans for types and links the tests.
	 *
	 * @return all types in a mapped with fully qualified type name (inclusive module spec) as key, the type info only
	 *         contains the types, no other information yet.
	 * @throws InterruptedException
	 *             thrown when user cancels the operation
	 */
	public Collection<SpecInfo> readN4JSDs(Collection<IN4JSProject> projects,
			Function<IN4JSProject, ResourceSet> resSetProvider, SubMonitorMsg monitor) throws InterruptedException {

		SpecInfosByName specInfosByName = new SpecInfosByName(issueAcceptor, globalScopeProvider, containerTypesHelper,
				n4jsCore);
		ResourceSet resSet = null;
		SubMonitorMsg sub = monitor.convert(2 * 100 * projects.size());
		for (IN4JSProject project : projects) {
			if (resSet == null) {
				resSet = resSetProvider.apply(project);
			}
			readScripts(specInfosByName, project, resSet, sub.newChild(100));
		}
		for (IN4JSProject project : projects) {
			if (resSet == null) {
				resSet = resSetProvider.apply(project);
			}
			linkTests(specInfosByName, project, resSet, sub.newChild(100));
		}

		return specInfosByName.values();
	}

	/**
	 * Reads all N4JSD files in project and scans for types. No further information is added yet. Reads all types into a
	 * map with fully qualified type name (inclusive module spec) as key, the type info only contains the types, no
	 * other information yet.
	 *
	 * @param specInfosByName
	 *            map of fqn of types or reqid keys to their corresponding spec info.
	 * @throws InterruptedException
	 *             thrown when user cancels the operation
	 */
	private void readScripts(SpecInfosByName specInfosByName, IN4JSProject project, ResourceSet resSet,
			SubMonitorMsg monitor) throws InterruptedException {

		ImmutableList<? extends IN4JSSourceContainer> srcCont = project.getSourceContainers();
		List<IN4JSSourceContainer> srcContFilter = new LinkedList<>();

		int count = 0;
		for (IN4JSSourceContainer container : srcCont) {
			if (container.isSource() || container.isTest()) {
				count += Iterables.size(container);
				srcContFilter.add(container);
			}
		}
		SubMonitorMsg sub = monitor.convert(count);

		for (IN4JSSourceContainer container : srcContFilter) {
			for (URI uri : container) {
				String ext = uri.fileExtension();
				if ("n4js".equals(ext) || "n4jsd".equals(ext)) {
					try {
						Resource resource = resSet.getResource(uri, true);
						if (resource != null) {
							Script script = (Script) (resource.getContents().isEmpty() ? null
									: resource.getContents().get(0));
							if (script == null) {
								// throw new IllegalStateException("Error parsing " + uri);
								continue;
							}
							N4JSResource.postProcess(resource);
							for (Type type : getRealTopLevelTypes(script)) {
								specInfosByName.createTypeSpecInfo(type, rrph);
							}
							for (TVariable tvar : script.getModule().getVariables()) {
								specInfosByName.createTVarSpecInfo(tvar, rrph);
							}
						}
					} catch (Exception ex) {
						ex.printStackTrace();
						String msg = "Error processing " + uri + ": " + ex.getMessage();
						throw new IllegalArgumentException(msg, ex);
					}
				}
				sub.worked(1);
				sub.checkCanceled();
			}
		}
	}

	/**
	 * The method {@link TModule#getTopLevelTypes()} returns also functions that are nested in functions. These are
	 * filtered out in this method.
	 *
	 * @return real top level types
	 */
	private Collection<Type> getRealTopLevelTypes(Script script) {
		Collection<Type> realTLT = new LinkedList<>();
		for (Type tlt : script.getModule().getTopLevelTypes()) {
			if (tlt instanceof SyntaxRelatedTElement) {
				SyntaxRelatedTElement srte = (SyntaxRelatedTElement) tlt;
				EObject astElem = srte.getAstElement();
				astElem = astElem != null ? astElem.eContainer() : null;
				FunctionOrFieldAccessor fofa = EcoreUtil2.getContainerOfType(astElem, FunctionOrFieldAccessor.class);
				if (fofa != null) {
					continue;
				}
			}

			realTLT.add(tlt);
		}
		return realTLT;
	}

	/**
	 * Links the tests to the testees and may create new specInfos for requirement ID related tests.
	 *
	 * @throws InterruptedException
	 *             thrown when user cancels the operation
	 */
	private void linkTests(SpecInfosByName specInfosByName, IN4JSProject project, ResourceSet resSet,
			SubMonitorMsg monitor) throws InterruptedException {

		List<Type> testTypes = getTestTypes(project, resSet, monitor);

		for (Type testType : testTypes) {
			try {
				if (testType instanceof TClassifier) {
					TClassifier ctype = (TClassifier) testType;
					processClassifier(specInfosByName, ctype);
				}
			} catch (Exception ex) {
				ex.printStackTrace();
				String msg = "Error processing " + testType.eResource().getURI().toString() + ": " + ex.getMessage();
				throw new IllegalArgumentException(msg);
			}
		}
	}

	private List<Type> getTestTypes(IN4JSProject project, ResourceSet resSet, SubMonitorMsg monitor)
			throws InterruptedException {

		List<Type> testTypes = new ArrayList<>();
		ImmutableList<? extends IN4JSSourceContainer> srcCont = project.getSourceContainers();

		// count container
		int count = 0;
		for (IN4JSSourceContainer container : srcCont) {
			if (container.isTest()) {
				count += Iterables.size(container);
			}
		}
		SubMonitorMsg sub = monitor.convert(count);

		// scan for types
		for (IN4JSSourceContainer container : srcCont) {
			if (container.isTest()) {
				for (URI uri : container) {
					String ext = uri.fileExtension();
					if ("n4js".equals(ext)) {
						Resource resource = resSet.getResource(uri, true);
						if (resource != null) {
							Script script = (Script) (resource.getContents().isEmpty() ? null
									: resource.getContents().get(0));

							if (script == null) {
								throw new IllegalStateException("Error parsing " + uri);
							}
							N4JSResource.postProcess(resource);
							for (Type type : getRealTopLevelTypes(script)) {
								testTypes.add(type);
							}
						}
					}
					sub.worked(1);
					sub.checkCanceled();
				}
			}
		}
		return testTypes;
	}

	private void processClassifier(SpecInfosByName specInfosByName, TClassifier testType) {
		RepoRelativePath rrpOfTest = RepoRelativePath.compute(createFileURI(testType), n4jsCore);

		// Retrieve the references to testees stated in the jsdoc of the test class itself.
		Doclet testTypeDoclet = n4jsDocHelper.getDoclet(testType.getAstElement());
		Collection<FullMemberReference> testeeRefsFromType = getFullMemberRefsFromType(testTypeDoclet);
		Collection<FullMemberReference> testeeTypeRefsFromType = getFullTypeRefsFromType(testTypeDoclet);

		MemberList<TMember> allMembers = containerTypesHelper.fromContext(testType).allMembers(testType, false, false);
		for (TMember testMember : allMembers) {
			boolean isOwnedMember = testMember.getContainingType() == testType;
			if (testMember instanceof TMethod && AnnotationDefinition.TEST_METHOD.hasAnnotation(testMember)) {
				EObject astElement = testMember.getAstElement();
				if (!astElement.eIsProxy()) {

					// Retrieve the references to testees stated in the jsdoc of the test
					Doclet testMethodDoclet = n4jsDocHelper.getDoclet(astElement);
					LineTag tag = findLinkToElementTag(testMethodDoclet, isOwnedMember);

					if (tag != null) {
						processTag(specInfosByName, rrpOfTest, testeeRefsFromType, testeeTypeRefsFromType, testMember,
								isOwnedMember, astElement, testMethodDoclet, tag);
					}
				} else {
					System.err.println("cannot result AST when scanning for doclets: " + astElement);
				}
			}
		}
	}

	private void processTag(SpecInfosByName specInfosByName, RepoRelativePath rrpOfTest,
			Collection<FullMemberReference> testeeRefsFromType, Collection<FullMemberReference> testeeTypeRefsFromType,
			TMember testMember, boolean isOwnedMember, EObject astElement, Doclet testMethodDoclet, LineTag tag) {

		String title = tag.getTitle().getTitle();
		if ("testee".equals(title)) {
			FullMemberReference ref = getFullMemberRef(tag);
			if (ref != null) {
				specInfosByName.addTestInfoForCodeElement(rrpOfTest, testMethodDoclet, ref, testMember);
			}

		} else if ("testeeFromType".equals(title)) {
			RepoRelativePath rrpTestMethod = isOwnedMember ? rrpOfTest
					: RepoRelativePath.compute(createFileURI(astElement), n4jsCore);

			for (FullMemberReference ref : testeeRefsFromType) {
				specInfosByName.addTestInfoForCodeElement(rrpTestMethod, testMethodDoclet, ref, testMember);
			}

		} else if ("testeeMember".equals(title)) {
			String testeeMember = N4JSDocletParser.TAG_TESTEEMEMBER.getValue(tag, "");
			RepoRelativePath rrpTestMethod = isOwnedMember ? rrpOfTest
					: RepoRelativePath.compute(createFileURI(astElement), n4jsCore);

			for (FullMemberReference testeeTypeRef : testeeTypeRefsFromType) {
				FullMemberReference combinedTesteeRef = EcoreUtil.copy(testeeTypeRef);
				combinedTesteeRef.setMemberName(testeeMember);
				combinedTesteeRef.setRange(tag.getBegin(), tag.getEnd());
				specInfosByName.addTestInfoForCodeElement(
						rrpTestMethod,
						testMethodDoclet,
						combinedTesteeRef,
						testMember);
			}

		} else if ("reqid".equals(title)) {
			String reqid = N4JSDocletParser.TAG_REQID.getValue(tag, "");
			if (Strings.isNullOrEmpty(reqid)) {
				throw new IllegalStateException("Found reqid tag without requirement ID.");
			}
			RepoRelativePath rrpTestMethod = isOwnedMember ? rrpOfTest
					: RepoRelativePath.compute(createFileURI(astElement), n4jsCore);

			specInfosByName.addTestInfoForRequirement(rrpTestMethod, testMethodDoclet, reqid, testMember);

		} else {
			// should not happen
			System.err.println("unhandled referencing tag: " + title);
		}
	}

	/**
	 * Returns the best matching tag which links the test to either a type, member, or reqid.
	 */
	private LineTag findLinkToElementTag(Doclet testMethodDoclet, boolean isOwnedMember) {
		LineTag bestMatch = null;
		for (LineTag tag : testMethodDoclet.getLineTags()) {
			if (isOwnedMember && "testee".equals(tag.getTitle().getTitle())) {
				bestMatch = tag; // testee always is the best match
				break;
			} else if ("testeeFromType".equals(tag.getTitle().getTitle())) {
				bestMatch = tag; // testeeFromType overrules testeeMember and reqid
			} else if ("testeeMember".equals(tag.getTitle().getTitle())) {
				if (bestMatch == null || "reqid".equals(bestMatch.getTitle().getTitle())) {
					bestMatch = tag; // testeeMember overrules reqid
				}
			} else if (isOwnedMember && "reqid".equals(tag.getTitle().getTitle())) {
				if (bestMatch == null) {
					bestMatch = tag; // lowes prio
				}
			}
		}
		return bestMatch;
	}

	private Collection<FullMemberReference> getFullMemberRefsFromType(Doclet testTypeDoclet) {
		Map<String, FullMemberReference> refsByName = new HashMap<>();
		for (LineTag tag : testTypeDoclet.getLineTags()) {
			if ("testee".equals(tag.getTitle().getTitle())) {
				FullMemberReference ref = getFullMemberRef(tag);
				if (ref != null) {
					refsByName.put(ref.toString(), ref);
				}
			}
		}
		return refsByName.values();
	}

	private Collection<FullMemberReference> getFullTypeRefsFromType(Doclet testTypeDoclet) {
		Map<String, FullMemberReference> refsByName = new HashMap<>();
		for (LineTag tag : testTypeDoclet.getLineTags()) {
			if ("testeeType".equals(tag.getTitle().getTitle())) {
				FullMemberReference ref = getFullMemberRef(tag);
				if (ref != null) {
					refsByName.put(ref.toString(), ref);
				}
			}
		}
		return refsByName.values();
	}

	private FullMemberReference getFullMemberRef(LineTag tag) {
		EList<ContentNode> contents = tag.getValueByKey(LineTagWithFullElementReference.REF).getContents();
		if (!contents.isEmpty()) {
			return (FullMemberReference) contents.get(0);
		}
		return null;
	}

	private FileURI createFileURI(EObject eObject) {
		return new FileURI(eObject.eResource().getURI());
	}

}