/*******************************************************************************
 * Copyright (c) 2011-2020 The University of York, Aston University.
 * 
 * This program and the accompanying materials are made available under the
 * terms of the Eclipse Public License 2.0 which is available at
 * http://www.eclipse.org/legal/epl-2.0.
 *
 * This Source Code may also be made available under the following Secondary
 * Licenses when the conditions for such availability set forth in the Eclipse
 * Public License, v. 2.0 are satisfied: GNU General Public License, version 3.
 *
 * SPDX-License-Identifier: EPL-2.0 OR GPL-3.0
 *
 * Contributors:
 *     Antonio Garcia-Dominguez - initial API and implementation
 *     Jonathan Co - update revision calculation and filename detection
 ******************************************************************************/
package org.eclipse.hawk.http;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.DigestInputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.Base64;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Objects;

import org.apache.commons.io.IOUtils;
import org.apache.http.Header;
import org.apache.http.HttpHeaders;
import org.apache.http.HttpHost;
import org.apache.http.HttpResponse;
import org.apache.http.NameValuePair;
import org.apache.http.auth.AuthScope;
import org.apache.http.auth.UsernamePasswordCredentials;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpHead;
import org.apache.http.client.methods.HttpRequestBase;
import org.apache.http.client.utils.DateUtils;
import org.apache.http.impl.client.BasicCredentialsProvider;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.impl.client.LaxRedirectStrategy;
import org.eclipse.hawk.core.IConsole;
import org.eclipse.hawk.core.ICredentialsStore;
import org.eclipse.hawk.core.ICredentialsStore.Credentials;
import org.eclipse.hawk.core.IModelIndexer;
import org.eclipse.hawk.core.IVcsManager;
import org.eclipse.hawk.core.VcsChangeType;
import org.eclipse.hawk.core.VcsCommit;
import org.eclipse.hawk.core.VcsCommitItem;
import org.eclipse.hawk.core.VcsRepositoryDelta;

public class HTTPManager implements IVcsManager {

	private static final String HEADER_CONTENT_DISPOSITION = "Content-Disposition";
	private static final String FIRST_REV = "0";

	private boolean isActive;
	private IConsole console;
	private URI repositoryURI;

	private String username;
	private String password;
	private String lastETag;

	/**
	 * These two fields are used when the server does not provide ETag
	 * or Last-Modified headers. The class will need to use the digest
	 * (SHA-1) of the response as the version number.
	 *
	 * In order to avoid having to download the content twice, it will
	 * remember this digested version and reuse it in import() if the
	 * revision matches the SHA-1.
	 */
	private String lastDigest;
	private byte[] lastDigestedResponse;

	/**
	 * This remembers the last revision we computed getDelta(...) on, to provide a
	 * simple single-version change delta functionality.
	 */
	private String lastDelta;
	
	/**
	 * Not all locations will have the filename embedded in the URL
	 * 
	 * This field is used to store the extracted filename from the HTTP
	 * request if available, to ensure files are correctly retrieved when
	 * creating deltas
	 */
	private String lastFilename;
	
	private IModelIndexer indexer;
	private boolean isFrozen = false;

	@Override
	public String getCurrentRevision() throws Exception {
		try (final CloseableHttpClient cl = createClient()) {
			/*
			 * Since HTTP servers can be quite varied, we try several methods in sequence:
			 *
			 * - ETag headers are preferred, since these explicitly check for changes.
			 *
			 * - Otherwise, Last-Modified dates are used.
			 *
			 * - Otherwise, compute a SHA1 on the response. This response will be reused the
			 * next time we import, as long as we use that same SHA1 as the revision.
			 *
			 * We try first a HEAD request with ETag/Last-Modified, and if that doesn't work
			 * we use a GET request and use ETag / Last-Modified / SHA1.
			 */

			final HttpHead headRequest = new HttpHead(repositoryURI);
			decorateCurrentRevisionRequest(headRequest);
			try (CloseableHttpResponse response = cl.execute(headRequest)) {
				String headRevision = getRevision(response, false);
				if (headRevision != null) {
					return headRevision;
				}
			}

			final HttpGet getRequest = new HttpGet(repositoryURI);
			decorateCurrentRevisionRequest(getRequest);
			try (CloseableHttpResponse response = cl.execute(getRequest)) {
				String getRev = getRevision(response, true);
				if (getRev != null) {
					return getRev;
				}
			}
		}

		// No way to detect changes - just fetch the file once
		return "1";
	}

	protected void decorateCurrentRevisionRequest(final HttpRequestBase request) {
		if (lastETag != null) {
			request.setHeader(HttpHeaders.ETAG, lastETag);
			request.setHeader(HttpHeaders.IF_NONE_MATCH, "*");
		}
	}

	protected String getRevision(final HttpResponse response, boolean useDigest) {
		if (response.getStatusLine().getStatusCode() == 304) {
			// Not-Modified, as told by the server
			return lastETag;
		} else if (response.getStatusLine().getStatusCode() != 200) {
			// Request failed for some reason (4xx, 5xx: we already
			// handle 3xx redirects)
			return FIRST_REV;
		}
		
		// Extract filename from request
		final Header cdHeader = response.getFirstHeader(HEADER_CONTENT_DISPOSITION);
		if (cdHeader != null) {
			lastFilename = Arrays.stream(cdHeader.getElements())
	                 .map(e -> e.getParameterByName("filename"))
	                 .filter(Objects::nonNull)
	                 .map(NameValuePair::getValue)
	                 .findFirst()
	                 .orElse(null);
		}		

		final Header etagHeader = response.getFirstHeader(HttpHeaders.ETAG);
		if (etagHeader != null) {
			lastETag = etagHeader.getValue();
			return lastETag;
		}

		final Header lmHeader = response.getFirstHeader(HttpHeaders.LAST_MODIFIED);
		if (lmHeader != null) {
			final Date lmDate = DateUtils.parseDate(lmHeader.getValue());
			return lmDate.getTime() + "";
		}

		if (useDigest) {
			try {
				final MessageDigest md = MessageDigest.getInstance("SHA");
				final ByteArrayOutputStream bos = new ByteArrayOutputStream();
				try (InputStream is = response.getEntity().getContent();
					DigestInputStream dis = new DigestInputStream(is, md)) {
					IOUtils.copy(dis, bos);
				}
				lastDigestedResponse = bos.toByteArray();
				lastDigest = Base64.getEncoder().encodeToString(md.digest());
				return lastDigest;
			} catch (NoSuchAlgorithmException ex) {
				console.printerrln(ex);
			} catch (IOException ex) {
				console.printerrln(ex);
			}
		}

		return null;
	}

	protected CloseableHttpClient createClient() {
		final HttpClientBuilder builder = HttpClientBuilder.create();

		// Provide username and password if specified
		if (username != null) {
			final BasicCredentialsProvider credProvider = new BasicCredentialsProvider();
			credProvider.setCredentials(new AuthScope(new HttpHost(repositoryURI.getHost())),
					new UsernamePasswordCredentials(username, password));
			builder.setDefaultCredentialsProvider(credProvider);
		}

		// Follow redirects
		builder.setRedirectStrategy(new LaxRedirectStrategy());

		return builder.build();
	}

	@Override
	public String getFirstRevision() throws Exception {
		return FIRST_REV;
	}
	 
	/**
	 * Note that this differs from {@link IVcsManager#getDelta(String)}.
	 * <p>
	 * {@code endRevision} is used rather than {@code startRevision}
	 */
	@Override
	public List<VcsCommitItem> getDelta(String endRevision) throws Exception {
		return getDelta(FIRST_REV, endRevision).getCompactedCommitItems();
	}

	@Override
	public VcsRepositoryDelta getDelta(String startRevision, String endRevision) throws Exception {
		VcsRepositoryDelta delta;
		if (lastDelta == null || !lastDelta.equals(endRevision)) {
			VcsCommit c = new VcsCommit();
			c.setAuthor("Unknown");
			c.setJavaDate(new Date());
			c.setMessage("HTTP file changed: " + repositoryURI);
			c.setRevision(endRevision);

			VcsCommitItem ci = new VcsCommitItem();
			
			Path path = Paths.get(repositoryURI.getPath());
			if (lastFilename != null && !path.endsWith(lastFilename)) {
				path = Paths.get(path.toString(), lastFilename);
			}
			
			ci.setPath(path.toString());
			ci.setChangeType(VcsChangeType.UPDATED);
			ci.setCommit(c);
			c.getItems().add(ci);

			delta = new VcsRepositoryDelta(Collections.singleton(c));
		} else {
			delta = new VcsRepositoryDelta(Collections.emptyList());
		}

		delta.setManager(this);
		lastDelta = endRevision;
		return delta;
	}

	@Override
	public File importFile(String revision, String path, File temp) {
		if (revision != null && revision.equals(lastDigest)) {
			// We have the stored response with this SHA1 - reuse it
			try (ByteArrayInputStream bis = new ByteArrayInputStream(lastDigestedResponse)) {
				Files.copy(bis, temp.toPath());
			} catch (IOException e) {
				console.printerrln(e);
			}
		} else {
			// We do not have the response already stored in memory - fetch it
			try (CloseableHttpClient cl = createClient()) {
				try (CloseableHttpResponse response = cl.execute(new HttpGet(repositoryURI))) {
					Files.copy(response.getEntity().getContent(), temp.toPath());
					return temp;
				}
			} catch (IOException e) {
				console.printerrln(e);
			}
		}

		return null;
	}

	@Override
	public boolean isActive() {
		return isActive;
	}

	@Override
	public void init(String vcsloc, IModelIndexer indexer) throws URISyntaxException {
		console = indexer.getConsole();
		this.repositoryURI = new URI(vcsloc);
		this.indexer = indexer;
	}

	@Override
	public void run() throws Exception {
		try {
			final ICredentialsStore credStore = indexer.getCredentialsStore();
			if (username != null) {
				// The credentials were provided by a previous setCredentials
				// call: retry the change to the credentials store.
				setCredentials(username, password, credStore);
			} else {
				final Credentials credentials = credStore.get(repositoryURI.toString());
				if (credentials != null) {
					this.username = credentials.getUsername();
					this.password = credentials.getPassword();
				} else {
					/*
					 * If we use null for the default username/password, SVNKit
					 * will try to use the GNOME keyring in Linux, and that will
					 * lock up our Eclipse instance in some cases.
					 */
					console.printerrln("No username/password recorded for the repository " + repositoryURI);
					this.username = "";
					this.password = "";
				}
			}

			isActive = true;
		} catch (Exception e) {
			console.printerrln("exception in svnmanager run():");
			console.printerrln(e);
		}
	}

	@Override
	public void shutdown() {
		repositoryURI = null;
		console = null;
	}

	@Override
	public String getLocation() {
		return repositoryURI.toString();
	}

	@Override
	public String getUsername() {
		return username;
	}

	@Override
	public String getPassword() {
		return password;
	}

	@Override
	public void setCredentials(String username, String password, ICredentialsStore credStore) {
		if (username != null && password != null && repositoryURI != null
				&& (!username.equals(this.username) || !password.equals(this.password))) {
			try {
				credStore.put(repositoryURI.toString(), new Credentials(username, password));
			} catch (Exception e) {
				console.printerrln("Could not save new username/password");
				console.printerrln(e);
			}
		}
		this.username = username;
		this.password = password;
	}

	@Override
	public String getHumanReadableName() {
		return "HTTP Monitor";
	}

	@Override
	public boolean isAuthSupported() {
		return true;
	}

	@Override
	public boolean isPathLocationAccepted() {
		return false;
	}

	@Override
	public boolean isURLLocationAccepted() {
		return true;
	}

	@Override
	public String getRepositoryPath(String rawPath) {
		final String sRepositoryURI = repositoryURI.toString();
		if (rawPath.startsWith(sRepositoryURI)) {
			return rawPath.substring(sRepositoryURI.length());
		}
		return rawPath;
	}

	@Override
	public boolean isFrozen() {
		return isFrozen;
	}

	@Override
	public void setFrozen(boolean f) {
		isFrozen = f;
	}

	@Override
	public String getDefaultLocation() {
		return "http://host:port/path";
	}
}
