/*******************************************************************************
 * Copyright (c) 2008, 2018 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
 *     Cloudsmith Inc - bug fixes
 *     martin.kirst@s1998.tu-chemnitz.de - fixed and improved sort algorithm
 *******************************************************************************/
package org.eclipse.equinox.internal.p2.artifact.repository;

import static java.lang.Math.abs;
import static java.lang.Math.max;
import static java.lang.Math.min;
import static java.lang.Math.sqrt;

import java.io.FileNotFoundException;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.*;
import javax.xml.parsers.*;
import org.eclipse.core.runtime.*;
import org.eclipse.equinox.internal.p2.core.helpers.*;
import org.eclipse.equinox.internal.p2.repository.*;
import org.eclipse.equinox.p2.repository.IRepository;
import org.w3c.dom.*;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;

/**
 * Mirror support class for repositories. This class implements
 * mirror support equivalent to the mirroring of update manager sites. A repository 
 * optionally provides a mirror URL via the {@link IRepository#PROP_MIRRORS_URL} key. 
 * The contents of the file at this URL is expected to be an XML document 
 * containing a list of <mirror> elements. The mirrors are assumed to be already 
 * sorted geographically with closer mirrors first.
 * <br><br>
 * Always use {@link MirrorSelector.MirrorInfoComparator} for comparison.
 *
 */
public class MirrorSelector {
	private static final double LOG2 = Math.log(2);

	/**
	 * Encapsulates information about a single mirror
	 */
	public static class MirrorInfo {
		private static final long PRIMARY_FAILURE_LINGER_TIME = 30000; // Retry again after 30 seconds
		private static final long SECONDARY_FAILURE_LINGER_TIME = 300000; // Wait 5 minutes
		private static final int ACCEPTABLE_FILE_NOT_FOUND_COUNT = 5; // Given an established connection, those are generally quick
		private static final Timer resetFailure = new Timer(true);

		long bytesPerSecond;
		int failureCount;
		int fileNotFoundCount;
		int totalFailureCount;
		final int initialRank;
		String locationString;

		public MirrorInfo(String location, int initialRank) {
			this.initialRank = initialRank;
			this.locationString = location;
			if (!locationString.endsWith("/")) //$NON-NLS-1$
				locationString = locationString + "/"; //$NON-NLS-1$
			failureCount = 0;
			totalFailureCount = 0;
			bytesPerSecond = DownloadStatus.UNKNOWN_RATE;
		}

		@Override
		public synchronized String toString() {
			return "Mirror(" + locationString + ',' + failureCount + ',' + bytesPerSecond + ')'; //$NON-NLS-1$
		}

		public synchronized void decrementFailureCount() {
			if (failureCount > 0)
				failureCount--;
		}

		public synchronized void incrementFailureCount() {
			++failureCount;
			++totalFailureCount;
			if (totalFailureCount < 3) {
				resetFailure.schedule(new TimerTask() {
					@Override
					public void run() {
						decrementFailureCount();
					}
				}, totalFailureCount == 1 ? PRIMARY_FAILURE_LINGER_TIME : SECONDARY_FAILURE_LINGER_TIME);
			}
		}

		public synchronized void setBytesPerSecond(long newValue) {
			// Any non-positive value is treated as an unknown rate
			if (newValue <= 0)
				newValue = DownloadStatus.UNKNOWN_RATE;

			if (newValue > 0)
				// Back in commission
				failureCount = 0;
			bytesPerSecond = newValue;
		}

		public synchronized long getBytesPerSecond() {
			return bytesPerSecond;
		}

		public synchronized void incrementFileNotFoundCount() {
			if (++fileNotFoundCount > ACCEPTABLE_FILE_NOT_FOUND_COUNT) {
				incrementFailureCount();
				fileNotFoundCount = 0;
			}
		}
	}

	/**
	 * The URI of the base repository being mirrored.
	 */
	URI baseURI;

	MirrorInfo[] mirrors;

	private final IRepository<?> repository;

	private final Random random = new Random();

	private final Transport transport;

	/**
	 * Constructs a mirror support class for the given repository. Mirrors are
	 * not contacted and the mirrorsURL document is not parsed until a
	 * mirror location request is sent.
	 */
	public MirrorSelector(IRepository<?> repository, Transport transport) {
		this.repository = repository;
		this.transport = transport;
		try {
			String base = repository.getProperties().get(IRepository.PROP_MIRRORS_BASE_URL);
			if (base != null) {
				this.baseURI = new URI(base);
			} else {
				URI repositoryLocation = repository.getLocation();
				if (repositoryLocation != null)
					this.baseURI = repositoryLocation;
			}
		} catch (URISyntaxException e) {
			log("Error initializing mirrors for: " + repository.getLocation(), e); //$NON-NLS-1$
		}
	}

	/**
	 * This {@link Comparator} uses a vector space classification algorithm
	 * and implements some kind of Rocchio Classification.
	 * In theory, when sorting multiple mirrors, we want to know which one
	 * is the best in terms of its attributes 'bytesPerSecons', 'failureCount'
	 * and 'initialRank'.
	 * This {@link Comparator} needs three initial query attributes, which mean
	 * to search for the fastest mirror, with lowest failure and nearest to the
	 * initial rank.
	 * By calculating the vector space distance from query to Object 1 and
	 * query to Object 2, we implicitly know, which mirror is better.
	 * <br><br>
	 * There are two weight factors, which directly influences sorting results.
	 * They are computed by using a one real live mirror list of eclipse.org
	 * and tweak as long as the results look good as a list ;-)
	 * See test case fore more details.
	 * <br><br>
	 * <h4>Mathematical basics used in
	 *  {@link MirrorInfoComparator#compare(MirrorInfo, MirrorInfo)}:</h4>
	 * Given two vectors Q and T, where Q is query and t is Target
	 * and Q_a (or T_a) are attributes of each vector, this is
	 * the formula, which is computed to each MirrorInfo object.
	 * <pre>
	 *                       ->   ->
	 *                       q  * t                             (dot product)
	 *  sim(q,t) = --------------------------- 
	 *               / || -> ||   || -> || \
	 *              |  || q  || * || t  ||  |             (euclidean lengths)
	 *               \                     /
	 *  
	 *                              N
	 *                             ---
	 *                             \
	 *                              >   Q   * T           
	 *                             /     ai    ai
	 *                             ---
	 *                            i = 1
	 *  sim(q,t) = -----------------------------------------
	 *               /     ___________       ___________ \
	 *              |     |   N             |   N         |
	 *              |     |  ---            |  ---        |
	 *              |  _  |  \     2     _  |  \     2    |
	 *              |   \ |   >   Q    *  \ |   >   T     |
	 *              |    \|  /     ai      \|  /     ai   |
	 *              |     |  ---            |  ---        |
	 *               \    | i = 1           | i = 1      / 
	 * </pre>
	 */
	public static final class MirrorInfoComparator implements Comparator<MirrorInfo> {

		/**
		 * This weight is used to treat speed attribute in 25kByte steps.
		 */
		static final double WEIGHT_BYTESPERSECOND = 1d / 25000d;
		/**
		 * This weight influences the failureCount classification
		 * Value was calculated by empirical tests. 
		 */
		static final double WEIGHT_FAILURECOUNT = 1.75d;

		final double qBytesPerSeconds;
		final double qFailureCount;
		final double qRank;
		final double qel; // euclidean length

		public MirrorInfoComparator(long qBytesPerSeconds, int qFailureCount, int qRank) {
			// Query: bytesPerSecondond=max + 10%, failureCountr=0, rank=1
			this.qBytesPerSeconds = (qBytesPerSeconds + qBytesPerSeconds / 10) * WEIGHT_BYTESPERSECOND;
			this.qFailureCount = qFailureCount;
			this.qRank = qRank;
			this.qel = sqrt(qBytesPerSeconds * qBytesPerSeconds + (qFailureCount * 1d) * (qFailureCount * 1d) + qRank * qRank);
		}

		@Override
		public int compare(MirrorInfo o1, MirrorInfo o2) {
			if (o1 == o2) {
				return 0; // shortest way
			}
			// euclidean lengths
			double o1_el = sqrt(abs(o1.bytesPerSecond * WEIGHT_BYTESPERSECOND) * abs(o1.bytesPerSecond * WEIGHT_BYTESPERSECOND) + (o1.failureCount * WEIGHT_FAILURECOUNT) * (o1.failureCount * WEIGHT_FAILURECOUNT) + o1.initialRank * o1.initialRank);
			double o2_el = sqrt(abs(o2.bytesPerSecond * WEIGHT_BYTESPERSECOND) * abs(o2.bytesPerSecond * WEIGHT_BYTESPERSECOND) + (o2.failureCount * WEIGHT_FAILURECOUNT) * (o2.failureCount * WEIGHT_FAILURECOUNT) + o2.initialRank * o2.initialRank);
			// vector dot products
			double dp_1 = (qBytesPerSeconds * abs(o1.bytesPerSecond * WEIGHT_BYTESPERSECOND) + qFailureCount * (o1.failureCount * WEIGHT_FAILURECOUNT) + qRank * o1.initialRank);
			double dp_2 = (qBytesPerSeconds * abs(o2.bytesPerSecond * WEIGHT_BYTESPERSECOND) + qFailureCount * (o2.failureCount * WEIGHT_FAILURECOUNT) + qRank * o2.initialRank);
			// similarities from o1 to Q and o2 to Q (where q=query)
			double sim1 = dp_1 / (qel * o1_el);
			double sim2 = dp_2 / (qel * o2_el);
			return Double.valueOf(sim2).compareTo(Double.valueOf(sim1));
		}

	}

	/**
	 * Parses the given mirror URL to obtain the list of mirrors. Returns the mirrors,
	 * or null if mirrors could not be computed.
	 * 
	 * Originally copied from DefaultSiteParser.getMirrors in org.eclipse.update.core
	 */
	private MirrorInfo[] computeMirrors(String mirrorsURL, IProgressMonitor monitor) {
		try {
			mirrorsURL = enrichWithClientLocation(mirrorsURL);
			Document document = getMirrorsDocument(mirrorsURL, monitor);
			if (document == null)
				return null;
			return buildMirrorInfos(document);
		} catch (OperationCanceledException e) {
			return null;
		} catch (Exception e) {
			// log if absolute url
			if (mirrorsURL != null && (mirrorsURL.startsWith("http://") //$NON-NLS-1$
					|| mirrorsURL.startsWith("https://") //$NON-NLS-1$
					|| mirrorsURL.startsWith("file://") //$NON-NLS-1$
					|| mirrorsURL.startsWith("ftp://") //$NON-NLS-1$
					|| mirrorsURL.startsWith("jar://"))) { //$NON-NLS-1$
				if (e instanceof AuthenticationFailedException) {
					LogHelper.log(new Status(IStatus.WARNING, Activator.ID,
							String.format("Authentication for mirrors URL %s failed", mirrorsURL))); //$NON-NLS-1$
				} else if (e instanceof FileNotFoundException) {
					LogHelper.log(new Status(IStatus.WARNING, Activator.ID,
							String.format("Mirrors URL %s was not found", mirrorsURL))); //$NON-NLS-1$
				} else {
					log("Error processing mirrors URL: " + mirrorsURL, e); //$NON-NLS-1$
				}
			}
			return null;
		}
	}

	private MirrorInfo[] buildMirrorInfos(Document document) {
		NodeList mirrorNodes = document.getElementsByTagName("mirror"); //$NON-NLS-1$
		int mirrorCount = mirrorNodes.getLength();
		MirrorInfo[] infos = new MirrorInfo[mirrorCount + 1];
		for (int i = 0; i < mirrorCount; i++) {
			Element mirrorNode = (Element) mirrorNodes.item(i);
			String infoURL = mirrorNode.getAttribute("url"); //$NON-NLS-1$
			infos[i] = new MirrorInfo(infoURL, i);
		}
		//p2: add the base site as the last resort mirror so we can track download speed and failure rate
		infos[mirrorCount] = new MirrorInfo(baseURI.toString(), mirrorCount);
		return infos;
	}

	private Document getMirrorsDocument(String mirrorsURL, IProgressMonitor monitor) throws ParserConfigurationException, FileNotFoundException, CoreException, AuthenticationFailedException, URISyntaxException, SAXException, IOException {
		DocumentBuilderFactory domFactory = SecureXMLUtil.newSecureDocumentBuilderFactory();
		DocumentBuilder builder = domFactory.newDocumentBuilder();
		// Use Transport to read the mirrors list (to benefit from proxy support, authentication, etc)
		InputSource input = new InputSource(mirrorsURL);
		input.setByteStream(transport.stream(URIUtil.fromString(mirrorsURL), monitor));
		return builder.parse(input);
	}

	private String enrichWithClientLocation(String baseURL) {
		String countryCode = Activator.getContext().getProperty("eclipse.p2.countryCode"); //$NON-NLS-1$
		if (countryCode == null || countryCode.trim().length() == 0)
			countryCode = Locale.getDefault().getCountry().toLowerCase();
		String timeZone = Activator.getContext().getProperty("eclipse.p2.timeZone"); //$NON-NLS-1$
		if (timeZone == null || timeZone.trim().length() == 0)
			timeZone = Integer.toString(new GregorianCalendar().get(Calendar.ZONE_OFFSET) / (60 * 60 * 1000));

		if (baseURL.indexOf('?') != -1) {
			baseURL = baseURL + '&';
		} else {
			baseURL = baseURL + '?';
		}
		return baseURL + "countryCode=" + countryCode + "&timeZone=" + timeZone + "&format=xml"; //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$
	}

	/**
	 * Returns an equivalent location for the given artifact location in the base 
	 * repository.  Always falls back to the given input location in case of failure
	 * to compute mirrors. Never returns null.
	 */
	public synchronized URI getMirrorLocation(URI inputLocation, IProgressMonitor monitor) {
		Assert.isNotNull(inputLocation);
		if (baseURI == null)
			return inputLocation;
		URI relativeLocation = baseURI.relativize(inputLocation);
		//if we failed to relativize the location, we can't select a mirror
		if (relativeLocation == null || relativeLocation.isAbsolute())
			return inputLocation;
		MirrorInfo selectedMirror = selectMirror(monitor);
		if (selectedMirror == null)
			return inputLocation;
		if (Tracing.DEBUG_MIRRORS)
			Tracing.debug("Selected mirror for artifact " + inputLocation + ": " + selectedMirror); //$NON-NLS-1$ //$NON-NLS-2$
		try {
			return new URI(selectedMirror.locationString + relativeLocation.getPath());
		} catch (URISyntaxException e) {
			log("Unable to make location " + inputLocation + " relative to mirror " + selectedMirror.locationString, e); //$NON-NLS-1$ //$NON-NLS-2$
		}
		return inputLocation;
	}

	/**
	 * Initialize {@link #mirrors} for {@link #repository}, or <code>null</code> if
	 * they could not be computed.
	 */
	private void initMirrors(IProgressMonitor monitor) {
		if (mirrors != null)
			return;
		String mirrorsURL = repository.getProperties().get(IRepository.PROP_MIRRORS_URL);
		if (mirrorsURL != null)
			mirrors = computeMirrors(mirrorsURL, monitor);
	}

	private MirrorInfoComparator getComparator() {
		long maxBytesPerSecond = 0;
		if (mirrors != null) {
			for (MirrorInfo mi : mirrors) {
				maxBytesPerSecond = max(maxBytesPerSecond, mi.bytesPerSecond);
			}
		}
		// Use the fastest mirror, with 0 failures and initial rank 1 as base query
		return new MirrorInfoComparator(maxBytesPerSecond, 0, 1);
	}

	private void log(String message, Throwable exception) {
		LogHelper.log(new Status(IStatus.ERROR, Activator.ID, message, exception));
	}

	/**
	 * Reports the result of a mirror download
	 */
	public synchronized void reportResult(String toDownload, IStatus result) {
		if (mirrors == null)
			return;
		for (MirrorInfo mirror : mirrors) {
			if (toDownload.startsWith(mirror.locationString)) {
				if (!result.isOK() && result.getSeverity() != IStatus.CANCEL) {
					// Punishing a mirror harshly for a FileNotFoundException can be very wrong.
					// Some artifacts are not found on any mirror. When that's the case,
					// the best mirrors will be the first to receive that kind of punishment.
					//
					if (result.getException() instanceof FileNotFoundException)
						mirror.incrementFileNotFoundCount();
					else
						mirror.incrementFailureCount();
				}
				if (result instanceof DownloadStatus) {
					long oldRate = mirror.bytesPerSecond;
					long newRate = ((DownloadStatus) result).getTransferRate();
					//average old and new rate so one slow download doesn't ruin the mirror's reputation
					if (newRate > 0) {
						if (oldRate > 0)
							newRate = (oldRate + newRate) / 2;
						mirror.setBytesPerSecond(newRate);
					}
				}
				if (Tracing.DEBUG_MIRRORS)
					Tracing.debug("Updated mirror " + mirror); //$NON-NLS-1$
				return;
			}
		}
	}

	/** 
	 * Return whether or not all the mirrors for this selector have proven to be invalid
	 * @return whether or not there is a valid mirror in this selector.
	 */
	public synchronized boolean hasValidMirror() {
		// return true if there is a mirror and it doesn't have multiple failures.
		if (mirrors == null || mirrors.length == 0)
			return false;
		Arrays.sort(mirrors, getComparator());
		return mirrors[0].failureCount < 2;
	}

	/**
	 * Selects a mirror from the given list of mirrors. Returns null if a mirror
	 * could not be found.
	 */
	private MirrorInfo selectMirror(IProgressMonitor monitor) {
		initMirrors(monitor);
		final int mirrorCount;
		if (mirrors == null || (mirrorCount = mirrors.length) == 0)
			return null;

		MirrorInfo selected;
		if (mirrorCount == 1)
			selected = mirrors[0];
		else {
			Arrays.sort(mirrors, getComparator());
			for (;;) {
				//this is a function that randomly selects a mirror based on a logarithmic
				//distribution. Mirror 0 has a 1/2 chance of being selected, mirror 1 has a 1/4 chance, 
				// mirror 2 has a 1/8 chance, etc. This introduces some variation in the mirror 
				//selection, while still heavily favoring better mirrors
				//the algorithm computes the most significant digit in a binary number by computing the base 2 logarithm
				//if the first digit is most significant, mirror 0 is selected, if the second is most significant, mirror 1 is selected, etc
				int highestMirror = min(15, mirrorCount);
				int result = (int) (Math.log(random.nextInt(1 << highestMirror) + 1) / LOG2);
				if (result >= highestMirror || result < 0)
					result = highestMirror - 1;

				int mirrorIndex = highestMirror - 1 - result;
				selected = mirrors[mirrorIndex];

				// Only choose a mirror from the best 50% of the top 15 of all mirrors
				if (mirrorIndex <= (mirrorCount * 0.5d))
					// This is good enough
					break;
			}
		}

		//for now, don't tolerate mirrors with multiple failures
		if (selected.failureCount > 1)
			return null;
		return selected;
	}

}
