/* This code is part of Freenet. It is distributed under the GNU General
 * Public License, version 2 (or at your option any later version). See
 * http://www.gnu.org/ for further details of the GPL. */
package freenet.client.async;

import java.io.IOException;
import java.io.Serializable;
import java.net.MalformedURLException;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;

import freenet.client.InsertContext;
import freenet.client.InsertException;
import freenet.client.InsertException.InsertExceptionMode;
import freenet.client.Metadata;
import freenet.keys.BaseClientKey;
import freenet.keys.FreenetURI;
import freenet.keys.InsertableUSK;
import freenet.keys.USK;
import freenet.support.LogThresholdCallback;
import freenet.support.Logger;
import freenet.support.Logger.LogLevel;
import freenet.support.api.Bucket;
import freenet.support.io.BucketTools;
import freenet.support.io.ResumeFailedException;

/**
 * Insert a USK. The algorithm is simply to do a thorough search for the latest edition, and insert at the
 * following slot. Thereafter, if we get a collision, increment our slot; if we get more than 5 consecutive
 * collisions, search for the latest slot again.
 */
public class USKInserter implements ClientPutState, USKFetcherCallback, PutCompletionCallback, Serializable {

    private static final long serialVersionUID = 1L;
    private static volatile boolean logMINOR;
	
	static {
		Logger.registerLogThresholdCallback(new LogThresholdCallback() {
			
			@Override
			public void shouldUpdate() {
				logMINOR = Logger.shouldLog(LogLevel.MINOR, this);
			}
		});
	}
	
	// Stuff to be passed on to the SingleBlockInserter
	final BaseClientPutter parent;
	Bucket data;
	final short compressionCodec;
	final InsertContext ctx;
	final PutCompletionCallback cb;
	final boolean isMetadata;
	final int sourceLength;
	final int token;
	public final Object tokenObject;
	final boolean persistent;
	final boolean realTimeFlag;
	
	final InsertableUSK privUSK;
	final USK pubUSK;
	/** Scanning for latest slot */
	private USKFetcherTag fetcher;
	/** Insert the actual SSK */
	private SingleBlockInserter sbi;
	private long edition;
	/** Number of collisions while trying to insert so far */
	private int consecutiveCollisions;
	private boolean finished;
	/** After attempting inserts on this many slots, go back to the Fetcher */
	private static final long MAX_TRIED_SLOTS = 10;
	private boolean freeData;
	final int hashCode;
	private final int extraInserts;
	final byte cryptoAlgorithm;
	final byte[] forceCryptoKey;
	
	@Override
	public void schedule(ClientContext context) throws InsertException {
		// Caller calls schedule()
		// schedule() calls scheduleFetcher()
		// scheduleFetcher() creates a Fetcher (set up to tell us about author-errors as well as valid inserts)
		// (and starts it)
		// when this completes, onFoundEdition() calls scheduleInsert()
		// scheduleInsert() starts a SingleBlockInserter
		// if that succeeds, we complete
		// if that fails, we increment our index and try again (in the callback)
		// if that continues to fail 5 times, we go back to scheduleFetcher()
		scheduleFetcher(context);
	}

	/**
	 * Schedule a Fetcher to find us the latest inserted key of the USK.
	 * The Fetcher must be insert-mode, in other words, it must know that we want the latest edition,
	 * including author errors and so on.
	 */
	private void scheduleFetcher(ClientContext context) {
		synchronized(this) {
			if(logMINOR)
				Logger.minor(this, "scheduling fetcher for "+pubUSK.getURI());
			if(finished) return;
			fetcher = context.uskManager.getFetcherForInsertDontSchedule(persistent ? pubUSK.copy() : pubUSK, parent.priorityClass, this, parent.getClient(), context, persistent, ctx.ignoreUSKDatehints);
			if(logMINOR)
				Logger.minor(this, "scheduled: "+fetcher);
		}
		fetcher.schedule(context);
	}

	@Override
	public void onFoundEdition(long l, USK key, ClientContext context, boolean lastContentWasMetadata, short codec, byte[] hisData, boolean newKnownGood, boolean newSlotToo) {
		boolean alreadyInserted = false;
		synchronized(this) {
			edition = Math.max(l, edition);
			consecutiveCollisions = 0;
			if((lastContentWasMetadata == isMetadata) && hisData != null
					&& (codec == compressionCodec)) {
				try {
					byte[] myData = BucketTools.toByteArray(data);
					if(Arrays.equals(myData, hisData)) {
						// Success
						alreadyInserted = true;
						finished = true;
						sbi = null;
					}
				} catch (IOException e) {
					Logger.error(this, "Could not decode: "+e, e);
				}
			}
			if(persistent) {
				fetcher = null;
			}
		}
		if(alreadyInserted) {
			// Success!
			parent.completedBlock(true, context);
			cb.onEncode(pubUSK.copy(edition), this, context);
			insertSucceeded(context, l);
			if(freeData) {
				data.free();
			}
		} else {
			scheduleInsert(context);
		}
	}

	private void insertSucceeded(ClientContext context, long edition) {
		if(ctx.ignoreUSKDatehints) {
			if(logMINOR) Logger.minor(this, "Inserted to edition "+edition);
			cb.onSuccess(this, context);
			return;
		}
		if(logMINOR) Logger.minor(this, "Inserted to edition "+edition+" - inserting USK date hints...");
		USKDateHint hint = USKDateHint.now();
		MultiPutCompletionCallback m = new MultiPutCompletionCallback(cb, parent, tokenObject, persistent, true);
		byte[] hintData = hint.getData(edition).getBytes(StandardCharsets.UTF_8);
		FreenetURI[] hintURIs = hint.getInsertURIs(privUSK);
		boolean added = false;
		for(FreenetURI uri : hintURIs) {
			try {
				Bucket bucket = BucketTools.makeImmutableBucket(context.getBucketFactory(persistent), hintData);
				SingleBlockInserter sb = 
					new SingleBlockInserter(parent, bucket, (short) -1, uri,
							ctx, realTimeFlag, m, false, sourceLength, token, true, true /* we don't use it */, null, context, persistent, true, extraInserts, cryptoAlgorithm, forceCryptoKey);
				Logger.normal(this, "Inserting "+uri+" with "+sb+" for insert of "+pubUSK);
				m.add(sb);
				sb.schedule(context);
				added = true;
			} catch (IOException e) {
				Logger.error(this, "Unable to insert USK date hints due to disk I/O error: "+e, e);
				if(!added) {
					cb.onFailure(new InsertException(InsertExceptionMode.BUCKET_ERROR, e, pubUSK.getSSK(edition).getURI()), this, context);
					return;
				} // Else try to insert the other hints.
			} catch (InsertException e) {
				Logger.error(this, "Unable to insert USK date hints due to error: "+e, e);
				if(!added) {
					cb.onFailure(e, this, context);
					return;
				} // Else try to insert the other hints.
			}
		}
		cb.onTransition(this, m, context);
		m.arm(context);
	}

	private void scheduleInsert(ClientContext context) {
		long edNo = Math.max(edition, context.uskManager.lookupLatestSlot(pubUSK)+1);
		synchronized(this) {
			if(finished) return;
			edition = edNo;
			if(logMINOR)
				Logger.minor(this, "scheduling insert for "+pubUSK.getURI()+ ' ' +edition);
			sbi = new SingleBlockInserter(parent, data, compressionCodec, privUSK.getInsertableSSK(edition).getInsertURI(),
					ctx, realTimeFlag, this, isMetadata, sourceLength, token, false, true /* we don't use it */, tokenObject, context, persistent, false, extraInserts, cryptoAlgorithm, forceCryptoKey);
		}
		try {
			sbi.schedule(context);
		} catch (InsertException e) {
			synchronized(this) {
				finished = true;
			}
			if(freeData) {
				data.free();
				synchronized(this) {
					data = null;
				}
			}
			cb.onFailure(e, this, context);
		}
	}

	@Override
	public synchronized void onSuccess(ClientPutState state, ClientContext context) {
		USK newEdition = pubUSK.copy(edition);
		finished = true;
		sbi = null;
		FreenetURI targetURI = pubUSK.getSSK(edition).getURI();
		FreenetURI realURI = ((SingleBlockInserter)state).getURI(context);
		if(!targetURI.equals(realURI))
			Logger.error(this, "URI should be "+targetURI+" actually is "+realURI);
		else {
			if(logMINOR)
				Logger.minor(this, "URI should be "+targetURI+" actually is "+realURI);
			context.uskManager.updateKnownGood(pubUSK, edition, context);
		}
		if(freeData) {
			data.free();
			data = null;
		}
		cb.onEncode(newEdition, this, context);
		insertSucceeded(context, edition);
		// FINISHED!!!! Yay!!!
	}

	@Override
	public void onFailure(InsertException e, ClientPutState state, ClientContext context) {
		synchronized(this) {
			sbi = null;
			if(e.getMode() == InsertExceptionMode.COLLISION) {
				// Try the next slot
				edition++;
				consecutiveCollisions++;
				if(consecutiveCollisions > MAX_TRIED_SLOTS)
					scheduleFetcher(context);
				else
					scheduleInsert(context);
			} else {
				Bucket d = null;
				synchronized(this) {
					finished = true;
					if(freeData) {
						d = data;
						data = null;
					}
				}
				if(freeData) {
					d.free();
				}
				cb.onFailure(e, state, context);
			}
		}
	}

	@Override
	public int hashCode() {
		return hashCode;
	}
	
	public USKInserter(BaseClientPutter parent, Bucket data, short compressionCodec, FreenetURI uri, 
			InsertContext ctx, PutCompletionCallback cb, boolean isMetadata, int sourceLength, int token, 
			boolean addToParent, Object tokenObject, ClientContext context, boolean freeData, boolean persistent, boolean realTimeFlag, int extraInserts, byte cryptoAlgorithm, byte[] forceCryptoKey) throws MalformedURLException {
		this.hashCode = super.hashCode();
		this.tokenObject = tokenObject;
		this.persistent = persistent;
		this.parent = parent;
		this.data = data;
		this.compressionCodec = compressionCodec;
		this.ctx = ctx;
		this.cb = cb;
		this.isMetadata = isMetadata;
		this.sourceLength = sourceLength;
		this.token = token;
		if(addToParent) {
			parent.addMustSucceedBlocks(1);
			parent.notifyClients(context);
		}
		privUSK = InsertableUSK.createInsertable(uri, persistent);
		pubUSK = privUSK.getUSK();
		edition = pubUSK.suggestedEdition;
		this.freeData = freeData;
		this.extraInserts = extraInserts;
		this.cryptoAlgorithm = cryptoAlgorithm;
		this.forceCryptoKey = forceCryptoKey;
		this.realTimeFlag = realTimeFlag;
	}
	
	protected USKInserter() {
	    // For serialization.
	    this.hashCode = 0;
	    this.tokenObject = null;
	    this.persistent = false;
	    this.parent = null;
	    this.data = null;
	    this.compressionCodec = 0;
	    this.ctx = null;
	    this.cb = null;
	    this.isMetadata = false;
	    this.sourceLength = 0;
	    this.token = 0;
	    this.privUSK = null;
	    this.pubUSK = null;
	    this.edition = 0;
	    this.freeData = false;
	    this.extraInserts = 0;
	    this.cryptoAlgorithm = 0;
	    this.forceCryptoKey = null;
	    this.realTimeFlag = false;
	}

	@Override
	public BaseClientPutter getParent() {
		return parent;
	}

	@Override
	public void cancel(ClientContext context) {
		USKFetcherTag tag;
		synchronized(this) {
			if(finished) return;
			finished = true;
			tag = fetcher;
			fetcher = null;
		}
		if(tag != null) {
			tag.cancel(context);
		}
		if(sbi != null) {
			sbi.cancel(context); // will call onFailure, which will removeFrom()
		}
		if(freeData) {
			if(data == null) {
				Logger.error(this, "data == null in cancel() on "+this, new Exception("error"));
			} else {
				data.free();
				synchronized(this) {
					data = null;
				}
			}
		}
		cb.onFailure(new InsertException(InsertExceptionMode.CANCELLED), this, context);
	}

	@Override
	public void onFailure(ClientContext context) {
		if(logMINOR) Logger.minor(this, "Fetcher failed to find the given edition or any later edition on "+this);
		scheduleInsert(context);
	}

	@Override
	public void onCancelled(ClientContext context) {
		synchronized(this) {
		    fetcher = null;
			if(finished) return;
		}
		Logger.error(this, "Unexpected onCancelled()", new Exception("error"));
		cancel(context);
	}

	@Override
	public void onEncode(BaseClientKey key, ClientPutState state, ClientContext context) {
		// Ignore
	}

	@Override
	public void onTransition(ClientPutState oldState, ClientPutState newState, ClientContext context) {
		// Shouldn't happen
		Logger.error(this, "Got onTransition("+oldState+ ',' +newState+ ')');
	}

	@Override
	public void onMetadata(Metadata m, ClientPutState state, ClientContext context) {
		// Shouldn't happen
		Logger.error(this, "Got onMetadata("+m+ ',' +state+ ')');
	}

	@Override
	public void onBlockSetFinished(ClientPutState state, ClientContext context) {
		// Ignore
	}

	@Override
	public Object getToken() {
		return tokenObject;
	}

	@Override
	public void onFetchable(ClientPutState state) {
		// Ignore
	}

	@Override
	public short getPollingPriorityNormal() {
		return parent.getPriorityClass();
	}

	@Override
	public short getPollingPriorityProgress() {
		return parent.getPriorityClass();
	}

	@Override
	public void onMetadata(Bucket meta, ClientPutState state, ClientContext context) {
		Logger.error(this, "onMetadata on "+this+" from "+state, new Exception("error"));
		meta.free();
	}
	
	private transient boolean resumed = false;

    @Override
    public void onResume(ClientContext context) throws InsertException, ResumeFailedException {
        if(resumed) return;
        resumed = true;
        if(data != null) data.onResume(context);
        if(cb != null && cb != parent) cb.onResume(context);
        if(fetcher != null) fetcher.onResume(context);
        if(sbi != null) sbi.onResume(context);
    }

    @Override
    public void onShutdown(ClientContext context) {
        SingleBlockInserter sbi;
        synchronized(this) {
            sbi = this.sbi;
        }
        if(sbi != null) sbi.onShutdown(context);
    }

}
