package freenet.node.updater;

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

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

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.l10n.NodeL10n;
import freenet.node.NodeClientCore;
import freenet.node.RequestClient;
import freenet.node.RequestStarter;
import freenet.support.Logger;
import freenet.support.Logger.LogLevel;
import freenet.support.MediaType;
import freenet.support.api.Bucket;
import freenet.support.api.RandomAccessBucket;
import freenet.support.api.RandomAccessBuffer;
import freenet.support.io.ArrayBucket;
import freenet.support.io.BucketTools;
import freenet.support.io.ByteArrayRandomAccessBuffer;
import freenet.support.io.FileBucket;
import freenet.support.io.FileUtil;
import freenet.support.io.FileRandomAccessBuffer;

/**
 * Fetches the revocation key. Each time it starts, it will try to fetch it until it has 3 DNFs. If it ever finds it, it will
 * be immediately fed to the NodeUpdateManager.
 */
public class RevocationChecker implements ClientGetCallback, RequestClient {

	public final static int REVOCATION_DNF_MIN = 3;
	
	private boolean logMINOR;

	private NodeUpdateManager manager;
	private NodeClientCore core;
	private int revocationDNFCounter;
	private FetchContext ctxRevocation;
	private ClientGetter revocationGetter;
	private boolean wasAggressive;
	/** Last time at which we got 3 DNFs on the revocation key */
	private long lastSucceeded;
	// Kept separately from NodeUpdateManager.hasBeenBlown because there are local problems that can blow the key.
	private volatile boolean blown;
	
	private File blobFile;
	/** The original binary blob bucket. */
	private ArrayBucket blobBucket;

	public RevocationChecker(NodeUpdateManager manager, File blobFile) {
		this.manager = manager;
		core = manager.getNode().getClientCore();
		this.revocationDNFCounter = 0;
		this.blobFile = blobFile;
		this.logMINOR = Logger.shouldLog(LogLevel.MINOR, this);
		ctxRevocation = core.makeClient((short)0, true, false).getFetchContext();
		// Do not allow redirects etc.
		// If we allow redirects then it will take too long to download the revocation.
		// Anyone inserting it should be aware of this fact! 
		// You must insert with no content type, and be less than the size limit, and less than the block size after compression!
		// If it doesn't fit, we'll still tell the user, but the message may not be easily readable.
		ctxRevocation.allowSplitfiles = false;
		ctxRevocation.maxArchiveLevels = 0;
		ctxRevocation.followRedirects = false;
		// big enough ?
		ctxRevocation.maxOutputLength = NodeUpdateManager.MAX_REVOCATION_KEY_LENGTH;
		ctxRevocation.maxTempLength = NodeUpdateManager.MAX_REVOCATION_KEY_TEMP_LENGTH;
		ctxRevocation.maxSplitfileBlockRetries = -1; // if we find content, try forever to get it; not used because of the above size limits.
		ctxRevocation.maxNonSplitfileRetries = 0; // but return quickly normally
	}
	
	public int getRevocationDNFCounter() {
		return revocationDNFCounter;
	}

	public void start(boolean aggressive) {
		start(aggressive, true);
		if(blobFile.exists()) {
			ArrayBucket bucket = new ArrayBucket();
			try {
				BucketTools.copy(new FileBucket(blobFile, true, false, false, true), bucket);
				// Allow to free if bogus.
				manager.getUpdateOverMandatory().processRevocationBlob(bucket, "disk", true);
			} catch (IOException e) {
				Logger.error(this, "Failed to read old revocation blob: "+e, e);
				System.err.println("We may have downloaded an old revocation blob before restarting but it cannot be read: "+e);
				e.printStackTrace();
			}
		}
	}
	
	/** Start a fetch.
	 * @param aggressive If set to true, then we have just fetched an update, and therefore can increase the priority of the
	 * fetch to maximum.
	 * @return True if the checker was already running and the counter was not reset.
	 * */
	public boolean start(boolean aggressive, boolean reset) {
		
		if(manager.isBlown()) {
			Logger.error(this, "Not starting revocation checker: key already blown!");
			return false;
		}
		boolean wasRunning = false;
		logMINOR = Logger.shouldLog(LogLevel.MINOR, this);
		ClientGetter cg = null;
		try {
			ClientGetter toCancel = null;
			synchronized(this) {
				if(aggressive && !wasAggressive) {
					// Ignore old one.
					toCancel = revocationGetter;
					if(logMINOR) Logger.minor(this, "Ignoring old request, because was low priority");
					revocationGetter = null;
					if(toCancel != null) wasRunning = true;
				}
				wasAggressive = aggressive;
				if(revocationGetter != null && 
						!(revocationGetter.isCancelled() || revocationGetter.isFinished()))  {
					if(logMINOR) Logger.minor(this, "Not queueing another revocation fetcher yet, old one still running");
					reset = false;
					wasRunning = false;
				} else {
					if(reset) {
						if(logMINOR) Logger.minor(this, "Resetting DNF count from "+revocationDNFCounter, new Exception("debug"));
						revocationDNFCounter = 0;
					} else {
						if(logMINOR) Logger.minor(this, "Revocation count "+revocationDNFCounter);
					}
					if(logMINOR) Logger.minor(this, "fetcher="+revocationGetter);
					if(revocationGetter != null && logMINOR) Logger.minor(this, "revocation fetcher: cancelled="+revocationGetter.isCancelled()+", finished="+revocationGetter.isFinished());
					// Client startup may not have completed yet.
					manager.getNode().getClientCore().getPersistentTempDir().mkdirs();
					cg = revocationGetter = new ClientGetter(this, 
							manager.getRevocationURI(), ctxRevocation, 
							aggressive ? RequestStarter.MAXIMUM_PRIORITY_CLASS : RequestStarter.IMMEDIATE_SPLITFILE_PRIORITY_CLASS, 
							null, new BinaryBlobWriter(new ArrayBucket()), null);
					if(logMINOR) Logger.minor(this, "Queued another revocation fetcher (count="+revocationDNFCounter+")");
				}
			}
			if(toCancel != null)
				toCancel.cancel(core.getClientContext());
			if(cg != null) {
				core.getClientContext().start(cg);
				if(logMINOR) Logger.minor(this, "Started revocation fetcher");
			}
			return wasRunning;
		} catch (FetchException e) {
			if(e.mode == FetchExceptionMode.RECENTLY_FAILED) {
				Logger.error(this, "Cannot start revocation fetcher because recently failed");
			} else {
				Logger.error(this, "Cannot start fetch for the auto-update revocation key: "+e, e);
				manager.blow("Cannot start fetch for the auto-update revocation key: "+e, true);
			}
			synchronized(this) {
				if(revocationGetter == cg) {
					revocationGetter = null;
				}
			}
			return false;
		} catch (PersistenceDisabledException e) {
			// Impossible
			return false;
		}
	}

	long lastSucceeded() {
		return lastSucceeded;
	}
	
	long lastSucceededDelta() {
		if(lastSucceeded <= 0) return -1;
		return System.currentTimeMillis() - lastSucceeded;
	}
	
	/** Called when the revocation URI changes. */
	public void onChangeRevocationURI() {
		kill();
		start(wasAggressive);
	}

	@Override
	public void onSuccess(FetchResult result, ClientGetter state) {
		onSuccess(result, state, state.getBlobBucket());
	}
	
	void onSuccess(FetchResult result, ClientGetter state, Bucket blob) {
		// The key has been blown !
		// FIXME: maybe we need a bigger warning message.
		blown = true;
		moveBlob(blob);
		String msg = null;
		try {
			byte[] buf = result.asByteArray();
			msg = new String(buf, MediaType.getCharsetRobustOrUTF(result.getMimeType()));
		} catch (Throwable t) {
			try {
				msg = "Failed to extract result when key blown: "+t;
				Logger.error(this, msg, t);
				System.err.println(msg);
				t.printStackTrace();
			} catch (Throwable t1) {
				msg = "Internal error after retreiving revocation key";
			}
		}
		manager.blow(msg, false); // Real one, even if we can't extract the message.
	}
	
	public boolean hasBlown() {
		return blown;
	}

	private void moveBlob(Bucket tmpBlob) {
		if(tmpBlob == null) {
			Logger.error(this, "No temporary binary blob file moving it: may not be able to propagate revocation, bug???");
			return;
		}
		if(tmpBlob instanceof ArrayBucket) {
			synchronized(this) {
				if(tmpBlob == blobBucket) return;
				blobBucket = (ArrayBucket) tmpBlob;
			}
		} else {
			try {
				ArrayBucket buf = new ArrayBucket(BucketTools.toByteArray(tmpBlob));
				synchronized(this) {
					blobBucket = buf;
				}
			} catch (IOException e) {
				System.err.println("Unable to copy data from revocation bucket!");
				System.err.println("This should not happen and indicates there may be a problem with the auto-update checker.");
				// Don't blow(), as that's already happened.
				return;
			}
			if(tmpBlob instanceof FileBucket) {
				File f = ((FileBucket)tmpBlob).getFile();
				synchronized(this) {
					if(f == blobFile) return;
					if(f.equals(blobFile)) return;
					if(FileUtil.getCanonicalFile(f).equals(FileUtil.getCanonicalFile(blobFile))) return;
				}
			}
			System.out.println("Unexpected blob file in revocation checker: "+tmpBlob);
		}
		FileBucket fb = new FileBucket(blobFile, false, false, false, false);
		try {
			BucketTools.copy(tmpBlob, fb);
		} catch (IOException e) {
			System.err.println("Got revocation but cannot write it to disk: "+e);
			System.err.println("This means the auto-update system is blown but we can't tell other nodes about it!");
			e.printStackTrace();
		}
	}

	@Override
	public void onFailure(FetchException e, ClientGetter state) {
		onFailure(e, state, state.getBlobBucket());
	}
	
	void onFailure(FetchException e, ClientGetter state, Bucket blob) {
		logMINOR = Logger.shouldLog(LogLevel.MINOR, this);
		if(logMINOR) Logger.minor(this, "Revocation fetch failed: "+e);
		FetchExceptionMode errorCode = e.getMode();
		boolean completed = false;
		long now = System.currentTimeMillis();
		if(errorCode == FetchExceptionMode.CANCELLED) {
			return; // cancelled by us above, or killed; either way irrelevant and doesn't need to be restarted
		}
		if(e.isFatal()) {
			if(!e.isDefinitelyFatal()) {
				// INTERNAL_ERROR could be related to the key but isn't necessarily.
				// FIXME somebody should look at these two strings and de-uglify them!
				// They should never be seen but they should be idiot-proof if they ever are.
				// FIXME split into two parts? Fetch manually should be a second part?
				String message = l10n("revocationFetchFailedMaybeInternalError", new String[] { "detail", "key" }, new String[] { e.toUserFriendlyString(), manager.getRevocationURI().toASCIIString() });
				System.err.println(message);
				e.printStackTrace();
				manager.blow(message, true);
				return;
			}
			// Really fatal, i.e. something was inserted but can't be decoded.
			// FIXME somebody should look at these two strings and de-uglify them!
			// They should never be seen but they should be idiot-proof if they ever are.
			String message = l10n("revocationFetchFailedFatally", new String[] { "detail", "key" }, new String[] { e.toUserFriendlyString(), manager.getRevocationURI().toASCIIString() });			
			manager.blow(message, false);
			moveBlob(blob);
			return;
		}
		if(e.newURI != null) {
			manager.blow("Revocation URI redirecting to "+e.newURI+" - maybe you set the revocation URI to the update URI?", false);
		}
		synchronized(this) {
			if(errorCode == FetchExceptionMode.DATA_NOT_FOUND){
				revocationDNFCounter++;
				if(logMINOR) Logger.minor(this, "Incremented DNF counter to "+revocationDNFCounter);
			}
			if(revocationDNFCounter >= 3) {
				lastSucceeded = now;
				completed = true;
				revocationDNFCounter = 0;
			}
			revocationGetter = null;
		}
		if(completed)
			manager.noRevocationFound();
		else {
			if(errorCode == FetchExceptionMode.RECENTLY_FAILED) {
				// Try again in 1 second.
				// This ensures we don't constantly start them, fail them, and start them again.
				this.manager.getNode().getTicker().queueTimedJob(new Runnable() {

					@Override
					public void run() {
						start(wasAggressive, false);
					}

				}, SECONDS.toMillis(1));
			} else {
				start(wasAggressive, false);
			}
		}
	}
	
	private String l10n(String key, String[] pattern, String[] value) {
		return NodeL10n.getBase().getString("RevocationChecker." + key,
				pattern, value);
	}

	public void kill() {
		if(revocationGetter != null)
			revocationGetter.cancel(core.getClientContext());
	}

	public long getBlobSize() {
		return blobFile.length();
	}

	public RandomAccessBucket getBlobBucket() {
		if(!manager.isBlown()) return null;
		synchronized(this) {
			if(blobBucket != null)
				return blobBucket;
		}
		File f = getBlobFile();
		if(f == null) return null;
		return new FileBucket(f, true, false, false, false);
	}
	
	public RandomAccessBuffer getBlobBuffer() {
		if(!manager.isBlown()) return null;
		synchronized(this) {
			if(blobBucket != null) {
			    try {
			        ByteArrayRandomAccessBuffer t = new ByteArrayRandomAccessBuffer(blobBucket.toByteArray());
			        t.setReadOnly();
			        return t;
			    } catch (IOException e) {
			        Logger.error(this, "Impossible: "+e, e);
			        return null;
			    }
			}
		}
		File f = getBlobFile();
		if(f == null) return null;
		try {
			return new FileRandomAccessBuffer(f, true);
		} catch(FileNotFoundException e) {
			Logger.error(this, "We do not have the blob file for the revocation even though we have successfully downloaded it!", e);
			return null;
		} catch (IOException e) {
            Logger.error(this, "Error reading downloaded revocation blob file: "+e, e);
            return null;
		}
	}
	
	/** Get the binary blob, if we have fetched it. */
	private File getBlobFile() {
		if(blobFile.exists()) return blobFile;
		return null;
	}

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

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

    @Override
    public void onResume(ClientContext context) {
        // Do nothing. Not persistent.
    }

    @Override
    public RequestClient getRequestClient() {
        return this;
    }

}
