package freenet.node.updater;

import static java.util.concurrent.TimeUnit.HOURS;
import static java.util.concurrent.TimeUnit.SECONDS;

import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.DataInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.MalformedURLException;
import java.nio.charset.StandardCharsets;
import java.util.Properties;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;

import freenet.client.FetchContext;
import freenet.client.FetchException;
import freenet.client.FetchException.FetchExceptionMode;
import freenet.client.FetchResult;
import freenet.client.async.BinaryBlobWriter;
import freenet.client.async.ClientContext;
import freenet.client.async.ClientGetCallback;
import freenet.client.async.ClientGetter;
import freenet.client.async.PersistenceDisabledException;
import freenet.client.async.USKCallback;
import freenet.keys.FreenetURI;
import freenet.keys.USK;
import freenet.node.Node;
import freenet.node.NodeClientCore;
import freenet.node.RequestClient;
import freenet.node.RequestStarter;
import freenet.node.Version;
import freenet.support.Logger;
import freenet.support.Logger.LogLevel;
import freenet.support.Ticker;
import freenet.support.api.Bucket;
import freenet.support.api.RandomAccessBucket;
import freenet.support.io.Closer;
import freenet.support.io.FileBucket;
import freenet.support.io.FileUtil;
import freenet.support.io.NullOutputStream;

public abstract class NodeUpdater implements ClientGetCallback, USKCallback, RequestClient {

	static private boolean logMINOR;
	private FetchContext ctx;
	private ClientGetter cg;
	private FreenetURI URI;
	private final Ticker ticker;
	public final NodeClientCore core;
	protected final Node node;
	public final NodeUpdateManager manager;
	private final int currentVersion;
	private int realAvailableVersion;
	private int availableVersion;
	private int fetchingVersion;
	protected int fetchedVersion;
	private int maxDeployVersion;
	private int minDeployVersion;
	private boolean isRunning;
	private boolean isFetching;
	private final String blobFilenamePrefix;
	protected File tempBlobFile;
	
	public abstract String jarName();

	NodeUpdater(NodeUpdateManager manager, FreenetURI URI, int current, int min, int max, String blobFilenamePrefix) {
		logMINOR = Logger.shouldLog(LogLevel.MINOR, this);
		this.manager = manager;
		this.node = manager.getNode();
		this.URI = URI.setSuggestedEdition(Version.buildNumber() + 1);
		this.ticker = node.getTicker();
		this.core = node.getClientCore();
		this.currentVersion = current;
		this.availableVersion = -1;
		this.isRunning = true;
		this.cg = null;
		this.isFetching = false;
		this.blobFilenamePrefix = blobFilenamePrefix;
		this.maxDeployVersion = max;
		this.minDeployVersion = min;

		FetchContext tempContext = core.makeClient((short) 0, true, false).getFetchContext();
		tempContext.allowSplitfiles = true;
		tempContext.dontEnterImplicitArchives = false;
		this.ctx = tempContext;

	}

	void start() {
		subscribe(() -> manager.blow("The auto-update URI isn't valid and can't be used", true));
	}

	private void subscribe(Runnable onError) {
		try {
			// because of UoM, this version is actually worth having as well
			USK myUsk = USK.create(URI.setSuggestedEdition(currentVersion));
			core.getUskManager().subscribe(myUsk, this, true, getRequestClient());
		} catch (MalformedURLException e) {
			Logger.error(this, "The auto-update URI isn't valid and can't be used");
			onError.run();
		}
	}
	
	protected void maybeProcessOldBlob() {
		File oldBlob = getBlobFile(currentVersion);
		if(oldBlob.exists()) {
			File temp;
			try {
				temp = File.createTempFile(blobFilenamePrefix + availableVersion + "-", ".fblob.tmp", manager.getNode().getClientCore().getPersistentTempDir());
			} catch (IOException e) {
				Logger.error(this, "Unable to process old blob: "+e, e);
				return;
			}
			if(oldBlob.renameTo(temp)) {
				FreenetURI uri = URI.setSuggestedEdition(currentVersion);
				uri = uri.sskForUSK();
				try {
					manager.getUpdateOverMandatory().processMainJarBlob(temp, null, currentVersion, uri);
				} catch (Throwable t) {
					// Don't disrupt startup.
					Logger.error(this, "Unable to process old blob, caught "+t, t);
				}
				temp.delete();
			} else {
				Logger.error(this, "Unable to rename old blob file "+oldBlob+" to "+temp+" so can't process it.");
			}
		}

	}

	public RequestClient getRequestClient() {
		return this;
	}
	
	@Override
	public void onFoundEdition(long l, USK key, ClientContext context, boolean wasMetadata, short codec, byte[] data, boolean newKnownGood, boolean newSlotToo) {
		if(newKnownGood && !newSlotToo) return;
		logMINOR = Logger.shouldLog(LogLevel.MINOR, this);
		if(logMINOR)
			Logger.minor(this, "Found edition " + l);
		int found;
		synchronized(this) {
			if(!isRunning)
				return;
			found = (int) key.suggestedEdition;

			realAvailableVersion = found;
			if(found > maxDeployVersion) {
				System.err.println("Ignoring "+jarName() + " update edition "+l+": version too new (min "+minDeployVersion+" max "+maxDeployVersion+")");
				found = maxDeployVersion;
			}
			
			if(found <= availableVersion)
				return;
			System.err.println("Found " + jarName() + " update edition " + found);
			Logger.minor(this, "Updating availableVersion from " + availableVersion + " to " + found + " and queueing an update");
			this.availableVersion = found;
		}
		finishOnFoundEdition(found);
	}

	private void finishOnFoundEdition(int found) {
		ticker.queueTimedJob(new Runnable() {
			@Override
			public void run() {
				maybeUpdate();
			}
		}, SECONDS.toMillis(60)); // leave some time in case we get later editions
		// LOCKING: Always take the NodeUpdater lock *BEFORE* the NodeUpdateManager lock
		if(found <= currentVersion) {
			System.err.println("Cancelling fetch for "+found+": not newer than current version "+currentVersion);
			return;
		}
		onStartFetching();
		Logger.minor(this, "Fetching " + jarName() + " update edition " + found);
	}

	protected abstract void onStartFetching();

	public void maybeUpdate() {
		ClientGetter toStart = null;
		if(!manager.isEnabled())
			return;
		if(manager.isBlown())
			return;
		ClientGetter cancelled = null;
		synchronized(this) {
			if(logMINOR)
				Logger.minor(this, "maybeUpdate: isFetching=" + isFetching + ", isRunning=" + isRunning + ", availableVersion=" + availableVersion);
			if(!isRunning) 
				return;
			if(isFetching && availableVersion == fetchingVersion) 
				return;
			if(availableVersion <= fetchedVersion)
				return;
			if(fetchingVersion < minDeployVersion || fetchingVersion == currentVersion) {
				Logger.normal(this, "Cancelling previous fetch");
				cancelled = cg;
				cg = null;
			}
			fetchingVersion = availableVersion;

			if(availableVersion > currentVersion) {
				Logger.normal(this, "Starting the update process (" + availableVersion + ')');
				System.err.println("Starting the update process: found the update (" + availableVersion + "), now fetching it.");
			}
			if(logMINOR)
				Logger.minor(this, "Starting the update process (" + availableVersion + ')');
			// We fetch it
			try {
				if((cg == null) || cg.isCancelled()) {
					if(logMINOR)
						Logger.minor(this, "Scheduling request for " + URI.setSuggestedEdition(availableVersion));
					if(availableVersion > currentVersion)
						System.err.println("Starting " + jarName() + " fetch for " + availableVersion);
					tempBlobFile =
						File.createTempFile(blobFilenamePrefix + availableVersion + "-", ".fblob.tmp", manager.getNode().getClientCore().getPersistentTempDir());
					FreenetURI uri = URI.setSuggestedEdition(availableVersion);
					uri = uri.sskForUSK();
					cg = new ClientGetter(this,  
						uri, ctx, RequestStarter.IMMEDIATE_SPLITFILE_PRIORITY_CLASS,
						null, new BinaryBlobWriter(new FileBucket(tempBlobFile, false, false, false, false)), null);
					toStart = cg;
				} else {
					System.err.println("Already fetching "+jarName() + " fetch for " + fetchingVersion + " want "+availableVersion);
				}
				isFetching = true;
			} catch(Exception e) {
				Logger.error(this, "Error while starting the fetching: " + e, e);
				isFetching = false;
			}
		}
		if(toStart != null)
			try {
				node.getClientCore().getClientContext().start(toStart);
			} catch(FetchException e) {
				Logger.error(this, "Error while starting the fetching: " + e, e);
				synchronized(this) {
					isFetching = false;
				}
			} catch (PersistenceDisabledException e) {
				// Impossible
			}
		if(cancelled != null)
			cancelled.cancel(core.getClientContext());
	}

	final File getBlobFile(int availableVersion) {
		return new File(node.getClientCore().getPersistentTempDir(), blobFilenamePrefix + availableVersion + ".fblob");
	}
	
	RandomAccessBucket getBlobBucket(int availableVersion) {
		File f = getBlobFile(availableVersion);
		if(f == null) return null;
		return new FileBucket(f, true, false, false, false);
	}
	
	@Override
	public void onSuccess(FetchResult result, ClientGetter state) {
		onSuccess(result, state, tempBlobFile, fetchingVersion);
	}

	void onSuccess(FetchResult result, ClientGetter state, File tempBlobFile, int fetchedVersion) {
		logMINOR = Logger.shouldLog(LogLevel.MINOR, this);
		File blobFile = null;
		synchronized(this) {
			if(fetchedVersion <= this.fetchedVersion) {
				tempBlobFile.delete();
				if(result != null) {
					Bucket toFree = result.asBucket();
					if(toFree != null)
						toFree.free();
				}
				return;
			}
			if(result == null || result.asBucket() == null || result.asBucket().size() == 0) {
				tempBlobFile.delete();
				Logger.error(this, "Cannot update: result either null or empty for " + availableVersion);
				System.err.println("Cannot update: result either null or empty for " + availableVersion);
				// Try again
				if(result == null || result.asBucket() == null || availableVersion > fetchedVersion)
					node.getTicker().queueTimedJob(new Runnable() {

						@Override
						public void run() {
							maybeUpdate();
						}
					}, 0);
				return;
			}
			blobFile = getBlobFile(fetchedVersion);
			if(!tempBlobFile.renameTo(blobFile)) {
				blobFile.delete();
				if(!tempBlobFile.renameTo(blobFile))
					if(blobFile.exists() && tempBlobFile.exists() &&
						blobFile.length() == tempBlobFile.length())
						Logger.minor(this, "Can't rename " + tempBlobFile + " over " + blobFile + " for " + fetchedVersion + " - probably not a big deal though as the files are the same size");
					else {
						Logger.error(this, "Not able to rename binary blob for node updater: " + tempBlobFile + " -> " + blobFile + " - may not be able to tell other peers about this build");
						blobFile = null;
					}
			}
			this.fetchedVersion = fetchedVersion;
			System.out.println("Found " + jarName() + " version " + fetchedVersion);
			if(fetchedVersion > currentVersion)
				Logger.normal(this, "Found version " + fetchedVersion + ", setting up a new UpdatedVersionAvailableUserAlert");
			maybeParseManifest(result, fetchedVersion);
			this.cg = null;
		}
		processSuccess(fetchedVersion, result, blobFile);
	}
	
	/** We have fetched the jar! Do something after onSuccess(). Called unlocked. */
	protected abstract void processSuccess(int fetched, FetchResult result, File blobFile);

	/** Called with locks held 
	 * @param result */
	protected abstract void maybeParseManifest(FetchResult result, int build);

	protected void parseManifest(FetchResult result) {
		InputStream is = null;
		try {
			is = result.asBucket().getInputStream();
			ZipInputStream zis = new ZipInputStream(is);
			try {
				ZipEntry ze;
				while(true) {
					ze = zis.getNextEntry();
					if(ze == null) break;
					if(ze.isDirectory()) continue;
					String name = ze.getName();
					
					if(name.equals("META-INF/MANIFEST.MF")) {
						if(logMINOR) Logger.minor(this, "Found manifest");
						long size = ze.getSize();
						if(logMINOR) Logger.minor(this, "Manifest size: "+size);
						if(size > MAX_MANIFEST_SIZE) {
							Logger.error(this, "Manifest is too big: "+size+" bytes, limit is "+MAX_MANIFEST_SIZE);
							break;
						}
						byte[] buf = new byte[(int) size];
						DataInputStream dis = new DataInputStream(zis);
						dis.readFully(buf);
						ByteArrayInputStream bais = new ByteArrayInputStream(buf);
						InputStreamReader isr = new InputStreamReader(bais, StandardCharsets.UTF_8);
						BufferedReader br = new BufferedReader(isr);
						String line;
						while((line = br.readLine()) != null) {
							parseManifestLine(line);
						}
					} else {
						zis.closeEntry();
					}
				}
			} finally {
				Closer.close(zis);
			}
		} catch (IOException e) {
			Logger.error(this, "IOException trying to read manifest on update");
		} catch (Throwable t) {
			Logger.error(this, "Failed to parse update manifest: "+t, t);
		} finally {
			Closer.close(is);
		}
	}
	
	static final String DEPENDENCIES_FILE = "dependencies.properties";
	
	/** Read the jar file. Parse the Properties. Read every file in the ZIP; if it is corrupted,
	 * we will get a CRC error and therefore an IOException, and so the update won't be deployed.
	 * This is not entirely foolproof because ZipInputStream doesn't check the CRC for stored 
	 * files, only for deflated files, and it's only a CRC32 anyway. But it should reduce the
	 * chances of accidental corruption breaking an update.
	 * @param is The InputStream for the jar file.
	 * @param filename The filename of the manifest file containing the properties (normally 
	 * META-INF/MANIFEST.MF). 
	 * @throws IOException If there is a temporary files error or the jar is corrupted. */
	static Properties parseProperties(InputStream is, String filename) throws IOException {
		Properties props = new Properties();
		ZipInputStream zis = new ZipInputStream(is);
		try {
			ZipEntry ze;
			while(true) {
				ze = zis.getNextEntry();
				if(ze == null) break;
				if(ze.isDirectory()) continue;
				String name = ze.getName();
				
				if(name.equals(filename)) {
					if(logMINOR) Logger.minor(NodeUpdater.class, "Found manifest");
					long size = ze.getSize();
					if(logMINOR) Logger.minor(NodeUpdater.class, "Manifest size: "+size);
					if(size > MAX_MANIFEST_SIZE) {
						Logger.error(NodeUpdater.class, "Manifest is too big: "+size+" bytes, limit is "+MAX_MANIFEST_SIZE);
						break;
					}
					byte[] buf = new byte[(int) size];
					DataInputStream dis = new DataInputStream(zis);
					dis.readFully(buf);
					ByteArrayInputStream bais = new ByteArrayInputStream(buf);
					props.load(bais);
				} else {
				    // Read the file. Throw if there is a CRC error.
				    // Note that java.util.zip.ZipInputStream only checks the CRC for compressed 
				    // files, so this is not entirely foolproof.
				    long size = ze.getSize();
				    FileUtil.copy(zis, new NullOutputStream(), size);
					zis.closeEntry();
				}
			}
		} finally {
			Closer.close(zis);
		}
		return props;
	}

	protected void parseDependencies(FetchResult result, int build) {
		InputStream is = null;
		try {
			is = result.asBucket().getInputStream();
			parseDependencies(parseProperties(is, DEPENDENCIES_FILE), build);
		} catch (IOException e) {
			Logger.error(this, "IOException trying to read manifest on update");
		} catch (Throwable t) {
			Logger.error(this, "Failed to parse update manifest: "+t, t);
		} finally {
			Closer.close(is);
		}
	}

	/** Override if you want to deal with the file dependencies.properties */
	protected void parseDependencies(Properties props, int build) {
		// Do nothing
	}

	protected void parseManifestLine(String line) {
		// Do nothing by default, only some NodeUpdater's will use this, those that don't won't call parseManifest().
	}
	
	private static final int MAX_MANIFEST_SIZE = 1024*1024;

	@Override
	public void onFailure(FetchException e, ClientGetter state) {
		logMINOR = Logger.shouldLog(LogLevel.MINOR, this);
		if(!isRunning)
			return;
		FetchExceptionMode errorCode = e.getMode();
		tempBlobFile.delete();

		if(logMINOR)
			Logger.minor(this, "onFailure(" + e + ',' + state + ')');
		synchronized(this) {
			this.cg = null;
			isFetching = false;
		}
		if(errorCode == FetchExceptionMode.CANCELLED ||
			!e.isFatal()) {
			Logger.normal(this, "Rescheduling new request");
			ticker.queueTimedJob(new Runnable() {

				@Override
				public void run() {
					maybeUpdate();
				}
			}, 0);
		} else {
			Logger.error(this, "Canceling fetch : " + e.getMessage());
			System.err.println("Unexpected error fetching update: " + e.getMessage());
			if(e.isFatal()) {
				// Wait for the next version
			} else
				ticker.queueTimedJob(new Runnable() {

					@Override
					public void run() {
						maybeUpdate();
					}
				}, HOURS.toMillis(1));
		}
	}

	/** Called before kill(). Don't do anything that will involve taking locks. */
	public void preKill() {
		isRunning = false;
	}

	void kill() {
		try {
			ClientGetter c;
			synchronized(this) {
				isRunning = false;
				USK myUsk = USK.create(URI.setSuggestedEdition(currentVersion));
				core.getUskManager().unsubscribe(myUsk, this);
				c = cg;
				cg = null;
			}
			c.cancel(core.getClientContext());
		} catch(Exception e) {
			Logger.minor(this, "Cannot kill NodeUpdater", e);
		}
	}

	public FreenetURI getUpdateKey() {
		return URI;
	}

	public synchronized boolean canUpdateNow() {
		return fetchedVersion > currentVersion;
	}

	/** Called when the fetch URI has changed. No major locks are held by caller. 
	 * @param uri The new URI. */
	public void onChangeURI(FreenetURI uri) {
		kill(); // unsubscribes from the old uri
		this.URI = uri;
		subscribe(() -> {});
		maybeUpdate();
	}

	public int getFetchedVersion() {
		return fetchedVersion;
	}

	public boolean isFetching() {
		return availableVersion > fetchedVersion && availableVersion > currentVersion;
	}

	public int fetchingVersion() {
		// We will not deploy currentVersion...
		if(fetchingVersion <= currentVersion)
			return availableVersion;
		else
			return fetchingVersion;
	}

	public long getBlobSize() {
		return getBlobFile(getFetchedVersion()).length();
	}

	public File getBlobFile() {
		return getBlobFile(getFetchedVersion());
	}

	@Override
	public short getPollingPriorityNormal() {
		return RequestStarter.IMMEDIATE_SPLITFILE_PRIORITY_CLASS;
	}

	@Override
	public short getPollingPriorityProgress() {
		return RequestStarter.INTERACTIVE_PRIORITY_CLASS;
	}

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

	/**
	** Called by NodeUpdateManager to re-set the min/max versions for ext when
	** a new freenet.jar has been downloaded. This is to try to avoid the node
	** installing incompatible versions of main and ext.
	*/
	public void setMinMax(int requiredExt, int recommendedExt) {
		int callFinishedFound = -1;
		synchronized(this) {
			if(recommendedExt > -1) {
				maxDeployVersion = recommendedExt;
			}
			if(requiredExt > -1) {
				minDeployVersion = requiredExt;
				if(realAvailableVersion != availableVersion && availableVersion < requiredExt && realAvailableVersion >= requiredExt) {
					// We found a revision but didn't fetch it because it wasn't within the range for the old jar.
					// The new one requires it, however.
					System.err.println("Previously out-of-range edition "+realAvailableVersion+" is now needed by the new jar; scheduling fetch.");
					callFinishedFound = availableVersion = realAvailableVersion;
				} else if(availableVersion < requiredExt) {
					// Including if it hasn't been found at all
					// Just try it ...
					callFinishedFound = availableVersion = requiredExt;
					System.err.println("Need minimum edition "+requiredExt+" for new jar, found "+availableVersion+"; scheduling fetch.");
				}
			}
		}
		if(callFinishedFound > -1)
			finishOnFoundEdition(callFinishedFound);
	}
	
	@Override
	public boolean realTimeFlag() {
		return false;
	}
	
    @Override
    public void onResume(ClientContext context) {
        // Do nothing. Not persistent.
    }
}
