/*******************************************************************************
 * Copyright (c) 2000, 2015 IBM Corporation and others.
 *
 * This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License 2.0
 * which accompanies this distribution, and is available at
 * https://www.eclipse.org/legal/epl-2.0/
 *
 * SPDX-License-Identifier: EPL-2.0
 *
 * Contributors:
 *     IBM Corporation - initial API and implementation
 *******************************************************************************/

package org.eclipse.jdt.text.tests.performance;

import java.io.IOException;
import java.lang.reflect.Constructor;
import java.net.InetAddress;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import com.sun.jdi.ClassType;
import com.sun.jdi.Method;
import com.sun.jdi.ObjectReference;
import com.sun.jdi.VMDisconnectedException;
import com.sun.jdi.VirtualMachine;
import com.sun.jdi.VirtualMachineManager;
import com.sun.jdi.connect.AttachingConnector;
import com.sun.jdi.connect.Connector;
import com.sun.jdi.connect.IllegalConnectorArgumentsException;
import com.sun.jdi.event.BreakpointEvent;
import com.sun.jdi.event.Event;
import com.sun.jdi.event.EventIterator;
import com.sun.jdi.event.EventQueue;
import com.sun.jdi.event.EventSet;
import com.sun.jdi.event.VMDeathEvent;
import com.sun.jdi.request.BreakpointRequest;
import com.sun.jdi.request.EventRequest;

import org.junit.Assert;

import org.eclipse.jdi.Bootstrap;
import org.eclipse.test.internal.performance.InternalDimensions;
import org.eclipse.test.internal.performance.InternalPerformanceMeter;
import org.eclipse.test.internal.performance.PerformanceTestPlugin;
import org.eclipse.test.internal.performance.data.DataPoint;
import org.eclipse.test.internal.performance.data.Dim;
import org.eclipse.test.internal.performance.data.Sample;
import org.eclipse.test.internal.performance.data.Scalar;

/**
 * To use this performance meter add the following VM arguments:
 * <code>-Xdebug -Xnoagent -Xrunjdwp:transport=dt_socket,address=7777,suspend=n,server=y -Declipse.perf.debugPort=7777</code>.
 * Try a different port if 7777 does not work.
 * Because the performance meter uses the VM's debugging facility, it cannot be
 * debugged itself. A {@link org.eclipse.test.performance.Performance#getNullPerformanceMeter()}
 * could be used while debugging clients.
 *
 * @since 3.1
 */
public class InvocationCountPerformanceMeter extends InternalPerformanceMeter {

	/**
	 * An event reader that continuously reads and handles events coming
	 * from the VM.
	 */
	public class EventReader implements Runnable {

		/** Event queue */
		private EventQueue fEventQueue;

		/** Background thread */
		private Thread fThread;

		/** <code>true</code> if the reader should stop */
		private boolean fIsStopping= false;

		/**
		 * Creates a new event reader that will read from the given
		 * event queue.
		 *
		 * @param queue the event queue
		 */
		public EventReader(EventQueue queue) {
			fEventQueue= queue;
		}

		/**
		 * Start the thread that reads events.
		 */
		public void start() {
			fThread= new Thread(this);
			fThread.setDaemon(true);
			synchronized (fThread) {
				try {
					fThread.start();
					fThread.wait();
				} catch (InterruptedException x) {
					x.printStackTrace();
				}
			}
		}

		/**
		 * Tells the reader loop that it should stop.
		 */
		public void stop() {
			fIsStopping= true;
			if (fThread != null) {
				try {
					fThread.interrupt();
					fThread.join();
					fThread= null;
				} catch (InterruptedException x) {
					x.printStackTrace();
				}
			}
		}

		/**
		 * Continuously reads and handles events that are coming from
		 * the event queue.
		 */
		@Override
		public void run() {
			try {
				synchronized (fThread) {
					enableBreakpoints();
					fThread.notifyAll();
				}
				while (!fIsStopping) {
					try {
						EventSet eventSet;
						if (fTimeout == -1)
							eventSet= fEventQueue.remove();
						else {
							eventSet= fEventQueue.remove(fTimeout);
							if (eventSet == null) {
								fIsStopping= true;
								System.out.println("Event reader timed out");
								return;
							}
						}

						EventIterator iterator= eventSet.eventIterator();
						while (iterator.hasNext()) {
							Event event= iterator.nextEvent();
							if (event instanceof BreakpointEvent)
								handleBreakpointEvent((BreakpointEvent) event);
							if (event instanceof VMDeathEvent) {
								fIsStopping= true;
								System.out.println("VM unexpectedly died"); //$NON-NLS-1$
							}
						}

						eventSet.resume();
					} catch (InterruptedException x) {
						if (fIsStopping)
							return;
						System.out.println("Event reader loop unexpectedly interrupted"); //$NON-NLS-1$
						x.printStackTrace();
						return;
					} catch (VMDisconnectedException x) {
						System.out.println("VM unexpectedly disconnected"); //$NON-NLS-1$
						x.printStackTrace();
						return;
					}
				}
			} finally {
				disableBreakpoints();
				fVM.resume();
			}
		}

		/**
		 * Enables all breakpoint request.
		 */
		private void enableBreakpoints() {
			for (BreakpointRequest breakpointRequest : fBreakpointRequests) {
				breakpointRequest.enable();
			}
		}

		/**
		 * Disables all breakpoint request.
		 */
		private void disableBreakpoints() {
			for (BreakpointRequest breakpointRequest : fBreakpointRequests) {
				breakpointRequest.disable();
			}
		}

		/**
		 * Handles the given breakpoint event.
		 *
		 * @param event the breakpoint event
		 */
		private void handleBreakpointEvent(BreakpointEvent event) {
			fInvocationCount++;
			if (fVerbose)
				try {
					ObjectReference thisObject= event.thread().frame(0).thisObject();
					Method method= event.location().method();
					String classKey= method.declaringType().name() + "#" + method.name() + method.signature(); //$NON-NLS-1$
					String instanceKey= fCollectInstanceResults ? (thisObject.referenceType().name() + " (id=" + thisObject.uniqueID() + ")") : "all instances"; //$NON-NLS-1$ //$NON-NLS-2$
					fResults.update(classKey, instanceKey);
				} catch (Exception x) {
					x.printStackTrace();
				}
		}
	}

	/**
	 * Invocation count results.
	 */
	public static class Results {

		/** The result map */
		private Map<Object, Map<Object, Integer>> fResultsMap= new HashMap<>();

		/**
		 * Updates the results for the given pair of keys.
		 *
		 * @param key1 the first key
		 * @param key2 the second key
		 */
		public void update(Object key1, Object key2) {
			int value;
			Map<Object, Integer> results;
			if (fResultsMap.containsKey(key1)) {
				results= fResultsMap.get(key1);
				if (results.containsKey(key2))
					value= results.get(key2);
				else
					value= 0;
			} else {
				results= new HashMap<>();
				fResultsMap.put(key1, results);
				value= 0;
			}
			results.put(key2, value + 1);
		}

		/**
		 * Clears the results.
		 */
		public void clear() {
			fResultsMap.clear();
		}

		/**
		 * Prints the results.
		 */
		public void print() {
			for (Object object : fResultsMap.keySet())
				print(object);
		}

		/**
		 * Prints the results for the given first key.
		 *
		 * @param key1 the first key
		 */
		public void print(Object key1) {
			System.out.println(key1.toString() + ":"); //$NON-NLS-1$
			Map<Object, Integer> results= fResultsMap.get(key1);
			for (Map.Entry<Object, Integer> entry : results.entrySet()) {
				Object key = entry.getKey();
				System.out.println("\t" + key + ": " + entry.getValue().intValue()); //$NON-NLS-1$ //$NON-NLS-2$
			}
		}
	}

	/** System property that specifies the debugging port */
	public static final String DEBUG_PORT_PROPERTY= "eclipse.perf.debugPort"; //$NON-NLS-1$

	/** Default debugging port when the system property cannot be interpreted as an integer */
	public static final int DEBUG_PORT_DEFAULT= 7777;

	/**
	 * Debugging port
	 * <p>
	 * TODO: Fetch the debug port with
	 * <code>Platform.getCommandLineArgs()</code> or
	 * <code>org.eclipse.core.runtime.adaptor.EnvironmentInfo.getDefault().getCommandLineArgs()</code>
	 * (the latter may be necessary because it also includes low-level
	 * arguments).
	 * </p>
	 */
	private static final int PORT= intValueOf(System.getProperty(DEBUG_PORT_PROPERTY), DEBUG_PORT_DEFAULT);

	/** Empty array of methods */
	private static final java.lang.reflect.Method[] NO_METHODS= new java.lang.reflect.Method[0];

	/** Empty array of constructors */
	private static final Constructor<?>[] NO_CONSTRUCTORS= new Constructor[0];

	/** Results */
	private Results fResults= new Results();

	/** Virtual machine */
	private VirtualMachine fVM;

	/** Event reader */
	private EventReader fEventReader;

	/** Methods from which to count the invocations */
	private java.lang.reflect.Method[] fMethods;

	/** Constructors from which to count the invocations */
	private Constructor<?>[] fConstructors;

	/** Timestamp */
	private long fStartTime;

	/** Total number of invocations */
	private long fInvocationCount;

	/** All breakpoint requests */
	private BreakpointRequest[] fBreakpointRequests;

	/** <code>true</code> iff additional information should be collected and printed */
	private boolean fVerbose;

	/** <code>true</code> iff additional information should be collected per instance */
	private boolean fCollectInstanceResults= true;

	/** Timeout after which the event reader aborts when no event occurred, <code>-1</code> for infinite */
	private long fTimeout= -1;

	/**
	 * Initialize the performance meter to count the number of invocation of
	 * the given methods and constructors.
	 *
	 * @param scenarioId the scenario id
	 * @param methods the methods
	 * @param constructors the constructors
	 */
	public InvocationCountPerformanceMeter(String scenarioId, java.lang.reflect.Method[] methods, Constructor<?>[] constructors) {
		super(scenarioId);
		Assert.assertNotNull("Could not create performance meter: check the command line arguments (see InvocationCountPerformanceMeter for details)", System.getProperty(DEBUG_PORT_PROPERTY));

		fMethods= methods;
		fConstructors= constructors;
		fStartTime= System.currentTimeMillis();
		fVerbose= PerformanceTestPlugin.getDBLocation() == null || System.getProperty(VERBOSE_PERFORMANCE_METER_PROPERTY) != null;
	}

	/**
	 * Initialize the performance meter to count the number of invocation of
	 * the given methods.
	 *
	 * @param scenarioId the scenario id
	 * @param methods the methods
	 */
	public InvocationCountPerformanceMeter(String scenarioId, java.lang.reflect.Method[] methods) {
		this(scenarioId, methods, NO_CONSTRUCTORS);
	}

	/**
	 * Initialize the performance meter to count the number of invocation of
	 * the given constructors.
	 *
	 * @param scenarioId the scenario id
	 * @param constructors the constructors
	 */
	public InvocationCountPerformanceMeter(String scenarioId, Constructor<?>[] constructors) {
		this(scenarioId, NO_METHODS, constructors);
		fCollectInstanceResults= false;
	}

	/*
	 * @see org.eclipse.test.performance.PerformanceMeter#start()
	 */
	@Override
	public void start() {
		try {
			String localhost = InetAddress.getLocalHost().getCanonicalHostName();
			attach(localhost, PORT);

			List<BreakpointRequest> requests= new ArrayList<>();
			for (java.lang.reflect.Method method : fMethods) {
				requests.add(createBreakpointRequest(method));
			}
			for (Constructor<?> constructor : fConstructors) {
				requests.add(createBreakpointRequest(constructor));
			}
			fBreakpointRequests= requests.toArray(new BreakpointRequest[requests.size()]);

			fEventReader= new EventReader(fVM.eventQueue());
			fEventReader.start();
		} catch (IOException | IllegalConnectorArgumentsException x) {
			x.printStackTrace();
		} finally {
			Assert.assertNotNull("Could not start performance meter, hints:\n1) check the command line arguments (see InvocationCountPerformanceMeter for details)\n2) use a different port number", fEventReader);
		}
	}

	/*
	 * @see org.eclipse.test.performance.PerformanceMeter#stop()
	 */
	@Override
	public void stop() {
		if (fEventReader != null) {
			fEventReader.stop();
			fEventReader= null;
		}

		if (fVM != null) {
			deleteBreakpointRequests();
			detach();
		}
	}

	/*
	 * @see org.eclipse.test.performance.PerformanceMeter#commit()
	 */
	@Override
	public void commit() {
		super.commit();
		if (fVerbose) {
			System.out.println("Detailed results:"); //$NON-NLS-1$
			fResults.print();
			System.out.println();
			System.out.println("--------------------------------------------------"); //$NON-NLS-1$
			System.out.println();
		}
	}

	/*
	 * @see org.eclipse.test.performance.PerformanceMeter#dispose()
	 */
	@Override
	public void dispose() {
		super.dispose();
		if (fVM != null || fEventReader != null)
			stop();
		fResults= null;
		fMethods= null;
		fConstructors= null;
	}

	/*
	 * @see org.eclipse.test.internal.performance.InternalPerformanceMeter#getSample()
	 */
	@Override
	public Sample getSample() {
		Map<Dim, Scalar> map= new HashMap<>(1);
		map.put(InternalDimensions.INVOCATION_COUNT, new Scalar(InternalDimensions.INVOCATION_COUNT, fInvocationCount));
		DataPoint[] dataPoints= new DataPoint[] { new DataPoint(AFTER, map) };
		return new Sample(getScenarioName(), fStartTime, null, dataPoints);
	}

	private void attach(String host, int port) throws IOException, IllegalConnectorArgumentsException {
		VirtualMachineManager manager= Bootstrap.virtualMachineManager();
		List<AttachingConnector> connectors= manager.attachingConnectors();
		AttachingConnector connector= connectors.get(0);
		Map<String, Connector.Argument> args= connector.defaultArguments();

		args.get("port").setValue(String.valueOf(port)); //$NON-NLS-1$
		args.get("hostname").setValue(host); //$NON-NLS-1$
		fVM= connector.attach(args);
	}

	/**
	 * Detaches from the VM.
	 */
	private void detach() {
		fVM.dispose();
		fVM= null;
	}

	/**
	 * Creates a breakpoint request on entry of the given method.
	 *
	 * @param method the method
	 * @return the breakpoint request
	 */
	private BreakpointRequest createBreakpointRequest(java.lang.reflect.Method method) {
		return createBreakpointRequest(getMethod(method.getDeclaringClass().getName(), method.getName(), getJNISignature(method)));
	}

	/**
	 * Creates a breakpoint request on entry of the given constructor.
	 *
	 * @param constructor the method
	 * @return the breakpoint request
	 */
	private BreakpointRequest createBreakpointRequest(Constructor<?> constructor) {
		return createBreakpointRequest(getMethod(constructor.getDeclaringClass().getName(), "<init>", getJNISignature(constructor)));
	}

	/**
	 * Creates a breakpoint request on entry of the given method.
	 *
	 * @param method the method
	 * @return the breakpoint request
	 */
	private BreakpointRequest createBreakpointRequest(Method method) {
		BreakpointRequest request= fVM.eventRequestManager().createBreakpointRequest(method.location());
		request.setSuspendPolicy(EventRequest.SUSPEND_EVENT_THREAD);
		return request;
	}

	/**
	 * Returns the JDI method for the given class, name and signature.
	 *
	 * @param className fully-qualified class name
	 * @param name method name
	 * @param signature JNI-style signature
	 * @return the JDI method
	 */
	private Method getMethod(String className, String name, String signature) {
		ClassType type= (ClassType) fVM.classesByName(className).get(0);
		return type.concreteMethodByName(name, signature);
	}

	/**
	 * Deletes all breakpoint requests.
	 */
	private void deleteBreakpointRequests() {
		try {
			fVM.eventRequestManager().deleteAllBreakpoints();
		} catch (VMDisconnectedException x) {
			/*
			 * No need to let the test fail at this point since
			 * next step is to disconnect from the VM.
			 */
			System.out.println("VM unexpectedly disconnected"); //$NON-NLS-1$
			x.printStackTrace();
		}
	}

	/**
	 * Returns the JNI-style signature of the given method. See
	 * http://download.oracle.com/javase/6/docs
	 * /jdk/api/jpda/jdi/com/sun/jdi/doc-files/signature.html
	 *
	 * @param method the method
	 * @return the JNI style signature
	 */
	private String getJNISignature(java.lang.reflect.Method method) {
		return getJNISignature(method.getParameterTypes()) + getJNISignature(method.getReturnType());
	}

	/**
	 * Returns the JNI-style signature of the given constructor. See
	 * http://download.oracle.com/javase
	 * /6/docs/jdk/api/jpda/jdi/com/sun/jdi/doc-files/signature.html
	 *
	 * @param constructor the constructor
	 * @return the JNI style signature
	 */
	private String getJNISignature(Constructor<?> constructor) {
		return getJNISignature(constructor.getParameterTypes()) + "V";
	}

	/**
	 * Returns the JNI-style signature of the given parameter types. See
	 * http://download.oracle.com/javase
	 * /6/docs/jdk/api/jpda/jdi/com/sun/jdi/doc-files/signature.html
	 *
	 * @param paramTypes the parameter types
	 * @return the JNI style signature
	 */
	private String getJNISignature(Class<?>[] paramTypes) {
		StringBuilder signature= new StringBuilder();
		signature.append('(');
		for (Class<?> paramType : paramTypes)
			signature.append(getJNISignature(paramType));
		signature.append(')');
		return signature.toString();
	}

	/**
	 * Returns the JNI-style signature of the given class. See
	 * http://download.oracle.com/javase/6/docs
	 * /jdk/api/jpda/jdi/com/sun/jdi/doc-files/signature.html
	 *
	 * @param clazz the class
	 * @return the JNI style signature
	 */
	private String getJNISignature(Class<?> clazz) {
		String qualifiedName= getName(clazz);
		StringBuilder signature= new StringBuilder();

		int index= qualifiedName.indexOf('[') + 1;
		while (index > 0) {
			index= qualifiedName.indexOf('[', index) + 1;
			signature.append('[');
		}

		int nameEndOffset= qualifiedName.indexOf('[');
		if (nameEndOffset < 0)
			nameEndOffset= qualifiedName.length();

		// Check for primitive types
		String name= qualifiedName.substring(0, nameEndOffset);
		switch (name) {
		case "byte": //$NON-NLS-1$
			signature.append('B');
			return signature.toString();
		case "boolean": //$NON-NLS-1$
			signature.append('Z');
			return signature.toString();
		case "int": //$NON-NLS-1$
			signature.append('I');
			return signature.toString();
		case "double": //$NON-NLS-1$
			signature.append('D');
			return signature.toString();
		case "short": //$NON-NLS-1$
			signature.append('S');
			return signature.toString();
		case "char": //$NON-NLS-1$
			signature.append('C');
			return signature.toString();
		case "long": //$NON-NLS-1$
			signature.append('J');
			return signature.toString();
		case "float": //$NON-NLS-1$
			signature.append('F');
			return signature.toString();
		case "void": //$NON-NLS-1$
			signature.append('V');
			return signature.toString();
		default:
			break;
		}

		// Class type
		signature.append('L');
		signature.append(name.replace('.','/'));
		signature.append(';');
		return signature.toString();
	}

	/**
	 * Returns the given class' name
	 *
	 * @param clazz the class
	 * @return the name
	 */
	private String getName(Class<?> clazz) {
		if (clazz.isArray())
			return getName(clazz.getComponentType()) + "[]"; //$NON-NLS-1$
		return clazz.getName();
	}

	/**
	 * Returns the integer value of the given string unless the string
	 * cannot be interpreted as such, in this case the given default is
	 * returned.
	 *
	 * @param stringValue the string to be interpreted as integer
	 * @param defaultValue the default integer value
	 * @return the integer value
	 */
	private static int intValueOf(String stringValue, int defaultValue) {
		try {
			if (stringValue != null)
				return Integer.valueOf(stringValue);
		} catch (NumberFormatException e) {
			// use default
		}
		return defaultValue;
	}

	/**
	 * Returns the timeout after which the event reader aborts when no event
	 * occurred, <code>-1</code> for infinite.
	 * <p>
	 * For debugging purposes.
	 * </p>
	 * <p>
	 * For debugging purposes.
	 * </p>
	 *
	 * @return the timeout after which the event reader aborts when no event
	 *         occurred, <code>-1</code> for infinite
	 */
	public long getTimeout() {
		return fTimeout;
	}

	/**
	 * Sets the timeout after which the event reader aborts when no event
	 * occurred, <code>-1</code> for infinite.
	 * <p>
	 * For debugging purposes.
	 * </p>
	 *
	 * @param timeout the timeout after which the event reader aborts when
	 *                no event occurred, <code>-1</code> for infinite
	 */
	public void setTimeout(long timeout) {
		fTimeout= timeout;
	}
}
