/*******************************************************************************
 * Copyright (c) 2015, 2016 Google, Inc and others.
 * 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:
 *   Stefan Xenos (Google) - Initial implementation
 *******************************************************************************/
package org.eclipse.jdt.internal.core.nd.java;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;

import org.eclipse.core.filesystem.EFS;
import org.eclipse.core.filesystem.IFileInfo;
import org.eclipse.core.filesystem.IFileStore;
import org.eclipse.core.runtime.CoreException;
import org.eclipse.core.runtime.IPath;
import org.eclipse.core.runtime.IProgressMonitor;
import org.eclipse.core.runtime.SubMonitor;
import org.eclipse.jdt.internal.core.nd.StreamHasher;

public class FileFingerprint {
	/**
	 * Sentinel value for {@link #time} indicating a nonexistent fingerprint. This is used for the timestamp of
	 * nonexistent files and for the {@link #getEmpty()} singleton.
	 */
	public static final long NEVER_MODIFIED = 0;

	/**
	 * Sentinel value for {@link #time} indicating that the timestamp was not recorded as part of the fingerprint.
	 * This is normally used to indicate that the file's timestamp was so close to the current system time at the time
	 * the fingerprint was computed that subsequent changes in the file might not be detected. In such cases, timestamps
	 * are an unreliable method for determining if the file has changed and so are not included as part of the fingerprint.
	 */
	public static final long UNKNOWN = 1;

	/**
	 * Worst-case accuracy of filesystem timestamps, among all supported platforms (this is currently 1s on linux, 2s on
	 * FAT systems).
	 */
	private static final long WORST_FILESYSTEM_TIMESTAMP_ACCURACY_MS = 2000;

	private long time;
	private long hash;
	private long size;

	private static final FileFingerprint EMPTY = new FileFingerprint(NEVER_MODIFIED,0,0);

	public static final FileFingerprint getEmpty() {
		return EMPTY;
	}

	public static final FileFingerprint create(IPath path, IProgressMonitor monitor) throws CoreException {
		return getEmpty().test(path, monitor).getNewFingerprint();
	}

	public FileFingerprint(long time, long size, long hash) {
		super();
		this.time = time;
		this.size = size;
		this.hash = hash;
	}

	public long getTime() {
		return this.time;
	}

	public long getHash() {
		return this.hash;
	}

	public long getSize() {
		return this.size;
	}

	@Override
	public int hashCode() {
		final int prime = 31;
		int result = 1;
		result = prime * result + (int) (this.hash ^ (this.hash >>> 32));
		result = prime * result + (int) (this.size ^ (this.size >>> 32));
		result = prime * result + (int) (this.time ^ (this.time >>> 32));
		return result;
	}

	@Override
	public boolean equals(Object obj) {
		if (this == obj)
			return true;
		if (obj == null)
			return false;
		if (getClass() != obj.getClass())
			return false;
		FileFingerprint other = (FileFingerprint) obj;
		if (this.hash != other.hash)
			return false;
		if (this.size != other.size)
			return false;
		if (this.time != other.time)
			return false;
		return true;
	}

	public static class FingerprintTestResult {
		private boolean matches;
		private boolean needsNewFingerprint;
		private FileFingerprint newFingerprint;

		public FingerprintTestResult(boolean matches, boolean needsNewFingerprint, FileFingerprint newFingerprint) {
			super();
			this.matches = matches;
			this.newFingerprint = newFingerprint;
			this.needsNewFingerprint = needsNewFingerprint;
		}

		public boolean needsNewFingerprint() {
			return this.needsNewFingerprint;
		}

		public boolean matches() {
			return this.matches;
		}

		public FileFingerprint getNewFingerprint() {
			return this.newFingerprint;
		}

		@Override
		public String toString() {
			return "FingerprintTestResult [matches=" + this.matches + ", needsNewFingerprint="  //$NON-NLS-1$//$NON-NLS-2$
					+ this.needsNewFingerprint + ", newFingerprint=" + this.newFingerprint + "]";  //$NON-NLS-1$//$NON-NLS-2$
		}
	}

	/**
	 * Returns true iff the file existed at the time the fingerprint was computed.
	 * 
	 * @return true iff the file existed at the time the fingerprint was computed.
	 */
	public boolean fileExists() {
		return !equals(EMPTY);
	}

	/**
	 * Compares the given File with the receiver. If the fingerprint matches (ie: the file
	 */
	public FingerprintTestResult test(IPath path, IProgressMonitor monitor) throws CoreException {
		SubMonitor subMonitor = SubMonitor.convert(monitor, 100);
		long currentTime = System.currentTimeMillis();
		IFileStore store = EFS.getLocalFileSystem().getStore(path);
		IFileInfo fileInfo = store.fetchInfo();

		long lastModified = fileInfo.getLastModified();
		if (Math.abs(currentTime - lastModified) < WORST_FILESYSTEM_TIMESTAMP_ACCURACY_MS) {
			// If the file was modified so recently that it's within our ability to measure it, don't include
			// the timestamp as part of the fingerprint. If another change were to happen to the file immediately
			// afterward, we might not be able to detect it using the timestamp.
			lastModified = UNKNOWN;
		}
		subMonitor.split(5);

		long fileSize = fileInfo.getLength();
		subMonitor.split(5);
		if (lastModified != UNKNOWN && lastModified == this.time && fileSize == this.size) {
			return new FingerprintTestResult(true, false, this);
		}

		long hashCode;
		try {
			hashCode = fileSize == 0 ? 0 : computeHashCode(path.toFile(), fileSize, subMonitor.split(90));
		} catch (IOException e) {
			throw new CoreException(Package.createStatus("An error occurred computing a hash code", e)); //$NON-NLS-1$
		}
		boolean matches = (hashCode == this.hash && fileSize == this.size);

		FileFingerprint newFingerprint = new FileFingerprint(lastModified, fileSize, hashCode);
		return new FingerprintTestResult(matches, !equals(newFingerprint), newFingerprint);
	}

	private long computeHashCode(File toTest, long fileSize, IProgressMonitor monitor) throws IOException {
		final int BUFFER_SIZE = 2048;
		char[] charBuffer = new char[BUFFER_SIZE];
		byte[] byteBuffer = new byte[BUFFER_SIZE * 2];

		SubMonitor subMonitor = SubMonitor.convert(monitor, (int) (fileSize / (BUFFER_SIZE * 2)));
		StreamHasher hasher = new StreamHasher();
		try {
			InputStream inputStream = new FileInputStream(toTest);
			try {
				while (true) {
					subMonitor.split(1);
					int bytesRead = readUntilBufferFull(inputStream, byteBuffer);

					if (bytesRead < byteBuffer.length) {
						charBuffer = new char[(bytesRead + 1) / 2];
						copyByteArrayToCharArray(charBuffer, byteBuffer, bytesRead);
						hasher.addChunk(charBuffer);
						break;
					}

					copyByteArrayToCharArray(charBuffer, byteBuffer, bytesRead);
					hasher.addChunk(charBuffer);
				}
			} finally {
				inputStream.close();
			}

		} catch (FileNotFoundException e) {
			return 0;
		}

		return hasher.computeHash();
	}

	private void copyByteArrayToCharArray(char[] charBuffer, byte[] byteBuffer, int bytesToCopy) {
		for (int ch = 0; ch < bytesToCopy / 2; ch++) {
			char next = (char) (byteBuffer[ch * 2] + byteBuffer[ch * 2 + 1]);
			charBuffer[ch] = next;
		}

		if (bytesToCopy % 2 != 0) {
			charBuffer[bytesToCopy / 2] = (char) byteBuffer[bytesToCopy - 1];
		}
	}

	int readUntilBufferFull(InputStream inputStream, byte[] buffer) throws IOException {
		int bytesRead = 0;
		while (bytesRead < buffer.length) {
			int thisRead = inputStream.read(buffer, bytesRead, buffer.length - bytesRead);

			if (thisRead == -1) {
				return bytesRead;
			}

			bytesRead += thisRead;
		}
		return bytesRead;
	}

	private static String getTimeString(long timestamp) {
		if (timestamp == UNKNOWN) {
			return "UNKNOWN"; //$NON-NLS-1$
		} else if (timestamp == NEVER_MODIFIED) {
			return "NEVER_MODIFIED"; //$NON-NLS-1$
		}
		return Long.toString(timestamp);
	}

	@Override
	public String toString() {
		return "FileFingerprint [time=" + getTimeString(this.time) + ", size=" + this.size + ", hash=" + this.hash + "]";    //$NON-NLS-1$//$NON-NLS-2$//$NON-NLS-3$//$NON-NLS-4$
	}
}