/*
 * Cobertura - http://cobertura.sourceforge.net/
 *
 * Copyright (C) 2005 Mark Doliner
 * Copyright (C) 2005 Jeremy Thomerson
 * Copyright (C) 2005 Grzegorz Lukasik
 * Copyright (C) 2008 Tri Bao Ho
 * Copyright (C) 2009 John Lewis
 * Copyright (C) 2014 Kunal Shah
 *
 * Cobertura is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published
 * by the Free Software Foundation; either version 2 of the License,
 * or (at your option) any later version.
 *
 * Cobertura is distributed in the hope that it will be useful, but
 * WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
 * General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with Cobertura; if not, write to the Free Software
 * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
 * USA
 */
package net.sourceforge.cobertura.reporting;

import net.sourceforge.cobertura.coveragedata.ClassData;
import net.sourceforge.cobertura.coveragedata.PackageData;
import net.sourceforge.cobertura.coveragedata.ProjectData;
import net.sourceforge.cobertura.coveragedata.SourceFileData;
import net.sourceforge.cobertura.javancss.FunctionMetric;
import net.sourceforge.cobertura.javancss.Javancss;
import net.sourceforge.cobertura.javancss.JavancssFactory;

import net.sourceforge.cobertura.util.FileFinder;
import net.sourceforge.cobertura.util.Source;

import org.apache.commons.lang3.ClassUtils;
import org.apache.commons.lang3.Validate;
import org.objectweb.asm.Opcodes;
import org.objectweb.asm.signature.SignatureReader;
import org.objectweb.asm.util.TraceSignatureVisitor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.io.InputStreamReader;
import java.io.Reader;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.regex.Pattern;

/**
 * Allows complexity computing for source files, packages and a whole project. Average
 * McCabe's number for methods contained in the specified entity is returned. This class
 * depends on FileFinder which is used to map source file names to existing files.
 * <p/>
 * <p>One instance of this class should be used for the same set of source files - an
 * object of this class can cache computed results.</p>
 *
 * @author Grzegorz Lukasik
 */
public class ComplexityCalculator {
	private static final Logger logger = LoggerFactory
			.getLogger(ComplexityCalculator.class);

	public static final Complexity ZERO_COMPLEXITY = new Complexity();

	// Finder used to map source file names to existing files
	private final FileFinder finder;

	// Factory use to get instances of {@code Javancss}
	private final JavancssFactory javancssFactory;

	// Contains pairs (String sourceFileName, Complexity complexity)
	private Map sourceFileCNNCache = new HashMap();

	// Contains pairs (String packageName, Complexity complexity)
	private Map packageCNNCache = new HashMap();

	// Cache for source file name to its function metrics parsed by {@link Javancss}
	private static final int FILE_FUNCTION_METRIC_CACHE_SIZE = 6;
	private Map<String, List<FunctionMetric>> sourceFileFunctionMetricCache = new LinkedHashMap<String, List<FunctionMetric>>(
		FILE_FUNCTION_METRIC_CACHE_SIZE, 0.75f, true) {
		private static final long serialVersionUID = 1L;

		@Override
		protected boolean removeEldestEntry(Entry<String, List<FunctionMetric>> arg0) {
			return size() > FILE_FUNCTION_METRIC_CACHE_SIZE;
		}
	};

	private boolean calculateMethodComplexity;

	private String encoding;

	/**
	 * Creates new calculator. Passed {@link FileFinder} will be used to
	 * map source file names to existing files when needed.
	 *
	 * @param finder {@link FileFinder} that allows to find source files
	 *
	 * @throws NullPointerException if finder is null
	 */
	public ComplexityCalculator(FileFinder finder) {
		this(finder, new JavancssFactory());
	}

	/**
	 * Creates new calculator. Passed {@link FileFinder} will be used to
	 * map source file names to existing files when needed.
	 *
	 * @param finder          {@link FileFinder} that allows to find source files
	 * @param javancssFactory factory to get instances of {@link Javancss}
	 * @throws NullPointerException if finder or javancssFactory is null
	 */
	public ComplexityCalculator(FileFinder finder, JavancssFactory javancssFactory) {
		this.finder = Validate.notNull(finder, "finder should not be null");
		this.javancssFactory = Validate.notNull(javancssFactory, "javancssFactory should not be null");
	}

	/**
	 * Calculates the code complexity number for single source file.
	 * "CCN" stands for "code complexity number."  This is
	 * sometimes referred to as McCabe's number.  This method
	 * calculates the average cyclomatic code complexity of all
	 * methods of all classes in a given directory.
	 *
	 * @param sourceFileName the name of the source file for which you want to calculate the complexity
	 * @return average complexity for the specified source file
	 * @throws IOException
	 */
	private Complexity getAccumlatedCCNForSingleFile(String sourceFileName)
		throws IOException {
		List methodMetrics = getFunctionMetricsForSingleFile(sourceFileName);
		if (methodMetrics.isEmpty()) {
			return ZERO_COMPLEXITY;
		}

		int classCcn = 0;
		for (Iterator method = methodMetrics.iterator(); method.hasNext(); ) {
			FunctionMetric singleMethodMetrics = (FunctionMetric) method.next();
			classCcn += singleMethodMetrics.ccn;
		}

		return new Complexity(classCcn, methodMetrics.size());
	}

	/**
	 * Computes CCN for all sources contained in the project.
	 * CCN for whole project is an average CCN for source files.
	 * All source files for which CCN cannot be computed are ignored.
	 *
	 * @param projectData project to compute CCN for
	 *
	 * @return CCN for project or 0 if no source files were found
	 *
	 * @throws NullPointerException if projectData is null
	 */
	public double getCCNForProject(ProjectData projectData) {
		// Sum complexity for all packages
		Complexity act = new Complexity();

		for (Object pkg : projectData.getPackages()) {
			PackageData packageData = (PackageData) pkg;
			act.add(getCCNForPackageInternal(packageData));
		}

		// Return average CCN for source files
		return act.averageCCN();
	}

	/**
	 * Computes CCN for all sources contained in the specified package.
	 * All source files that cannot be mapped to existing files are ignored.
	 *
	 * @param packageData package to compute CCN for
	 *
	 * @return CCN for the specified package or 0 if no source files were found
	 *
	 * @throws NullPointerException if <code>packageData</code> is <code>null</code>
	 */
	public double getCCNForPackage(PackageData packageData) {
		return getCCNForPackageInternal(packageData).averageCCN();
	}

	private Complexity getCCNForPackageInternal(PackageData packageData) {
		// Return CCN if computed earlier
		Complexity cachedCCN = (Complexity) packageCNNCache.get(packageData
				.getName());
		if (cachedCCN != null) {
			return cachedCCN;
		}

		// Compute CCN for all source files inside package
		Complexity act = new Complexity();
		for (Iterator it = packageData.getSourceFiles().iterator(); it
				.hasNext();) {
			SourceFileData sourceData = (SourceFileData) it.next();
			act.add(getCCNForSourceFileNameInternal(sourceData.getName()));
		}

		// Cache result and return it
		packageCNNCache.put(packageData.getName(), act);
		return act;
	}

	/**
	 * Computes CCN for single source file.
	 *
	 * @param sourceFile source file to compute CCN for
	 *
	 * @return CCN for the specified source file, 0 if cannot map <code>sourceFile</code> to existing file
	 *
	 * @throws NullPointerException if <code>sourceFile</code> is <code>null</code>
	 */
	public double getCCNForSourceFile(SourceFileData sourceFile) {
		return getCCNForSourceFileNameInternal(sourceFile.getName())
				.averageCCN();
	}

	/**
	 * Get the function metrics for the given source file. Use this over the {@link #sourceFileFunctionMetricCache} field.
	 *
	 * @param sourceFileName the name of the source file
	 * @return the function metrics for the given source file (parsed by {@link Javancss})
	 */
	private List<FunctionMetric> getFunctionMetricsForSingleFile(String sourceFileName) {
		List<FunctionMetric> functionMetrics = Collections.emptyList();
		if (!sourceFileFunctionMetricCache.containsKey(sourceFileName)) {
			Source source = null;
			try {
				source = finder.getSource(sourceFileName);
				if (source != null && sourceFileName.endsWith(".java")) {
					Javancss javancss = javancssFactory.newInstance(source.getInputStream(), encoding);
					if (javancss.getLastErrorMessage() != null) {
						//there is an error while parsing the java file. log it
						logger.warn("JavaNCSS got an error while parsing the java "
							+ source.getOriginDesc() + "\n"
							+ javancss.getLastErrorMessage());
					}
					functionMetrics = javancss.getFunctionMetrics();
				}
			} finally {
				if (source != null) {
					source.close();
				}
			}
			sourceFileFunctionMetricCache.put(sourceFileName, functionMetrics);
		}
		return sourceFileFunctionMetricCache.get(sourceFileName);
	}

	private Complexity getCCNForSourceFileNameInternal(String sourceFileName) {
		// Return CCN if computed earlier
		Complexity cachedCCN = (Complexity) sourceFileCNNCache
				.get(sourceFileName);
		if (cachedCCN != null) {
			return cachedCCN;
		}

		// Compute CCN and cache it for further use
		Complexity result = ZERO_COMPLEXITY;
		try {
			result = getAccumlatedCCNForSingleFile(sourceFileName);
		} catch (IOException ex) {
			logger
					.info("Cannot find source file during CCN computation, source=["
							+ sourceFileName + "]");
		}
		sourceFileCNNCache.put(sourceFileName, result);
		return result;
	}

	/**
	 * Computes CCN for source file the specified class belongs to.
	 *
	 * @param classData package to compute CCN for
	 *
	 * @return CCN for source file the specified class belongs to
	 *
	 * @throws NullPointerException if <code>classData</code> is <code>null</code>
	 */
	public double getCCNForClass(ClassData classData) {
		return getCCNForSourceFileNameInternal(classData.getSourceFileName())
				.averageCCN();
	}

	/**
	 * Computes CCN for a method.
	 *
	 * @param classData        class data for the class which contains the method to compute CCN for
	 * @param methodName       the name of the method to compute CCN for
	 * @param methodDescriptor the descriptor of the method to compute CCN for
	 * @return CCN for the method
	 * @throws NullPointerException if <code>classData</code> is <code>null</code>
	 */
	public int getCCNForMethod(ClassData classData, String methodName, String methodDescriptor) {
		if(!calculateMethodComplexity) {
			return 0;
		}

		Validate.notNull(classData, "classData must not be null");
		Validate.notNull(methodName, "methodName must not be null");
		Validate.notNull(methodDescriptor, "methodDescriptor must not be null");

		int complexity = 0;
		List<FunctionMetric> methodMetrics = getFunctionMetricsForSingleFile(classData.getSourceFileName());

		// golden method = method for which we need ccn
		String goldenMethodName = methodName;
		boolean isConstructor = false;
		if (goldenMethodName.equals("<init>")) {
			isConstructor = true;
			goldenMethodName = classData.getBaseName();
		}
		// fully-qualify the method
		goldenMethodName = classData.getName() + "." + goldenMethodName;
		// replace nested class separator $ by .
		goldenMethodName = goldenMethodName.replaceAll(Pattern.quote("$"), ".");

		TraceSignatureVisitor v = new TraceSignatureVisitor(Opcodes.ACC_PUBLIC);
		SignatureReader r = new SignatureReader(methodDescriptor);
		r.accept(v);

		// for the scope of this method, signature = signature of the method excluding the method name
		String goldenSignature = v.getDeclaration();
		// get parameter type list string which is enclosed by round brackets ()
		goldenSignature = goldenSignature.substring(1, goldenSignature.length() - 1);

		// collect all the signatures with the same method name as golden method
		Map<String, Integer> candidateSignatureToCcn = new HashMap<String, Integer>();
		for (FunctionMetric singleMethodMetrics : methodMetrics) {
			String candidateMethodName = singleMethodMetrics.name.substring(0, singleMethodMetrics.name.indexOf('('));
			String candidateSignature = stripTypeParameters(singleMethodMetrics.name.substring(singleMethodMetrics.name.indexOf('(') + 1,
				singleMethodMetrics.name.length() - 1));
			if (goldenMethodName.equals(candidateMethodName)) {
				candidateSignatureToCcn.put(candidateSignature, singleMethodMetrics.ccn);
			}
		}

		// if only one signature, no signature matching needed
		if (candidateSignatureToCcn.size() == 1) {
			return candidateSignatureToCcn.values().iterator().next();
		}

		// else, do signature matching and find the best match

		// update golden signature using reflection
		if (!goldenSignature.isEmpty()) {
			try {
				String[] goldenParameterTypeStrings = goldenSignature.split(",");
				Class<?>[] goldenParameterTypes = new Class[goldenParameterTypeStrings.length];
				for (int i = 0; i < goldenParameterTypeStrings.length; i++) {
					goldenParameterTypes[i] = ClassUtils.getClass(goldenParameterTypeStrings[i].trim(), false);
				}
				Class<?> klass = ClassUtils.getClass(classData.getName(), false);
				if (isConstructor) {
					Constructor<?> realMethod = klass.getDeclaredConstructor(goldenParameterTypes);
					goldenSignature = realMethod.toGenericString();
				} else {
					Method realMethod = klass.getDeclaredMethod(methodName, goldenParameterTypes);
					goldenSignature = realMethod.toGenericString();
				}
				// replace varargs ellipsis with array notation
				goldenSignature = goldenSignature.replaceAll("\\.\\.\\.", "[]");
				// extract the parameter type list string
				goldenSignature = goldenSignature.substring(goldenSignature.indexOf("(") + 1, goldenSignature.length() - 1);
				// strip the type parameters to get raw types
				goldenSignature = stripTypeParameters(goldenSignature);
			} catch (Exception e) {
				logger.error("Error while getting method CC for " + goldenMethodName, e);
				return 0;
			}
		}
		// replace nested class separator $ by .
		goldenSignature = goldenSignature.replaceAll(Pattern.quote("$"), ".");

		// signature matching - due to loss of fully qualified parameter types from JavaCC, get ccn for the closest match
		double signatureMatchPercentTillNow = 0;
		for (Entry<String, Integer> candidateSignatureToCcnEntry : candidateSignatureToCcn.entrySet()) {
			String candidateSignature = candidateSignatureToCcnEntry.getKey();
			double currentMatchPercent = matchSignatures(candidateSignature, goldenSignature);
			if (currentMatchPercent == 1) {
				return candidateSignatureToCcnEntry.getValue();
			}
			if (currentMatchPercent > signatureMatchPercentTillNow) {
				complexity = candidateSignatureToCcnEntry.getValue();
				signatureMatchPercentTillNow = currentMatchPercent;
			}
		}

		return complexity;
	}

	/**
	 * Strip the type parameters from the signature with parameterized types. Spaces are preserved.
	 * <p/>
	 * E.g. {@code stripTypeParamaters("List<String> a, Map<String, List<? extends Person>> b, int c")} returns {@code "List, Map, int"}.
	 *
	 * @param signature the signature with parameterized types which needs to be stripped
	 * @return the stripped signature
	 */
	private static final String stripTypeParameters(String signature) {
		StringBuilder strippedSignature = new StringBuilder();
		int openIndex = -1;
		int openCount = 0;
		final char open = '<';
		final char close = '>';
		while ((openIndex = signature.indexOf(open)) > -1) {
			strippedSignature.append(signature.substring(0, openIndex));
			for (int i = openIndex + 1; i < signature.length(); i++) {
				if (signature.charAt(i) == close) {
					if (openCount == 0) {
						signature = signature.substring(i + 1);
						break;
					}
					openCount--;
				} else if (signature.charAt(i) == open) {
					openCount++;
				}
			}
		}
		strippedSignature.append(signature);
		return strippedSignature.toString();
	}

	/**
	 * Match the {@code candidate} signature against the {@code golden} signature and return the match confidence.
	 * A signature, for the scope of this method, is just the string of method parameter types; for e.g. signature for method
	 * {@code public void process(List<String> names, int[] scores)} is {@code java.util.List, int[]}.
	 * <p/>
	 * Formula for calculating the return value is:<br/>
	 * Match Confidence = Average of Individual Parameter Type Match Percents<br/>
	 * <p/>
	 * Important: As indicated by the formula, do not compare the confidences across different golden signatures since the confidence values are not
	 * absolute.
	 *
	 * @param candidate signature which is to be matched; can have types which are not fully qualified
	 * @param golden    signature against which {@code candidate} is matched; should have only fully qualified types
	 * @return a value denoting the confidence that the given {@code candidate} signature matches the {@code golden} signature; between 0 and 1; 0
	 * means guaranteed mismatch and a 1 means that guaranteed match.
	 */
	private static final double matchSignatures(String candidate, String golden) {

		// assumption: golden is assumed to have fully qualified types and candidate may have types which are not fully qualified

		String[] candidateParamTypes = candidate.split(",");
		String[] goldenParamTypes = golden.split(",");

		// mismatch: if count of parameters are not same
		if (goldenParamTypes.length != candidateParamTypes.length) {
			return 0;
		}

		int totalParamTypes = goldenParamTypes.length;

		// complete match: if no parameters
		if (totalParamTypes == 0) {
			return 1;
		}

		double totalMatchPercent = 0;

		for (int i = 0; i < totalParamTypes; i++) {
			String goldenParamType = goldenParamTypes[i].trim();
			String candidateParamType = candidateParamTypes[i].trim();

			// mismatch: if golden parameter type is smaller than candidate parameter type
			if (goldenParamType.length() < candidateParamType.length()) {
				return 0;
			}

			if (goldenParamType.equals(candidateParamType)) {
				// complete match
				totalMatchPercent += 1;
			} else {
				int partialMatchIndex = goldenParamType.lastIndexOf(candidateParamType);
				// package separator '.' cannot be before position 1; e.g. a.type
				if (partialMatchIndex > 1 &&
					goldenParamType.length() == (partialMatchIndex + candidateParamType.length()) &&
					goldenParamType.charAt(partialMatchIndex - 1) == '.') {
					// partial match
					totalMatchPercent += (1 - (double) partialMatchIndex / goldenParamType.length());
				} else {
					// mismatch
					return 0;
				}
			}
		}

		return totalMatchPercent / totalParamTypes;
	}

	/**
	 * Indicate whether complexity should be calculated for methods.
	 *
	 * @param calculateMethodComplexity Whether to calculate method complexity
	 */
	public void setCalculateMethodComplexity(boolean calculateMethodComplexity)
	{
		this.calculateMethodComplexity = calculateMethodComplexity;
	}

	/**
	 * Set the encoding to be used when reading input streams.
	 * 
	 * @param encoding
	 *            The encoding to use
	 */
	public void setEncoding(String encoding) {
		this.encoding = encoding;
	}

	/**
	 * Represents complexity of source file, package or project. Stores the number of
	 * methods inside entity and accumlated complexity for these methods.
	 */
	private static class Complexity {
		private double accumlatedCCN;
		private int methodsNum;

		public Complexity(double accumlatedCCN, int methodsNum) {
			this.accumlatedCCN = accumlatedCCN;
			this.methodsNum = methodsNum;
		}

		public Complexity() {
			this(0, 0);
		}

		public double averageCCN() {
			if (methodsNum == 0) {
				return 0;
			}
			return accumlatedCCN / methodsNum;
		}

		public void add(Complexity second) {
			accumlatedCCN += second.accumlatedCCN;
			methodsNum += second.methodsNum;
		}
	}
}
