/* 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.io.xfer;

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

import freenet.io.comm.AsyncMessageCallback;
import freenet.io.comm.AsyncMessageFilterCallback;
import freenet.io.comm.ByteCounter;
import freenet.io.comm.DMT;
import freenet.io.comm.DisconnectedException;
import freenet.io.comm.Message;
import freenet.io.comm.MessageFilter;
import freenet.io.comm.NotConnectedException;
import freenet.io.comm.PeerContext;
import freenet.node.PrioRunnable;
import freenet.support.BitArray;
import freenet.support.LogThresholdCallback;
import freenet.support.Logger;
import freenet.support.Logger.LogLevel;
import freenet.support.io.NativeThread;

/**
 * Bulk data transfer (not block). Bulk transfer is designed for files which may be much bigger than a 
 * key block, and where we have the whole file at the outset. Do not persist across node restarts.
 * 
 * Used by update over mandatory, sending a file to our peers attached to an N2NTM etc.
 * @author toad
 */
public class BulkTransmitter {
	
	public interface AllSentCallback {

		void allSent(BulkTransmitter bulkTransmitter, boolean anyFailed);
		
	}

	/** If no packets sent in this period, and no completion acknowledgement / cancellation, assume failure. */
	static final long TIMEOUT = MINUTES.toMillis(5);
	/** Time to hang around listening for the last FNPBulkReceivedAll message */
	static final long FINAL_ACK_TIMEOUT = SECONDS.toMillis(10);
	final AllSentCallback allSentCallback;
	/** Available blocks */
	final PartiallyReceivedBulk prb;
	/** Peer who we are sending the data to */
	final PeerContext peer;
	/** Transfer UID for messages */
	final long uid;
	/** Blocks we have but haven't sent yet. 0 = block sent or not present, 1 = block present but not sent */
	final BitArray blocksNotSentButPresent;
	private boolean cancelled;
	/** Not persistent over reboots */
	final long peerBootID;
	private boolean sentCancel;
	private boolean finished;
	/** Not expecting a response? */
	final boolean noWait;
	private long finishTime=-1;
	private String cancelReason;
	private final ByteCounter ctr;
	private final boolean realTime;
	
	private static long transfersCompleted;
	private static long transfersSucceeded;

        private static volatile boolean logMINOR;
	static {
		Logger.registerLogThresholdCallback(new LogThresholdCallback(){
			@Override
			public void shouldUpdate(){
				logMINOR = Logger.shouldLog(LogLevel.MINOR, this);
			}
		});
	}
	
	public BulkTransmitter(PartiallyReceivedBulk prb, PeerContext peer, long uid, boolean noWait, ByteCounter ctr, boolean realTime) throws DisconnectedException {
		this(prb, peer, uid, noWait, ctr, realTime, null);
	}
	
	/**
	 * Create a bulk data transmitter.
	 * @param prb The PartiallyReceivedBulk containing the file we want to send, or the part of it that we have so far.
	 * @param peer The peer we want to send it to.
	 * @param uid The unique identifier for this data transfer
	 * @param noWait If true, don't wait for an FNPBulkReceivedAll, return as soon as we've sent everything.
	 * @throws DisconnectedException If the peer we are trying to send to becomes disconnected.
	 */
	public BulkTransmitter(PartiallyReceivedBulk prb, PeerContext peer, long uid, boolean noWait, ByteCounter ctr, boolean realTime, AllSentCallback cb) throws DisconnectedException {
		this.prb = prb;
		this.peer = peer;
		this.uid = uid;
		this.noWait = noWait;
		this.ctr = ctr;
		this.realTime = realTime;
		this.allSentCallback = cb;
		if(ctr == null) throw new NullPointerException();
		peerBootID = peer.getBootID();
		// Need to sync on prb while doing both operations, to avoid race condition.
		// Specifically, we must not get calls to blockReceived() until blocksNotSentButPresent
		// has been set, AND it must be accurate, so there must not be an unlocked period
		// between cloning and adding.
		synchronized(prb) {
			// We can just clone it.
			blocksNotSentButPresent = prb.cloneBlocksReceived();
			prb.add(this);
		}
		try {
			prb.usm.addAsyncFilter(MessageFilter.create().setNoTimeout().setSource(peer).setType(DMT.FNPBulkReceiveAborted).setField(DMT.UID, uid),
					new AsyncMessageFilterCallback() {
						@Override
						public void onMatched(Message m) {
							cancel("Other side sent FNPBulkReceiveAborted");
						}
						@Override
						public boolean shouldTimeout() {
							synchronized(BulkTransmitter.this) {
								if(cancelled || finished) return true;
							}
							if(BulkTransmitter.this.prb.isAborted()) return true;
							return false;
						}
						@Override
						public void onTimeout() {
							// Ignore
						}
						@Override
						public void onDisconnect(PeerContext ctx) {
							// Ignore
						}
						@Override
						public void onRestarted(PeerContext ctx) {
							// Ignore
						}
			}, ctr);
			prb.usm.addAsyncFilter(MessageFilter.create().setNoTimeout().setSource(peer).setType(DMT.FNPBulkReceivedAll).setField(DMT.UID, uid),
					new AsyncMessageFilterCallback() {
						@Override
						public void onMatched(Message m) {
							// send() will terminate, so must call setAllQueued().
							setAllQueued();
							completed();
						}
						@Override
						public boolean shouldTimeout() {
							synchronized(BulkTransmitter.this) {
								   if (cancelled) return true;
								   if (finished)  return (System.currentTimeMillis()-finishTime > FINAL_ACK_TIMEOUT);
							}
							if(BulkTransmitter.this.prb.isAborted()) return true;
							return false;
						}
						@Override
						public void onTimeout() {
							// Ignore
						}
						@Override
						public void onDisconnect(PeerContext ctx) {
							// Ignore
						}
						@Override
						public void onRestarted(PeerContext ctx) {
							// Ignore
						}
			}, ctr);
		} catch (DisconnectedException e) {
			cancel("Disconnected");
			throw e;
		}
	}

	/**
	 * Received a block. Set the relevant bit to 1 to indicate that we have the block but haven't sent
	 * it yet. **Only called by PartiallyReceivedBulk.**
	 * @param block The block number that has been received.
	 */
	synchronized void blockReceived(int block) {
		blocksNotSentButPresent.setBit(block, true);
		notifyAll();
	}

	/**
	 * Called when the PRB is aborted.
	 */
	public void onAborted() {
		sendAbortedMessage();
		synchronized(this) {
			notifyAll();
		}
	}
	
	private void sendAbortedMessage() {
		synchronized(this) {
			if(sentCancel) return;
			sentCancel = true;
		}
		try {
			peer.sendAsync(DMT.createFNPBulkSendAborted(uid), null, ctr);
		} catch (NotConnectedException e) {
			// Cool
		}
	}

	public void cancel(String reason) {
		if(logMINOR)
			Logger.minor(this, "Cancelling "+this);
		sendAbortedMessage();
		synchronized(this) {
			if(cancelled || finished) return;
			cancelled = true;
			cancelReason = reason;
			notifyAll();
		}
		prb.remove(this);
		synchronized(BulkTransmitter.class) {
			transfersCompleted++;
		}
		// Call AllSentCallback if necessary.
		// If there are packets still waiting, it will be called after they are sent or failed.
		setAllQueued();
	}

	/** Like cancel(), but without the negative overtones: The client says it's got everything,
	 * we believe them (even if we haven't sent everything; maybe they had a partial). */
	public void completed() {
		synchronized(this) {
			if(cancelled || finished) return;
			finished = true;
			finishTime = System.currentTimeMillis();
			notifyAll();
		}
		prb.remove(this);
		synchronized(BulkTransmitter.class) {
			transfersCompleted++;
			transfersSucceeded++;
		}
		if(logMINOR) Logger.minor(this, "Completed transfer successfully "+this);
	}
	
	/**
	 * Send the file.
	 * @return True if the file was successfully sent. False otherwise.
	 * @throws DisconnectedException 
	 */
	public boolean send() throws DisconnectedException {
		long lastSentPacket = System.currentTimeMillis();
outer:	while(true) {
			int max = Math.min(Integer.MAX_VALUE, prb.blocks);
			max = Math.min(max, peer.getThrottleWindowSize());
			// FIXME Need to introduce the global limiter of [code]max[/code] for memory management instead of hard-code for each, no? 
			max = Math.min(max, 100); 
			if(max < 1) max = 1;
			
			if(prb.isAborted()) {
				if(logMINOR)
					Logger.minor(this, "Aborted "+this);
				return false;
			}
			int blockNo;
			if(peer.getBootID() != peerBootID) {
				synchronized(this) {
					cancelled = true;
					notifyAll();
				}
				prb.remove(BulkTransmitter.this);
				if(logMINOR)
					Logger.minor(this, "Failed to send "+uid+": peer restarted: "+peer);
				throw new DisconnectedException();
			}
			synchronized(this) {
				if(finished) return true;
				if(cancelled) return false;
				blockNo = blocksNotSentButPresent.firstOne();
			}
			if(blockNo < 0) {
				setAllQueued();
				if(noWait && prb.hasWholeFile()) {
					completed();
					return true;
				}
				synchronized(this) {
					// Wait for all packets to complete
					while(true) {
						if(failedPacket) {
							cancel("Packet send failed");
							return false;
						}
						if(logMINOR)
							Logger.minor(this, "Waiting for packets: remaining: "+inFlightPackets);
						if(inFlightPackets == 0) break;
						try {
							wait();
							if(failedPacket) {
								cancel("Packet send failed");
								return false;
							}
							if(inFlightPackets == 0) break;
							continue outer; // Might be a packet...
						} catch (InterruptedException e) {
							// Ignore
						}
					}
					
					// Wait for a packet to come in, BulkReceivedAll or BulkReceiveAborted
					try {
						wait(SECONDS.toMillis(60));
					} catch (InterruptedException e) {
						// No problem
						continue;
					}
				}
				long end = System.currentTimeMillis();
				if(end - lastSentPacket > TIMEOUT) {
					Logger.error(this, "Send timed out on "+this);
					cancel("Timeout awaiting BulkReceivedAll");
					return false;
				}
				continue;
			}
			// Send a packet
			byte[] buf = prb.getBlockData(blockNo);
			if(buf == null) {
				if(logMINOR)
					Logger.minor(this, "Block "+blockNo+" is null, presumably the send is cancelled: "+this);
				// Already cancelled, quit
				return false;
			}
			
			// Congestion control and bandwidth limiting
			try {
				if(logMINOR) Logger.minor(this, "Sending packet "+blockNo);
				Message msg = DMT.createFNPBulkPacketSend(uid, blockNo, buf, realTime);
				UnsentPacketTag tag = new UnsentPacketTag();
				peer.sendAsync(msg, tag, ctr);
				synchronized(this) {
					while(inFlightPackets >= max && !failedPacket)
						try {
							wait(1000);
						} catch (InterruptedException e) {
							// Ignore
						}
				}
				synchronized(this) {
					blocksNotSentButPresent.setBit(blockNo, false);
				}
				lastSentPacket = System.currentTimeMillis();
			} catch (NotConnectedException e) {
				cancel("Disconnected");
				if(logMINOR)
					Logger.minor(this, "Cancelled: not connected "+this);
				throw new DisconnectedException();
			}
		}
	}
	
	private void setAllQueued() {
		if(allSentCallback != null) {
			boolean callAllSent = false;
			boolean anyFailed = false;
			synchronized(this) {
				allQueued = true;
				if(unsentPackets == 0 && !calledAllSent) {
					if(logMINOR) Logger.minor(this, "Calling all sent callback on "+this);
					callAllSent = true;
					calledAllSent = true;
					anyFailed = failedPacket;
				} else if(!calledAllSent) {
					if(logMINOR) Logger.minor(this, "Still waiting for "+unsentPackets);
				}
			}
			if(callAllSent) {
				callAllSentCallbackInner(anyFailed);
			}
		}
	}
	
	private void callAllSentCallbackInner(final boolean anyFailed) {
		prb.usm.getExecutor().execute(new PrioRunnable() {

			@Override
			public void run() {
				allSentCallback.allSent(BulkTransmitter.this, anyFailed);
			}

			@Override
			public int getPriority() {
				return NativeThread.HIGH_PRIORITY;
			}
			
		});
	}
	private int inFlightPackets = 0;
	private int unsentPackets = 0;
	private boolean failedPacket = false;
	private boolean allQueued = false;
	private boolean calledAllSent = false;
	
	private class UnsentPacketTag implements AsyncMessageCallback {

		private boolean finished;
		private boolean sent;
		
		private UnsentPacketTag() {
			synchronized(BulkTransmitter.this) {
				inFlightPackets++;
				unsentPackets++;
			}
		}
		
		@Override
		public void acknowledged() {
			complete(false);
		}

		private void complete(boolean failed) {
			synchronized(this) {
				if(finished) return;
				finished = true;
				notifyAll();
			}
			if(!failed)
				ctr.sentPayload(prb.blockSize);
			synchronized(BulkTransmitter.this) {
				if(failed) {
					failedPacket = true;
					BulkTransmitter.this.notifyAll();
					if(logMINOR) Logger.minor(this, "Packet failed for "+BulkTransmitter.this);
				} else {
					inFlightPackets--;
					BulkTransmitter.this.notifyAll();
					if(logMINOR) Logger.minor(this, "Packet sent "+BulkTransmitter.this+" remaining in flight: "+inFlightPackets);
				}
			}
			sent(true);
		}

		@Override
		public void disconnected() {
			complete(true);
		}

		@Override
		public void fatalError() {
			complete(true);
		}

		@Override
		public void sent() {
			sent(false);
		}
		
		public void sent(boolean ignoreFinished) {
			if(allSentCallback == null) return;
			synchronized(this) {
				if(finished && !ignoreFinished) return;
				if(sent) return;
				sent = true;
				notifyAll();
			}
			final boolean anyFailed;
			synchronized(BulkTransmitter.this) {
				unsentPackets--;
				if(unsentPackets > 0) return;
				if(!allQueued) return;
				if(calledAllSent) return;
				calledAllSent = true;
				anyFailed = failedPacket;
			}
			if(logMINOR) Logger.minor(this, "Calling all sent callback on "+this);
			callAllSentCallbackInner(anyFailed);
		}
		
	}
	
	@Override
	public String toString() {
		return "BulkTransmitter:"+uid+":"+peer.shortToString();
	}
	
	public String getCancelReason() {
		return cancelReason;
	}
	
	public static synchronized long[] transferSuccess() {
		return new long[] { transfersCompleted, transfersSucceeded };
	}
}
