package freenet.node;

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

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.EOFException;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.net.MalformedURLException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.zip.DeflaterOutputStream;
import java.util.zip.InflaterInputStream;

import freenet.client.DefaultMIMETypes;
import freenet.io.comm.DMT;
import freenet.io.comm.DisconnectedException;
import freenet.io.comm.FreenetInetAddress;
import freenet.io.comm.Message;
import freenet.io.comm.NotConnectedException;
import freenet.io.comm.Peer;
import freenet.io.comm.PeerParseException;
import freenet.io.comm.ReferenceSignatureVerificationException;
import freenet.io.comm.RetrievalException;
import freenet.io.xfer.BulkReceiver;
import freenet.io.xfer.BulkTransmitter;
import freenet.io.xfer.PartiallyReceivedBulk;
import freenet.keys.FreenetURI;
import freenet.l10n.NodeL10n;
import freenet.node.useralerts.AbstractUserAlert;
import freenet.node.useralerts.BookmarkFeedUserAlert;
import freenet.node.useralerts.DownloadFeedUserAlert;
import freenet.node.useralerts.N2NTMUserAlert;
import freenet.node.useralerts.UserAlert;
import freenet.support.Base64;
import freenet.support.HTMLNode;
import freenet.support.IllegalBase64Exception;
import freenet.support.Logger;
import freenet.support.SimpleFieldSet;
import freenet.support.SizeUtil;
import freenet.support.api.HTTPUploadedFile;
import freenet.support.api.RandomAccessBuffer;
import freenet.support.io.BucketTools;
import freenet.support.io.ByteArrayRandomAccessBuffer;
import freenet.support.io.FileRandomAccessBuffer;
import freenet.support.io.FileUtil;

public class DarknetPeerNode extends PeerNode {

	/** Name of this node */
	String myName;

	/** True if this peer is not to be connected with */
	private boolean isDisabled;

	/** True if we don't send handshake requests to this peer, but will connect if we receive one */
	private boolean isListenOnly;

	/** True if we send handshake requests to this peer in infrequent bursts */
	private boolean isBurstOnly;

	/** True if we want to ignore the source port of the node's sent packets.
	 * This is normally set when dealing with an Evil Corporate Firewall which rewrites the port on outgoing
	 * packets but does not redirect incoming packets destined to the rewritten port.
	 * What it does is this: If we have an address with the same IP but a different port, to the detectedPeer,
	 * we use that instead. */
	private boolean ignoreSourcePort;

	/** True if we want to allow LAN/localhost addresses. */
	private boolean allowLocalAddresses;

	/** Extra peer data file numbers */
	private LinkedHashSet<Integer> extraPeerDataFileNumbers;

	/** Private comment on the peer for /friends/ page */
	private String privateDarknetComment;

	/** Private comment on the peer for /friends/ page's extra peer data file number */
	private int privateDarknetCommentFileNumber;

	/** Queued-to-send N2NM extra peer data file numbers */
	private LinkedHashSet<Integer> queuedToSendN2NMExtraPeerDataFileNumbers;

	private FRIEND_TRUST trustLevel;

	private FRIEND_VISIBILITY ourVisibility;
	private FRIEND_VISIBILITY theirVisibility;

	private static volatile boolean logMINOR;
	static { Logger.registerClass(DarknetPeerNode.class); }

	public enum FRIEND_TRUST {
		LOW,
		NORMAL,
		HIGH;

		private static final FRIEND_TRUST[] valuesBackwards;
		static {
			final FRIEND_TRUST[] values = values();
			valuesBackwards = new FRIEND_TRUST[values.length];
			for(int i=0;i<values.length;i++)
				valuesBackwards[i] = values[values.length-i-1];
		}

		public static FRIEND_TRUST[] valuesBackwards() {
			return valuesBackwards.clone();
		}

		public boolean isDefaultValue() {
			return equals(FRIEND_TRUST.NORMAL);
		}

	}

	public enum FRIEND_VISIBILITY {
		YES((short)0), // Visible
		NAME_ONLY((short)1), // Only the name is visible, but other friends can ask for a connection
		NO((short)2); // Not visible to our other friends at all

		/** The codes are persistent and used to communicate between nodes, so they must not change.
		 * Which is why we are not using ordinal(). */
		final short code;

		FRIEND_VISIBILITY(short code) {
			this.code = code;
		}

		public boolean isStricterThan(FRIEND_VISIBILITY theirVisibility) {
			if(theirVisibility == null) return true;
			// Higher number = more strict.
			return theirVisibility.code < code;
		}

		public static FRIEND_VISIBILITY getByCode(short code) {
			for(FRIEND_VISIBILITY f : values()) {
				if(f.code == code) return f;
			}
			return null;
		}

		public boolean isDefaultValue() {
			return equals(FRIEND_VISIBILITY.YES);
		}
	}

	/**
	 * Create a darknet PeerNode from a SimpleFieldSet
	 * @param fs The SimpleFieldSet to parse
	 * @param node2 The running Node we are part of.
	 * @param trust If this is a new node, we will use this parameter to set the initial trust level.
	 * @throws PeerTooOldException
	 */
	public DarknetPeerNode(SimpleFieldSet fs, Node node2, NodeCrypto crypto, boolean fromLocal, FRIEND_TRUST trust, FRIEND_VISIBILITY visibility2) throws FSParseException, PeerParseException, ReferenceSignatureVerificationException, PeerTooOldException {
		super(fs, node2, crypto, fromLocal);

		String name = fs.get("myName");
		if(name == null) throw new FSParseException("No name");
		myName = name;

		if(fromLocal) {
			SimpleFieldSet metadata = fs.subset("metadata");

			isDisabled = metadata.getBoolean("isDisabled", false);
			isListenOnly = metadata.getBoolean("isListenOnly", false);
			isBurstOnly = metadata.getBoolean("isBurstOnly", false);
			disableRouting = disableRoutingHasBeenSetLocally = metadata.getBoolean("disableRoutingHasBeenSetLocally", false);
			ignoreSourcePort = metadata.getBoolean("ignoreSourcePort", false);
			allowLocalAddresses = metadata.getBoolean("allowLocalAddresses", false);
			String s = metadata.get("trustLevel");
			if(s != null) {
				trustLevel = FRIEND_TRUST.valueOf(s);
			} else {
				trustLevel = node.getSecurityLevels().getDefaultFriendTrust();
				System.err.println("Assuming friend ("+name+") trust is opposite of friend seclevel: "+trustLevel);
			}
			s = metadata.get("ourVisibility");
			if(s != null) {
				ourVisibility = FRIEND_VISIBILITY.valueOf(s);
			} else {
				System.err.println("Assuming friend ("+name+") wants to be invisible");
				node.createVisibilityAlert();
				ourVisibility = FRIEND_VISIBILITY.NO;
			}
			s = metadata.get("theirVisibility");
			if(s != null) {
				theirVisibility = FRIEND_VISIBILITY.valueOf(s);
			} else {
				theirVisibility = FRIEND_VISIBILITY.NO;
			}
		} else {
			if(trust == null) throw new IllegalArgumentException();
			trustLevel = trust;
			ourVisibility = visibility2;
		}

		// Setup the private darknet comment note
		privateDarknetComment = "";
		privateDarknetCommentFileNumber = -1;

		// Setup the extraPeerDataFileNumbers
		extraPeerDataFileNumbers = new LinkedHashSet<Integer>();

		// Setup the queuedToSendN2NMExtraPeerDataFileNumbers
		queuedToSendN2NMExtraPeerDataFileNumbers = new LinkedHashSet<Integer>();
	}

	/**
	 *
	 * Normally this is the address that packets have been received from from this node.
	 * However, if ignoreSourcePort is set, we will search for a similar address with a different port
	 * number in the node reference.
	 */
	@Override
	public synchronized Peer getPeer(){
		Peer detectedPeer = super.getPeer();
		if(ignoreSourcePort) {
			FreenetInetAddress addr = detectedPeer == null ? null : detectedPeer.getFreenetAddress();
			int port = detectedPeer == null ? -1 : detectedPeer.getPort();
			if(nominalPeer == null) return detectedPeer;
			for(Peer p : nominalPeer) {
				if(p.getPort() != port && p.getFreenetAddress().equals(addr)) {
					return p;
				}
			}
		}
		return detectedPeer;
	}

	/**
	 * @return True, if we are disconnected and it has been a
	 * sufficient time period since we last sent a handshake
	 * attempt.
	 */
	@Override
	public boolean shouldSendHandshake() {
		synchronized(this) {
			if(isDisabled) return false;
			if(isListenOnly) return false;
			if(!super.shouldSendHandshake()) return false;
		}
		return true;
	}

	@Override
	protected synchronized boolean innerProcessNewNoderef(SimpleFieldSet fs, boolean forARK, boolean forDiffNodeRef, boolean forFullNodeRef) throws FSParseException {
		boolean changedAnything = super.innerProcessNewNoderef(fs, forARK, forDiffNodeRef, forFullNodeRef);
		String name = fs.get("myName");
		if(name == null && forFullNodeRef) throw new FSParseException("No name in full noderef");
		if(name != null && !name.equals(myName)) {
			changedAnything = true;
			myName = name;
		}
		return changedAnything;
	}

	@Override
	public synchronized SimpleFieldSet exportFieldSet() {
		SimpleFieldSet fs = super.exportFieldSet();
		fs.putSingle("myName", getName());
		return fs;
	}

	@Override
	public synchronized SimpleFieldSet exportMetadataFieldSet(long now) {
		SimpleFieldSet fs = super.exportMetadataFieldSet(now);
		if(isDisabled)
			fs.putSingle("isDisabled", "true");
		if(isListenOnly)
			fs.putSingle("isListenOnly", "true");
		if(isBurstOnly)
			fs.putSingle("isBurstOnly", "true");
		if(ignoreSourcePort)
			fs.putSingle("ignoreSourcePort", "true");
		if(allowLocalAddresses)
			fs.putSingle("allowLocalAddresses", "true");
		if(disableRoutingHasBeenSetLocally)
			fs.putSingle("disableRoutingHasBeenSetLocally", "true");
		fs.putSingle("trustLevel", trustLevel.name());
		fs.putSingle("ourVisibility", ourVisibility.name());
		if(theirVisibility != null)
			fs.putSingle("theirVisibility", theirVisibility.name());

		return fs;
	}

	public synchronized String getName() {
		return myName;
	}

	@Override
	protected synchronized int getPeerNodeStatus(long now, long backedOffUntilRT, long backedOffUntilBulk, boolean overPingThreshold, boolean noLoadStats) {
		if(isDisabled) {
			return PeerManager.PEER_NODE_STATUS_DISABLED;
		}
		int status = super.getPeerNodeStatus(now, backedOffUntilRT, backedOffUntilBulk, overPingThreshold, noLoadStats);
		if(status == PeerManager.PEER_NODE_STATUS_CONNECTED ||
				status == PeerManager.PEER_NODE_STATUS_CLOCK_PROBLEM ||
				status == PeerManager.PEER_NODE_STATUS_ROUTING_BACKED_OFF ||
				status == PeerManager.PEER_NODE_STATUS_CONN_ERROR ||
				status == PeerManager.PEER_NODE_STATUS_TOO_NEW ||
				status == PeerManager.PEER_NODE_STATUS_TOO_OLD ||
				status == PeerManager.PEER_NODE_STATUS_ROUTING_DISABLED ||
				status == PeerManager.PEER_NODE_STATUS_DISCONNECTING ||
				status == PeerManager.PEER_NODE_STATUS_NO_LOAD_STATS)
			return status;
		if(isListenOnly)
			return PeerManager.PEER_NODE_STATUS_LISTEN_ONLY;
		if(isBurstOnly)
			return PeerManager.PEER_NODE_STATUS_LISTENING;
		return status;
	}

	public void enablePeer() {
		synchronized(this) {
			isDisabled = false;
		}
		setPeerNodeStatus(System.currentTimeMillis());
		node.getPeers().writePeersDarknetUrgent();
	}

	public void disablePeer() {
		synchronized(this) {
			isDisabled = true;
		}
		if(isConnected()) {
			forceDisconnect();
		}
		stopARKFetcher();
		setPeerNodeStatus(System.currentTimeMillis());
		node.getPeers().writePeersDarknetUrgent();
	}

	@Override
	public synchronized boolean isDisabled() {
		return isDisabled;
	}

	public void setListenOnly(boolean setting) {
		synchronized(this) {
			isListenOnly = setting;
		}
		if(setting && isBurstOnly()) {
			setBurstOnly(false);
		}
		if(setting) {
			stopARKFetcher();
		}
		setPeerNodeStatus(System.currentTimeMillis());
		node.getPeers().writePeersDarknetUrgent();
	}

	public synchronized boolean isListenOnly() {
		return isListenOnly;
	}

	public void setBurstOnly(boolean setting) {
		synchronized(this) {
			isBurstOnly = setting;
		}
		if(setting && isListenOnly()) {
			setListenOnly(false);
		}
		long now = System.currentTimeMillis();
		if(!setting) {
			synchronized(this) {
				sendHandshakeTime = now;  // don't keep any long handshake delays we might have had under BurstOnly
			}
		}
		setPeerNodeStatus(now);
		node.getPeers().writePeersDarknetUrgent();
	}

	public void setIgnoreSourcePort(boolean setting) {
		synchronized(this) {
			ignoreSourcePort = setting;
		}
	}

	/**
	 * Change the routing status of a peer
	 *
	 * @param shouldRoute
	 * @param localRequest (true everywhere but in NodeDispatcher)
	 */
	public void setRoutingStatus(boolean shouldRoute, boolean localRequest) {
		synchronized(this) {
			if(localRequest)
				disableRoutingHasBeenSetLocally = !shouldRoute;
			else
				disableRoutingHasBeenSetRemotely = !shouldRoute;

			disableRouting = disableRoutingHasBeenSetLocally || disableRoutingHasBeenSetRemotely;
		}

		if(localRequest) {
			Message msg = DMT.createRoutingStatus(shouldRoute);
			try {
				sendAsync(msg, null, node.getNodeStats().setRoutingStatusCtr);
			} catch(NotConnectedException e) {
			// ok
			}
		}
		setPeerNodeStatus(System.currentTimeMillis());
		node.getPeers().writePeersDarknetUrgent();

	}

	@Override
	public boolean isIgnoreSource() {
		return ignoreSourcePort;
	}

	@Override
	public boolean isBurstOnly() {
		synchronized(this) {
			if(isBurstOnly) return true;
		}
		return super.isBurstOnly();
	}

	@Override
	public boolean allowLocalAddresses() {
		synchronized(this) {
			if(allowLocalAddresses) return true;
		}
		return super.allowLocalAddresses();
	}

	public void setAllowLocalAddresses(boolean setting) {
		synchronized(this) {
			allowLocalAddresses = setting;
		}
		node.getPeers().writePeersDarknetUrgent();
	}

	public boolean readExtraPeerData() {
		String extraPeerDataDirPath = node.getExtraPeerDataDir();
		File extraPeerDataPeerDir = new File(extraPeerDataDirPath+File.separator+getIdentityString());
		if(!extraPeerDataPeerDir.exists()) {
			return false;
		}
		if(!extraPeerDataPeerDir.isDirectory()) {
			Logger.error(this, "Extra peer data directory for peer not a directory: "+extraPeerDataPeerDir.getPath());
			return false;
		}
		File[] extraPeerDataFiles = extraPeerDataPeerDir.listFiles();
		if(extraPeerDataFiles == null) {
			return false;
		}
		boolean gotError = false;
		boolean readResult = false;
		for (File extraPeerDataFile : extraPeerDataFiles) {
			Integer fileNumber;
			try {
				fileNumber = Integer.valueOf(extraPeerDataFile.getName());
			} catch (NumberFormatException e) {
				gotError = true;
				continue;
			}
			synchronized(extraPeerDataFileNumbers) {
				extraPeerDataFileNumbers.add(fileNumber);
			}
			readResult = readExtraPeerDataFile(extraPeerDataFile, fileNumber);
			if(!readResult) {
				gotError = true;
			}
		}
		return !gotError;
	}

	public boolean rereadExtraPeerDataFile(int fileNumber) {
		if(logMINOR)
			Logger.minor(this, "Rereading peer data file "+fileNumber+" for "+shortToString());
		String extraPeerDataDirPath = node.getExtraPeerDataDir();
		File extraPeerDataPeerDir = new File(extraPeerDataDirPath+File.separator+getIdentityString());
		if(!extraPeerDataPeerDir.exists()) {
			Logger.error(this, "Extra peer data directory for peer does not exist: "+extraPeerDataPeerDir.getPath());
			return false;
		}
		if(!extraPeerDataPeerDir.isDirectory()) {
			Logger.error(this, "Extra peer data directory for peer not a directory: "+extraPeerDataPeerDir.getPath());
			return false;
		}
		File extraPeerDataFile = new File(extraPeerDataDirPath+File.separator+getIdentityString()+File.separator+fileNumber);
		if(!extraPeerDataFile.exists()) {
			Logger.error(this, "Extra peer data file for peer does not exist: "+extraPeerDataFile.getPath());
			return false;
		}
		return readExtraPeerDataFile(extraPeerDataFile, fileNumber);
	}

	public boolean readExtraPeerDataFile(File extraPeerDataFile, int fileNumber) {
		if(logMINOR) Logger.minor(this, "Reading "+extraPeerDataFile+" : "+fileNumber+" for "+shortToString());
		boolean gotError = false;
		if(!extraPeerDataFile.exists()) {
			if(logMINOR)
				Logger.minor(this, "Does not exist");
			return false;
		}
		Logger.normal(this, "extraPeerDataFile: "+extraPeerDataFile.getPath());
		FileInputStream fis;
		try {
			fis = new FileInputStream(extraPeerDataFile);
		} catch (FileNotFoundException e1) {
			Logger.normal(this, "Extra peer data file not found: "+extraPeerDataFile.getPath());
			return false;
		}
		InputStreamReader isr = new InputStreamReader(fis, StandardCharsets.UTF_8);
		BufferedReader br = new BufferedReader(isr);
		SimpleFieldSet fs = null;
		try {
			// Read in the single SimpleFieldSet
			fs = new SimpleFieldSet(br, false, true);
		} catch (EOFException e3) {
			// End of file, fine
		} catch (IOException e4) {
			Logger.error(this, "Could not read extra peer data file: "+e4, e4);
		} finally {
			try {
				br.close();
			} catch (IOException e5) {
				Logger.error(this, "Ignoring "+e5+" caught reading "+extraPeerDataFile.getPath(), e5);
			}
		}
		if(fs == null) {
			Logger.normal(this, "Deleting corrupt (too short?) file: "+extraPeerDataFile);
			deleteExtraPeerDataFile(fileNumber);
			return true;
		}
		boolean parseResult = false;
		try {
			parseResult = parseExtraPeerData(fs, extraPeerDataFile, fileNumber);
			if(!parseResult) {
				gotError = true;
			}
		} catch (FSParseException e2) {
			Logger.error(this, "Could not parse extra peer data: "+e2+ '\n' +fs.toString(),e2);
			gotError = true;
		}
		return !gotError;
	}

	private boolean parseExtraPeerData(SimpleFieldSet fs, File extraPeerDataFile, int fileNumber) throws FSParseException {
		String extraPeerDataTypeString = fs.get("extraPeerDataType");
		int extraPeerDataType = -1;
		try {
			extraPeerDataType = Integer.parseInt(extraPeerDataTypeString);
		} catch (NumberFormatException e) {
			Logger.error(this, "NumberFormatException parsing extraPeerDataType ("+extraPeerDataTypeString+") in file "+extraPeerDataFile.getPath());
			return false;
		}
		if(extraPeerDataType == Node.EXTRA_PEER_DATA_TYPE_N2NTM) {
			node.handleNodeToNodeTextMessageSimpleFieldSet(fs, this, fileNumber);
			return true;
		} else if(extraPeerDataType == Node.EXTRA_PEER_DATA_TYPE_PEER_NOTE) {
			String peerNoteTypeString = fs.get("peerNoteType");
			int peerNoteType = -1;
			try {
				peerNoteType = Integer.parseInt(peerNoteTypeString);
			} catch (NumberFormatException e) {
				Logger.error(this, "NumberFormatException parsing peerNoteType ("+peerNoteTypeString+") in file "+extraPeerDataFile.getPath());
				return false;
			}
			if(peerNoteType == Node.PEER_NOTE_TYPE_PRIVATE_DARKNET_COMMENT) {
				synchronized(this) {
					try {
						privateDarknetComment = Base64.decodeUTF8(fs.get("privateDarknetComment"));
					} catch (IllegalBase64Exception e) {
						Logger.error(this, "Bad Base64 encoding when decoding a private darknet comment SimpleFieldSet", e);
						return false;
					}
					privateDarknetCommentFileNumber = fileNumber;
				}
				return true;
			}
			Logger.error(this, "Read unknown peer note type '"+peerNoteType+"' from file "+extraPeerDataFile.getPath());
			return false;
		} else if(extraPeerDataType == Node.EXTRA_PEER_DATA_TYPE_QUEUED_TO_SEND_N2NM) {
			int type = fs.getInt("n2nType");
			if(isConnected()) {

				if(fs.get("extraPeerDataType") != null) {
					fs.removeValue("extraPeerDataType");
				}
				if(fs.get("senderFileNumber") != null) {
					fs.removeValue("senderFileNumber");
				}
				fs.putOverwrite("senderFileNumber", String.valueOf(fileNumber));
				if(fs.get("sentTime") != null) {
					fs.removeValue("sentTime");
				}
				fs.putOverwrite("sentTime", Long.toString(System.currentTimeMillis()));

				Message n2nm = DMT.createNodeToNodeMessage(type, fs.toString().getBytes(StandardCharsets.UTF_8));
				// the callback ensures that n2ns are only unqueued after being acknowledged
				UnqueueMessageOnAckCallback cb = new UnqueueMessageOnAckCallback(this, fileNumber);
				try {
				  sendAsync(n2nm, cb, null);
				  Logger.normal(this, "Sending queued ("+fileNumber+") N2NM to '"+getName()+"': "+n2nm);
				} catch (NotConnectedException e) {
				  fs.removeValue("sentTime");
				}
			}
			return true;
		}
		else if(extraPeerDataType == Node.EXTRA_PEER_DATA_TYPE_BOOKMARK) {
			Logger.normal(this, "Read friend bookmark" + fs.toString());
			handleFproxyBookmarkFeed(fs, fileNumber);
			return true;
		}
		else if(extraPeerDataType == Node.EXTRA_PEER_DATA_TYPE_DOWNLOAD) {
			Logger.normal(this, "Read friend download" + fs.toString());
			handleFproxyDownloadFeed(fs, fileNumber);
			return true;
		}
		Logger.error(this, "Read unknown extra peer data type '"+extraPeerDataType+"' from file "+extraPeerDataFile.getPath());
		return false;
	}

	public int writeNewExtraPeerDataFile(SimpleFieldSet fs, int extraPeerDataType) {
		String extraPeerDataDirPath = node.getExtraPeerDataDir();
		if(extraPeerDataType > 0)
			fs.putOverwrite("extraPeerDataType", Integer.toString(extraPeerDataType));
		File extraPeerDataPeerDir = new File(extraPeerDataDirPath+File.separator+getIdentityString());
		if(!extraPeerDataPeerDir.exists()) {
			if(!extraPeerDataPeerDir.mkdir()) {
				Logger.error(this, "Extra peer data directory for peer could not be created: "+extraPeerDataPeerDir.getPath());
				return -1;
			}
		}
		if(!extraPeerDataPeerDir.isDirectory()) {
			Logger.error(this, "Extra peer data directory for peer not a directory: "+extraPeerDataPeerDir.getPath());
			return -1;
		}
		Integer[] localFileNumbers;
		int nextFileNumber = 0;
		synchronized(extraPeerDataFileNumbers) {
			// Find the first free slot
			localFileNumbers = extraPeerDataFileNumbers.toArray(new Integer[extraPeerDataFileNumbers.size()]);
			Arrays.sort(localFileNumbers);
			for (int localFileNumber : localFileNumbers) {
				if(localFileNumber > nextFileNumber) {
					break;
				}
				nextFileNumber = localFileNumber + 1;
			}
			extraPeerDataFileNumbers.add(nextFileNumber);
		}
		FileOutputStream fos;
		File extraPeerDataFile = new File(extraPeerDataPeerDir.getPath()+File.separator+nextFileNumber);
		if(extraPeerDataFile.exists()) {
			Logger.error(this, "Extra peer data file already exists: "+extraPeerDataFile.getPath());
			return -1;
		}
		String f = extraPeerDataFile.getPath();
		try {
			fos = new FileOutputStream(f);
		} catch (FileNotFoundException e2) {
			Logger.error(this, "Cannot write extra peer data file to disk: Cannot create "
					+ f + " - " + e2, e2);
			return -1;
		}
		OutputStreamWriter w = new OutputStreamWriter(fos, StandardCharsets.UTF_8);
		BufferedWriter bw = new BufferedWriter(w);
		try {
			fs.writeTo(bw);
			bw.close();
		} catch (IOException e) {
			try {
				fos.close();
			} catch (IOException e1) {
				Logger.error(this, "Cannot close extra peer data file: "+e, e);
			}
			Logger.error(this, "Cannot write file: " + e, e);
			return -1;
		}
		return nextFileNumber;
	}

	public void deleteExtraPeerDataFile(int fileNumber) {
		String extraPeerDataDirPath = node.getExtraPeerDataDir();
		File extraPeerDataPeerDir = new File(extraPeerDataDirPath, getIdentityString());
		if(!extraPeerDataPeerDir.exists()) {
			Logger.error(this, "Extra peer data directory for peer does not exist: "+extraPeerDataPeerDir.getPath());
			return;
		}
		if(!extraPeerDataPeerDir.isDirectory()) {
			Logger.error(this, "Extra peer data directory for peer not a directory: "+extraPeerDataPeerDir.getPath());
			return;
		}
		File extraPeerDataFile = new File(extraPeerDataPeerDir, Integer.toString(fileNumber));
		if(!extraPeerDataFile.exists()) {
			Logger.error(this, "Extra peer data file for peer does not exist: "+extraPeerDataFile.getPath());
			return;
		}
		synchronized(extraPeerDataFileNumbers) {
			extraPeerDataFileNumbers.remove(fileNumber);
		}
		if(!extraPeerDataFile.delete()) {
			if(extraPeerDataFile.exists()) {
				Logger.error(this, "Cannot delete file "+extraPeerDataFile+" after sending message to "+getPeer()+" - it may be resent on resting the node");
			} else {
				Logger.normal(this, "File does not exist when deleting: "+extraPeerDataFile+" after sending message to "+getPeer());
			}
		}
	}

	public void removeExtraPeerDataDir() {
		String extraPeerDataDirPath = node.getExtraPeerDataDir();
		File extraPeerDataPeerDir = new File(extraPeerDataDirPath+File.separator+getIdentityString());
		if(!extraPeerDataPeerDir.exists()) {
			Logger.error(this, "Extra peer data directory for peer does not exist: "+extraPeerDataPeerDir.getPath());
			return;
		}
		if(!extraPeerDataPeerDir.isDirectory()) {
			Logger.error(this, "Extra peer data directory for peer not a directory: "+extraPeerDataPeerDir.getPath());
			return;
		}
		Integer[] localFileNumbers;
		synchronized(extraPeerDataFileNumbers) {
			localFileNumbers = extraPeerDataFileNumbers.toArray(new Integer[extraPeerDataFileNumbers.size()]);
		}
		for (Integer localFileNumber : localFileNumbers) {
			deleteExtraPeerDataFile(localFileNumber);
		}
		extraPeerDataPeerDir.delete();
	}

	public boolean rewriteExtraPeerDataFile(SimpleFieldSet fs, int extraPeerDataType, int fileNumber) {
		String extraPeerDataDirPath = node.getExtraPeerDataDir();
		if(extraPeerDataType > 0)
			fs.putOverwrite("extraPeerDataType", Integer.toString(extraPeerDataType));
		File extraPeerDataPeerDir = new File(extraPeerDataDirPath+File.separator+getIdentityString());
		if(!extraPeerDataPeerDir.exists()) {
			Logger.error(this, "Extra peer data directory for peer does not exist: "+extraPeerDataPeerDir.getPath());
			return false;
		}
		if(!extraPeerDataPeerDir.isDirectory()) {
			Logger.error(this, "Extra peer data directory for peer not a directory: "+extraPeerDataPeerDir.getPath());
			return false;
		}
		File extraPeerDataFile = new File(extraPeerDataDirPath+File.separator+getIdentityString()+File.separator+fileNumber);
		if(!extraPeerDataFile.exists()) {
			Logger.error(this, "Extra peer data file for peer does not exist: "+extraPeerDataFile.getPath());
			return false;
		}
		String f = extraPeerDataFile.getPath();
		FileOutputStream fos;
		try {
			fos = new FileOutputStream(f);
		} catch (FileNotFoundException e2) {
			Logger.error(this, "Cannot write extra peer data file to disk: Cannot open "
					+ f + " - " + e2, e2);
			return false;
		}
		OutputStreamWriter w = new OutputStreamWriter(fos, StandardCharsets.UTF_8);
		BufferedWriter bw = new BufferedWriter(w);
		try {
			fs.writeTo(bw);
			bw.close();
		} catch (IOException e) {
			try {
				fos.close();
			} catch (IOException e1) {
				Logger.error(this, "Cannot close extra peer data file: "+e, e);
			}
			Logger.error(this, "Cannot write file: " + e, e);
			return false;
		}
		return true;
	}

	public synchronized String getPrivateDarknetCommentNote() {
		return privateDarknetComment;
	}

	public synchronized void setPrivateDarknetCommentNote(String comment) {
		int localFileNumber;
		privateDarknetComment = comment;
		localFileNumber = privateDarknetCommentFileNumber;
		SimpleFieldSet fs = new SimpleFieldSet(true);
		fs.put("peerNoteType", Node.PEER_NOTE_TYPE_PRIVATE_DARKNET_COMMENT);
		fs.putSingle("privateDarknetComment", Base64.encodeUTF8(comment));
		if(localFileNumber == -1) {
			localFileNumber = writeNewExtraPeerDataFile(fs, Node.EXTRA_PEER_DATA_TYPE_PEER_NOTE);
			privateDarknetCommentFileNumber = localFileNumber;
		} else {
			rewriteExtraPeerDataFile(fs, Node.EXTRA_PEER_DATA_TYPE_PEER_NOTE, localFileNumber);
		}
	}

	@Override
	public int queueN2NM(SimpleFieldSet fs) {
		int fileNumber = writeNewExtraPeerDataFile( fs, Node.EXTRA_PEER_DATA_TYPE_QUEUED_TO_SEND_N2NM);
		synchronized(queuedToSendN2NMExtraPeerDataFileNumbers) {
			queuedToSendN2NMExtraPeerDataFileNumbers.add(fileNumber);
		}
		return fileNumber;
	}

	public void unqueueN2NM(int fileNumber) {
		synchronized(queuedToSendN2NMExtraPeerDataFileNumbers) {
			queuedToSendN2NMExtraPeerDataFileNumbers.add(fileNumber);
		}
		deleteExtraPeerDataFile(fileNumber);
	}

	public void sendQueuedN2NMs() {
		if(logMINOR)
			Logger.minor(this, "Sending queued N2NMs for "+shortToString());
		Integer[] localFileNumbers;
		synchronized(queuedToSendN2NMExtraPeerDataFileNumbers) {
			localFileNumbers = queuedToSendN2NMExtraPeerDataFileNumbers.toArray(new Integer[queuedToSendN2NMExtraPeerDataFileNumbers.size()]);
		}
		Arrays.sort(localFileNumbers);
		for (Integer localFileNumber : localFileNumbers) {
			rereadExtraPeerDataFile(localFileNumber);
		}
	}

	@Override
	void startARKFetcher() {
		synchronized(this) {
			if(isListenOnly) {
				Logger.minor(this, "Not starting ark fetcher for "+this+" as it's in listen-only mode.");
				return;
			}
		}
		super.startARKFetcher();
	}

	@Override
	public String getTMCIPeerInfo() {
		return getName()+'\t'+super.getTMCIPeerInfo();
	}

	/**
	 * A method to be called once at the beginning of every time isConnected() is true
	 */
	@Override
	protected void onConnect() {
		super.onConnect();
		sendQueuedN2NMs();
	}

	// File transfer offers
	// FIXME this should probably be somewhere else, along with the N2NM stuff... but where?
	// FIXME this should be persistent across node restarts

	/** Files I have offered to this peer */
	private final HashMap<Long, FileOffer> myFileOffersByUID = new HashMap<Long, FileOffer>();
	/** Files this peer has offered to me */
	private final HashMap<Long, FileOffer> hisFileOffersByUID = new HashMap<Long, FileOffer>();

	private void storeOffers() {
		// FIXME do something
	}

	// FIXME refactor this. We want to be able to send file transfers from code that isn't related to fproxy.
	// FIXME and it should be able to talk to plugins on other nodes etc etc.
	// FIXME there are already type fields etc, so this shouldn't be too difficult? But it's not really supported at the moment.
	// FIXME See also e.g. fcp/SendTextMessage.
	class FileOffer {
		final long uid;
		final String filename;
		final String mimeType;
		final String comment;
		/** Only valid if amIOffering == false. Set when start receiving. */
		private File destination;
		private RandomAccessBuffer data;
		final long size;
		/** Who is offering it? True = I am offering it, False = I am being offered it */
		final boolean amIOffering;
		private PartiallyReceivedBulk prb;
		private BulkTransmitter transmitter;
		private BulkReceiver receiver;
		/** True if the offer has either been accepted or rejected */
		private boolean acceptedOrRejected;

		FileOffer(long uid, RandomAccessBuffer data, String filename, String mimeType, String comment) throws IOException {
			this.uid = uid;
			this.data = data;
			this.filename = filename;
			this.mimeType = mimeType;
			this.comment = comment;
			size = data.size();
			amIOffering = true;
		}

		public FileOffer(SimpleFieldSet fs, boolean amIOffering) throws FSParseException {
			uid = fs.getLong("uid");
			size = fs.getLong("size");
			mimeType = fs.get("metadata.contentType");
			filename = FileUtil.sanitize(fs.get("filename"), mimeType);
			destination = null;
			String s = fs.get("comment");
			if(s != null) {
				try {
					s = Base64.decodeUTF8(s);
				} catch (IllegalBase64Exception e) {
					// Maybe it wasn't encoded? FIXME remove
					Logger.error(this, "Bad Base64 encoding when decoding a private darknet comment SimpleFieldSet", e);
				}
			}
			comment = s;
			this.amIOffering = amIOffering;
		}

		public void toFieldSet(SimpleFieldSet fs) {
			fs.put("uid", uid);
			fs.putSingle("filename", filename);
			fs.putSingle("metadata.contentType", mimeType);
			fs.putSingle("comment", Base64.encodeUTF8(comment));
			fs.put("size", size);
		}

		public void accept() {
			acceptedOrRejected = true;
			final String baseFilename = "direct-"+FileUtil.sanitize(getName())+"-"+filename;
			final File dest = node.getClientCore().downloadsDir().file(baseFilename+".part");
			destination = node.getClientCore().downloadsDir().file(baseFilename);
			try {
				data = new FileRandomAccessBuffer(dest, size, false);
			} catch (IOException e) {
				// Impossible
				throw new Error("Impossible: FileNotFoundException opening with RAF with rw! "+e, e);
			}
			prb = new PartiallyReceivedBulk(node.getUSM(), size, Node.PACKET_SIZE, data, false);
			receiver = new BulkReceiver(prb, DarknetPeerNode.this, uid, null);
			// FIXME make this persistent
			node.getExecutor().execute(new Runnable() {
				@Override
				public void run() {
					if(logMINOR)
						Logger.minor(this, "Received file");
					try {
						if(!receiver.receive()) {
							String err = "Failed to receive "+this;
							Logger.error(this, err);
							System.err.println(err);
							onReceiveFailure();
						} else {
							data.close();
							if(!dest.renameTo(node.getClientCore().downloadsDir().file(baseFilename))){
								Logger.error(this, "Failed to rename "+dest.getName()+" to remove .part suffix.");
							}
							onReceiveSuccess();
						}
					} catch (Throwable t) {
						Logger.error(this, "Caught "+t+" receiving file", t);
						onReceiveFailure();
					} finally {
						remove();
					}
					if(logMINOR)
						Logger.minor(this, "Received file");
				}
			}, "Receiver for bulk transfer "+uid+":"+filename);
			sendFileOfferAccepted(uid);
		}

		protected void remove() {
			Long l = uid;
			synchronized(DarknetPeerNode.this) {
				myFileOffersByUID.remove(l);
				hisFileOffersByUID.remove(l);
			}
			data.close();
		}

		public void send() throws DisconnectedException {
			prb = new PartiallyReceivedBulk(node.getUSM(), size, Node.PACKET_SIZE, data, true);
			transmitter = new BulkTransmitter(prb, DarknetPeerNode.this, uid, false, node.getNodeStats().nodeToNodeCounter, false);
			if(logMINOR)
				Logger.minor(this, "Sending "+uid);
			node.getExecutor().execute(new Runnable() {
				@Override
				public void run() {
					if(logMINOR)
						Logger.minor(this, "Sending file");
					try {
						if(!transmitter.send()) {
							String err = "Failed to send "+uid+" for "+FileOffer.this;
							Logger.error(this, err);
							System.err.println(err);
						}
					} catch (Throwable t) {
						Logger.error(this, "Caught "+t+" sending file", t);
						remove();
					}
					if(logMINOR)
						Logger.minor(this, "Sent file");
				}

			}, "Sender for bulk transfer "+uid+":"+filename);
		}

		public void reject() {
			acceptedOrRejected = true;
			sendFileOfferRejected(uid);
		}

		public void onRejected() {
			transmitter.cancel("FileOffer: Offer rejected");
			// FIXME prb's can't be shared, right? Well they aren't here...
			prb.abort(RetrievalException.CANCELLED_BY_RECEIVER, "Cancelled by receiver");
		}

		protected void onReceiveFailure() {
			UserAlert alert = new AbstractUserAlert() {
				@Override
				public String dismissButtonText() {
					return NodeL10n.getBase().getString("UserAlert.hide");
				}
				@Override
				public HTMLNode getHTMLText() {
					HTMLNode div = new HTMLNode("div");

					div.addChild("p", l10n("failedReceiveHeader", new String[] { "filename", "node" },
							new String[] { filename, getName() }));

					// Descriptive table
					describeFile(div);

					return div;
				}

				@Override
				public short getPriorityClass() {
					return UserAlert.MINOR;
				}

				@Override
				public String getText() {
					StringBuilder sb = new StringBuilder();
					sb.append(l10n("failedReceiveHeader", new String[] { "filename", "node" },
							new String[] { filename, getName() }));
					sb.append('\n');
					sb.append(l10n("fileLabel"));
					sb.append(' ');
					sb.append(filename);
					sb.append('\n');
					sb.append(l10n("sizeLabel"));
					sb.append(' ');
					sb.append(SizeUtil.formatSize(size));
					sb.append('\n');
					sb.append(l10n("mimeLabel"));
					sb.append(' ');
					sb.append(mimeType);
					sb.append('\n');
					sb.append(l10n("senderLabel"));
					sb.append(' ');
					sb.append(getName());
					sb.append('\n');
					if(comment != null && !comment.isEmpty()) {
						sb.append(l10n("commentLabel"));
						sb.append(' ');
						sb.append(comment);
					}
					return sb.toString();
				}

				@Override
				public String getTitle() {
					return l10n("failedReceiveTitle");
				}

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

				@Override
				public void isValid(boolean validity) {
					// Ignore
				}

				@Override
				public void onDismiss() {
					// Ignore
				}

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

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

				@Override
				public String getShortText() {
					return l10n("failedReceiveShort", new String[] { "filename", "node" }, new String[] { filename, getName() });
				}

			};
			node.getClientCore().getAlerts().register(alert);
		}

		private void onReceiveSuccess() {
			UserAlert alert = new AbstractUserAlert() {
				@Override
				public String dismissButtonText() {
					return NodeL10n.getBase().getString("UserAlert.hide");
				}
				@Override
				public HTMLNode getHTMLText() {
					HTMLNode div = new HTMLNode("div");

					// FIXME localise!!!

					div.addChild("p", l10n("succeededReceiveHeader", new String[] { "filename", "node" },
							new String[] { filename, getName() }));

					// Descriptive table
					describeFile(div);

					return div;
				}

				@Override
				public short getPriorityClass() {
					return UserAlert.MINOR;
				}

				@Override
				public String getText() {
					String header = l10n("succeededReceiveHeader", new String[] { "filename", "node" },
							new String[] { filename, getName() });

					return describeFileText(header);
				}

				@Override
				public String getTitle() {
					return l10n("succeededReceiveTitle");
				}

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

				@Override
				public void isValid(boolean validity) {
					// Ignore
				}

				@Override
				public void onDismiss() {
					// Ignore
				}

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

				@Override
				public boolean userCanDismiss() {
					return true;
				}
				@Override
				public String getShortText() {
					return l10n("succeededReceiveShort", new String[] { "filename", "node" }, new String[] { filename, getName() });
				}

			};
			node.getClientCore().getAlerts().register(alert);
		}

		/** Ask the user whether (s)he wants to download a file from a direct peer */
		public UserAlert askUserUserAlert() {
			return new AbstractUserAlert() {
				@Override
				public String dismissButtonText() {
					return null; // Cannot hide, but can reject
				}
				@Override
				public HTMLNode getHTMLText() {
					HTMLNode div = new HTMLNode("div");

					div.addChild("p", l10n("offeredFileHeader", "name", getName()));

					// Descriptive table
					describeFile(div);

					// Accept/reject form

					// Hopefully we will have a container when this function is called!
					HTMLNode form = node.getClientCore().getToadletContainer().addFormChild(div, "/friends/", "f2fFileOfferAcceptForm");

					// FIXME node_ is inefficient
					form.addChild("input", new String[] { "type", "name" },
							new String[] { "hidden", "node_"+DarknetPeerNode.this.hashCode() });

					form.addChild("input", new String[] { "type", "name", "value" },
							new String[] { "hidden", "id", Long.toString(uid) });

					form.addChild("input", new String[] { "type", "name", "value" },
							new String[] { "submit", "acceptTransfer", l10n("acceptTransferButton") });

					form.addChild("input", new String[] { "type", "name", "value" },
							new String[] { "submit", "rejectTransfer", l10n("rejectTransferButton") });

					return div;
				}
				@Override
				public short getPriorityClass() {
					return UserAlert.MINOR;
				}
				@Override
				public String getText() {
					String header = l10n("offeredFileHeader", "name", getName());
					return describeFileText(header);
				}

				@Override
				public String getTitle() {
					return l10n("askUserTitle");
				}

				@Override
				public boolean isValid() {
					if(acceptedOrRejected) {
						node.getClientCore().getAlerts().unregister(this);
						return false;
					}
					return true;
				}
				@Override
				public void isValid(boolean validity) {
					// Ignore
				}
				@Override
				public void onDismiss() {
					// Ignore
				}
				@Override
				public boolean shouldUnregisterOnDismiss() {
					return false;
				}

				@Override
				public boolean userCanDismiss() {
					return false; // should accept or reject
				}
				@Override
				public String getShortText() {
					return l10n("offeredFileShort", new String[] { "filename", "node" }, new String[] { filename, getName() });
				}
			};

		}
		protected void addComment(HTMLNode node) {
			String[] lines = comment.split("\n");
			for (int i = 0, c = lines.length; i < c; i++) {
				node.addChild("#", lines[i]);
				if(i != lines.length - 1)
					node.addChild("br");
			}
		}

		private String l10n(String key) {
			return NodeL10n.getBase().getString("FileOffer."+key);
		}
		private String l10n(String key, String pattern, String value) {
			return NodeL10n.getBase().getString("FileOffer."+key, pattern, value);
		}
		private String l10n(String key, String[] pattern, String[] value) {
			return NodeL10n.getBase().getString("FileOffer."+key, pattern, value);
		}

		private String describeFileText(String header) {
			StringBuilder sb = new StringBuilder();
			sb.append(header);
			sb.append('\n');
			sb.append(l10n("fileLabel"));
			sb.append(' ');
			sb.append(filename);
			sb.append('\n');
			sb.append(l10n("sizeLabel"));
			sb.append(' ');
			sb.append(SizeUtil.formatSize(size));
			sb.append('\n');
			sb.append(l10n("mimeLabel"));
			sb.append(' ');
			sb.append(mimeType);
			sb.append('\n');
			sb.append(l10n("senderLabel"));
			sb.append(' ');
			sb.append(userToString());
			sb.append('\n');
			if(comment != null && !comment.isEmpty()) {
				sb.append(l10n("commentLabel"));
				sb.append(' ');
				sb.append(comment);
			}
			return sb.toString();
		}

		private void describeFile(HTMLNode div) {
			HTMLNode table = div.addChild("table", "border", "0");
			HTMLNode row = table.addChild("tr");
			row.addChild("td").addChild("#", l10n("fileLabel"));
			row.addChild("td").addChild("#", filename);
			if(destination != null) {
				row = table.addChild("tr");
				row.addChild("td").addChild("#", l10n("fileSavedToLabel"));
				row.addChild("td").addChild("#", destination.getPath());
			}
			row = table.addChild("tr");
			row.addChild("td").addChild("#", l10n("sizeLabel"));
			row.addChild("td").addChild("#", SizeUtil.formatSize(size));
			row = table.addChild("tr");
			row.addChild("td").addChild("#", l10n("mimeLabel"));
			row.addChild("td").addChild("#", mimeType);
			row = table.addChild("tr");
			row.addChild("td").addChild("#", l10n("senderLabel"));
			row.addChild("td").addChild("#", getName());
			row = table.addChild("tr");
			if(comment != null && !comment.isEmpty()) {
				row.addChild("td").addChild("#", l10n("commentLabel"));
				addComment(row.addChild("td"));
			}
		}
	}

	public int sendBookmarkFeed(FreenetURI uri, String name, String description, boolean hasAnActiveLink) {
		long now = System.currentTimeMillis();
		SimpleFieldSet fs = new SimpleFieldSet(true);
		fs.putSingle("URI", uri.toString());
		fs.putSingle("Name", name);
		fs.put("composedTime", now);
		fs.put("hasAnActivelink", hasAnActiveLink);
		if(description != null)
			fs.putSingle("Description", Base64.encodeUTF8(description));
		fs.put("type", Node.N2N_TEXT_MESSAGE_TYPE_BOOKMARK);
		sendNodeToNodeMessage(fs, Node.N2N_MESSAGE_TYPE_FPROXY, true, now, true);
		setPeerNodeStatus(System.currentTimeMillis());
		return getPeerNodeStatus();
	}

	public int sendDownloadFeed(FreenetURI URI, String description) {
		long now = System.currentTimeMillis();
		SimpleFieldSet fs = new SimpleFieldSet(true);
		fs.putSingle("URI", URI.toString());
		fs.put("composedTime", now);
		if(description != null) {
			fs.putSingle("Description", Base64.encodeUTF8(description));
		}
		fs.put("type", Node.N2N_TEXT_MESSAGE_TYPE_DOWNLOAD);
		sendNodeToNodeMessage(fs, Node.N2N_MESSAGE_TYPE_FPROXY, true, now, true);
		setPeerNodeStatus(System.currentTimeMillis());
		return getPeerNodeStatus();
	}

	public int sendTextFeed(String message) {
		long now = System.currentTimeMillis();
		long msgid = Math.abs(node.getRandom().nextLong());
		// split large messages
		int requiredN2nCount = 1 + ((message.length() - 1) / 1024);
		String messagePart;
		for (int i = 0; i < requiredN2nCount; i++) {
			messagePart = message.substring(i * 1024,
							Math.min((i+1) * 1024,
								 message.length()));
			SimpleFieldSet fs = new SimpleFieldSet(true);
			fs.put("type", Node.N2N_TEXT_MESSAGE_TYPE_USERALERT);
			fs.putSingle("text", Base64.encodeUTF8(messagePart));
			fs.put("msgid", msgid);
			fs.put("requiredParts", requiredN2nCount);
			fs.put("partIndex", i);
			// increment compose time to allow sorting messages
			fs.put("composedTime", now + i);
			sendNodeToNodeMessage(fs, Node.N2N_MESSAGE_TYPE_FPROXY, true, now, true);
			this.setPeerNodeStatus(System.currentTimeMillis());
		}
		return getPeerNodeStatus();
	}

	public int sendFileOfferAccepted(long uid) {
		long now = System.currentTimeMillis();
		storeOffers();

		SimpleFieldSet fs = new SimpleFieldSet(true);
		fs.put("type", Node.N2N_TEXT_MESSAGE_TYPE_FILE_OFFER_ACCEPTED);
		fs.put("uid", uid);
		if(logMINOR) {
			Logger.minor(this, "Sending node to node message (file offer accepted):\n"+fs);
		}

		sendNodeToNodeMessage(fs, Node.N2N_MESSAGE_TYPE_FPROXY, true, now, true);
		setPeerNodeStatus(System.currentTimeMillis());
		return getPeerNodeStatus();
	}

	public int sendFileOfferRejected(long uid) {
		long now = System.currentTimeMillis();
		storeOffers();

		SimpleFieldSet fs = new SimpleFieldSet(true);
		fs.put("type", Node.N2N_TEXT_MESSAGE_TYPE_FILE_OFFER_REJECTED);
		fs.put("uid", uid);
		if(logMINOR)
			Logger.minor(this, "Sending node to node message (file offer rejected):\n"+fs);

		sendNodeToNodeMessage(fs, Node.N2N_MESSAGE_TYPE_FPROXY, true, now, true);
		setPeerNodeStatus(System.currentTimeMillis());
		return getPeerNodeStatus();
	}

	private int sendFileOffer(String fnam, String mime, String message, RandomAccessBuffer data) throws IOException {
		long uid = node.getRandom().nextLong();
		long now = System.currentTimeMillis();
		FileOffer fo = new FileOffer(uid, data, fnam, mime, message);
		synchronized(this) {
			myFileOffersByUID.put(uid, fo);
		}
		storeOffers();
		SimpleFieldSet fs = new SimpleFieldSet(true);
		fo.toFieldSet(fs);
		if(logMINOR) {
			Logger.minor(this, "Sending node to node message (file offer):\n"+fs);
		}
		fs.put("type", Node.N2N_TEXT_MESSAGE_TYPE_FILE_OFFER);
		sendNodeToNodeMessage(fs, Node.N2N_MESSAGE_TYPE_FPROXY, true, now, true);
		setPeerNodeStatus(System.currentTimeMillis());
		return getPeerNodeStatus();
	}

	public int sendFileOffer(File file, String message) throws IOException {
		String fnam = file.getName();
		String mime = DefaultMIMETypes.guessMIMEType(fnam, false);
		RandomAccessBuffer data = new FileRandomAccessBuffer(file, true);
		return sendFileOffer(fnam, mime, message, data);
	}

	public int sendFileOffer(HTTPUploadedFile file, String message) throws IOException {
		String fnam = file.getFilename();
		String mime = file.getContentType();
		RandomAccessBuffer data = new ByteArrayRandomAccessBuffer(BucketTools.toByteArray(file.getData()));
		return sendFileOffer(fnam, mime, message, data);
	}

	// handler for sendTextFeed
	public void handleFproxyN2NTM(SimpleFieldSet fs, int fileNumber) {
		String text = null;
		long composedTime = fs.getLong("composedTime", -1);
		long sentTime = fs.getLong("sentTime", -1);
		long receivedTime = fs.getLong("receivedTime", -1);
		try {
			text = Base64.decodeUTF8(fs.get("text"));
		} catch (IllegalBase64Exception e) {
			Logger.error(this, "Bad Base64 encoding when decoding a N2NTM SimpleFieldSet", e);
			return;
		}
		long msgid = fs.getLong("msgid", -1);
		String newText = text;
		List<UserAlert> merged = new ArrayList<>();
		// TODO: extract merging logic into private method
		if (msgid != -1) {
			// merge existing user alerts

			// TODO: This costs time linear to the number
			// of known alerts, so receiving a large text
			// message is quadratic in its size! Worst
			// case cost for transfer of a single huge
			// n2ntm (when receiving every second message)
			// is about 0.5 N*N, with N at most
			// 128. However even sending a 64kiB file
			// (DarknetPeerNode.java) does not cause
			// noticeable load on the receiving system.
			synchronized (node.getClientCore().getAlerts()) {
				for (UserAlert userAlert : node.getClientCore().getAlerts().getAlerts()) {
					if (!(userAlert instanceof N2NTMUserAlert)) {
						continue;
					}
					N2NTMUserAlert alert = (N2NTMUserAlert) userAlert;
					if (msgid == alert.getMsgid()) {
						// merge with a duplicate
						if (composedTime == alert.getComposedTime()) {
							String alertText = alert.getMessageText();
							// all known, throw away the existing message
							if (newText.contains(alertText)) {
								merged.add(userAlert);
							// strict subset
							} else if (alertText.contains(newText)) {
								newText = alertText;
								merged.add(userAlert);
							} else if (logMINOR) {
								Logger.minor(this, "failed to merge N2NTMs; there will be at least one duplicate and text might be garbled:\n"+newText+"\n"+alertText);
							}
						}
						// merge a preceding n2ntm
						if (composedTime == alert.getComposedTime() + 1) {
							newText = alert.getMessageText() + newText;
							merged.add(userAlert);
						// merge a succeeding n2ntm
						} else if (composedTime == alert.getComposedTime() - 1) {
							newText = newText + alert.getMessageText();
							merged.add(userAlert);
						}
					}
				}
			}
		}
		int newFileNumber = fileNumber;
		if (!merged.isEmpty()) {
			// update the text
			fs.putOverwrite("text", Base64.encodeUTF8(newText));
			// persist the alert
			if (fs.getInt("n2nType", -1) == -1) { // required to be shown at next startup
				fs.put("n2nType", Node.N2N_MESSAGE_TYPE_FPROXY);
			}
			synchronized(this) {
				newFileNumber = writeNewExtraPeerDataFile( fs, Node.EXTRA_PEER_DATA_TYPE_N2NTM);
				if( newFileNumber == -1 ) {
					Logger.error( this, "Failed to write new N2NTM to extra peer data file for N2NTM sfs"+fs);
					// roll back to avoid losing data
					newFileNumber = fileNumber;
					newText = text;
					merged.clear();
				} else {
					// remove the old persisted alert
					deleteExtraPeerDataFile(fileNumber);
				}
			}
		}
		// show the alert
		N2NTMUserAlert userAlert = new N2NTMUserAlert(this, newText, newFileNumber, composedTime, sentTime, receivedTime, msgid);
		node.getClientCore().getAlerts().register(userAlert);
		// remove the merged alerts
		for (UserAlert alert : merged) {
			node.getClientCore().getAlerts().dismissAlert(alert.hashCode());
		}
	}

	public void handleFproxyFileOffer(SimpleFieldSet fs, int fileNumber) {
		final FileOffer offer;
		try {
			offer = new FileOffer(fs, false);
		} catch (FSParseException e) {
			Logger.error(this, "Could not parse offer: "+e+" on "+this+" :\n"+fs, e);
			return;
		}
		Long u = offer.uid;
		synchronized (this) {
			if (hisFileOffersByUID.containsKey(u))
				return; // Ignore re-advertisement
			hisFileOffersByUID.put(u, offer);
		}

		// Don't persist for now - FIXME
		this.deleteExtraPeerDataFile(fileNumber);

		UserAlert alert = offer.askUserUserAlert();

		node.getClientCore().getAlerts().register(alert);
	}

	public void acceptTransfer(long id) {
		if(logMINOR)
			Logger.minor(this, "Accepting transfer "+id+" on "+this);
		FileOffer fo;
		synchronized(this) {
			fo = hisFileOffersByUID.get(id);
		}
		if(fo == null) {
			Logger.error(this, "Cannot accept transfer "+id+" - does not exist");
			return;
		}
		fo.accept();
	}

	public void rejectTransfer(long id) {
		FileOffer fo;
		synchronized(this) {
			fo = hisFileOffersByUID.remove(id);
		}
		if(fo == null) {
			Logger.error(this, "Cannot accept transfer "+id+" - does not exist");
			return;
		}
		fo.reject();
	}

	public void handleFproxyFileOfferAccepted(SimpleFieldSet fs, int fileNumber) {
		// Don't persist for now - FIXME
		this.deleteExtraPeerDataFile(fileNumber);

		long uid;
		try {
			uid = fs.getLong("uid");
		} catch (FSParseException e) {
			Logger.error(this, "Could not parse offer accepted: "+e+" on "+this+" :\n"+fs, e);
			return;
		}
		if(logMINOR)
			Logger.minor(this, "Offer accepted for "+uid);
		FileOffer fo;
		synchronized(this) {
			fo = (myFileOffersByUID.get(uid));
		}
		if(fo == null) {
			Logger.error(this, "No such offer: "+uid);
			try {
				sendAsync(DMT.createFNPBulkSendAborted(uid), null, node.getNodeStats().nodeToNodeCounter);
			} catch (NotConnectedException e) {
				// Fine by me!
			}
			return;
		}
		try {
			fo.send();
		} catch (DisconnectedException e) {
			Logger.error(this, "Cannot send because node disconnected: "+e+" for "+uid+":"+fo.filename, e);
		}
	}

	public void handleFproxyFileOfferRejected(SimpleFieldSet fs, int fileNumber) {
		// Don't persist for now - FIXME
		this.deleteExtraPeerDataFile(fileNumber);

		long uid;
		try {
			uid = fs.getLong("uid");
		} catch (FSParseException e) {
			Logger.error(this, "Could not parse offer rejected: "+e+" on "+this+" :\n"+fs, e);
			return;
		}

		FileOffer fo;
		synchronized(this) {
			fo = myFileOffersByUID.remove(uid);
		}
		fo.onRejected();
	}

	public void handleFproxyBookmarkFeed(SimpleFieldSet fs, int fileNumber) {
		String name = fs.get("Name");
		String description = null;
		FreenetURI uri = null;
		boolean hasAnActiveLink = fs.getBoolean("hasAnActivelink", false);
		long composedTime = fs.getLong("composedTime", -1);
		long sentTime = fs.getLong("sentTime", -1);
		long receivedTime = fs.getLong("receivedTime", -1);
		try {
			String s = fs.get("Description");
			if(s != null)
				description = Base64.decodeUTF8(s);
			uri = new FreenetURI(fs.get("URI"));
		} catch (MalformedURLException e) {
			Logger.error(this, "Malformed URI in N2NTM Bookmark Feed message");
			return;
		} catch (IllegalBase64Exception e) {
			Logger.error(this, "Bad Base64 encoding when decoding a N2NTM SimpleFieldSet", e);
			return;
		}
		BookmarkFeedUserAlert userAlert = new BookmarkFeedUserAlert(this, name, description, hasAnActiveLink, fileNumber, uri, composedTime, sentTime, receivedTime);
		node.getClientCore().getAlerts().register(userAlert);
	}

	public void handleFproxyDownloadFeed(SimpleFieldSet fs, int fileNumber) {
		FreenetURI uri = null;
		String description = null;
		long composedTime = fs.getLong("composedTime", -1);
		long sentTime = fs.getLong("sentTime", -1);
		long receivedTime = fs.getLong("receivedTime", -1);
		try {
			String s = fs.get("Description");
			if(s != null)
				description = Base64.decodeUTF8(s);
			uri = new FreenetURI(fs.get("URI"));
		} catch (MalformedURLException e) {
			Logger.error(this, "Malformed URI in N2NTM File Feed message");
			return;
		} catch (IllegalBase64Exception e) {
			Logger.error(this, "Bad Base64 encoding when decoding a N2NTM SimpleFieldSet", e);
			return;
		}
		DownloadFeedUserAlert userAlert = new DownloadFeedUserAlert(this, description, fileNumber, uri, composedTime, sentTime, receivedTime);
		node.getClientCore().getAlerts().register(userAlert);
	}

	@Override
	public String userToString() {
		return ""+getPeer()+" : "+getName();
	}

	@Override
	public PeerNodeStatus getStatus(boolean noHeavy) {
		return new DarknetPeerNodeStatus(this, noHeavy);
	}

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

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

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

	@Override
	public void onSuccess(boolean insert, boolean ssk) {
		// Ignore it
	}

	@Override
	public void onRemove() {
		// Do nothing
		// FIXME is there anything we should do?
	}

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

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

	@Override
	public boolean equals(Object o) {
		if(o == this) return true;
		// Only equal to seednode of its own type.
		if(o instanceof DarknetPeerNode) {
			return super.equals(o);
		} else return false;
	}

	@Override
	public final boolean shouldDisconnectAndRemoveNow() {
		return false;
	}

	@Override
	/** Darknet peers clear peerAddedTime on connecting. */
	protected void maybeClearPeerAddedTimeOnConnect() {
		peerAddedTime = 0;  // don't store anymore
	}

	@Override
	/** Darknet nodes *do* export the peer added time. However it gets
	 * cleared on connecting: It is only kept for never-connected peers
	 * so we can see that we haven't had a connection in a long time and
	 * offer to get rid of them. */
	protected boolean shouldExportPeerAddedTime() {
		return true;
	}

	@Override
	protected void maybeClearPeerAddedTimeOnRestart(long now) {
		if((now - peerAddedTime) > DAYS.toMillis(30))
			peerAddedTime = 0;
		if(!neverConnected)
			peerAddedTime = 0;
	}

	// FIXME find a better solution???
	@Override
	public void fatalTimeout() {
		if(node.isStopping()) return;
		Logger.error(this, "Disconnecting from darknet node "+this+" because of fatal timeout", new Exception("error"));
		System.err.println("Your friend node \""+getName()+"\" ("+getPeer()+" version "+getVersion()+") is having severe problems. We have disconnected to try to limit the effect on us. It will reconnect soon.");
		// FIXME post a useralert
		// Disconnect.
		forceDisconnect();
	}

	public synchronized FRIEND_TRUST getTrustLevel() {
		return trustLevel;
	}

	@Override
	public boolean shallWeRouteAccordingToOurPeersLocation(int htl) {
		if(!node.shallWeRouteAccordingToOurPeersLocation(htl)) return false; // Globally disabled
		if(trustLevel == FRIEND_TRUST.LOW) return false;
		return true;
	}

	public void setTrustLevel(FRIEND_TRUST trust) {
		synchronized(this) {
			trustLevel = trust;
		}
		node.getPeers().writePeersDarknetUrgent();
	}

	/** FIXME This should be the worse of our visibility for the peer and that which the peer has told us.
	 * I.e. visibility is reciprocal. */
	public synchronized FRIEND_VISIBILITY getVisibility() {
		// ourVisibility can't be null.
		if(ourVisibility.isStricterThan(theirVisibility)) return ourVisibility;
		return theirVisibility;
	}

	public synchronized FRIEND_VISIBILITY getOurVisibility() {
		return ourVisibility;
	}

	public void setVisibility(FRIEND_VISIBILITY visibility) {
		synchronized(this) {
			if(ourVisibility == visibility) return;
			ourVisibility = visibility;
		}
		node.getPeers().writePeersDarknetUrgent();
		try {
			sendVisibility();
		} catch (NotConnectedException e) {
			Logger.normal(this, "Disconnected while sending visibility update");
		}
	}

	private void sendVisibility() throws NotConnectedException {
		sendAsync(DMT.createFNPVisibility(getOurVisibility().code), null, node.getNodeStats().initialMessagesCtr);
	}

	public void handleVisibility(Message m) {
		FRIEND_VISIBILITY v = FRIEND_VISIBILITY.getByCode(m.getShort(DMT.FRIEND_VISIBILITY));
		if(v == null) {
			Logger.error(this, "Bogus visibility setting from peer "+this+" : code "+m.getShort(DMT.FRIEND_VISIBILITY));
			v = FRIEND_VISIBILITY.NO;
		}
		synchronized(this) {
			if(theirVisibility == v) return;
			theirVisibility = v;
		}
		node.getPeers().writePeersDarknet();
	}

	public synchronized FRIEND_VISIBILITY getTheirVisibility() {
		if(theirVisibility == null) return FRIEND_VISIBILITY.NO;
		return theirVisibility;
	}

	@Override
	boolean dontKeepFullFieldSet() {
		return false;
	}

	private boolean sendingFullNoderef;

	public void sendFullNoderef() {
		synchronized(this) {
			if(sendingFullNoderef) return; // DoS????
			sendingFullNoderef = true;
		}
		try {
			SimpleFieldSet myFullNoderef = node.exportDarknetPublicFieldSet();
			ByteArrayOutputStream baos = new ByteArrayOutputStream();
			DeflaterOutputStream dos = new DeflaterOutputStream(baos);
			try {
				myFullNoderef.writeTo(dos);
				dos.close();
			} catch (IOException e) {
				Logger.error(this, "Impossible: Caught error while writing compressed noderef: "+e, e);
				synchronized(this) {
					sendingFullNoderef = false;
				}
				return;
			}
			byte[] data = baos.toByteArray();
			long uid = node.getFastWeakRandom().nextLong();
			RandomAccessBuffer raf = new ByteArrayRandomAccessBuffer(data);
			PartiallyReceivedBulk prb = new PartiallyReceivedBulk(node.getUSM(), data.length, Node.PACKET_SIZE, raf, true);
			try {
				sendAsync(DMT.createFNPMyFullNoderef(uid, data.length), null, node.getNodeStats().foafCounter);
			} catch (NotConnectedException e1) {
				// Ignore
				synchronized(this) {
					sendingFullNoderef = false;
				}
				return;
			}
			final BulkTransmitter bt;
			try {
				bt = new BulkTransmitter(prb, this, uid, false, node.getNodeStats().foafCounter, false);
			} catch (DisconnectedException e) {
				synchronized(this) {
					sendingFullNoderef = false;
				}
				return;
			}
			node.getExecutor().execute(new Runnable() {

				@Override
				public void run() {
					try {
						bt.send();
					} catch (DisconnectedException e) {
						// :|
					} finally {
						synchronized(DarknetPeerNode.this) {
							sendingFullNoderef = false;
						}
					}
				}

			});
		} catch (RuntimeException e) {
			synchronized(this) {
				sendingFullNoderef = false;
			}
			throw e;
		} catch (Error e) {
			synchronized(this) {
				sendingFullNoderef = false;
			}
			throw e;
		}
	}

	private boolean receivingFullNoderef;

	public void handleFullNoderef(Message m) {
		if(this.dontKeepFullFieldSet()) return;
		long uid = m.getLong(DMT.UID);
		int length = m.getInt(DMT.NODEREF_LENGTH);
		if(length > 8 * 1024) {
			// Way too long!
			return;
		}
		synchronized(this) {
			if(receivingFullNoderef) return; // DoS????
			receivingFullNoderef = true;
		}
		try {
			final byte[] data = new byte[length];
			RandomAccessBuffer raf = new ByteArrayRandomAccessBuffer(data);
			PartiallyReceivedBulk prb = new PartiallyReceivedBulk(node.getUSM(), length, Node.PACKET_SIZE, raf, false);
			final BulkReceiver br = new BulkReceiver(prb, this, uid, node.getNodeStats().foafCounter);
			node.getExecutor().execute(new Runnable() {

				@Override
				public void run() {
					try {
						if(br.receive()) {
							ByteArrayInputStream bais = new ByteArrayInputStream(data);
							InflaterInputStream dis = new InflaterInputStream(bais);
							SimpleFieldSet fs;
							try {
								fs = new SimpleFieldSet(new BufferedReader(new InputStreamReader(dis, StandardCharsets.UTF_8)), false, false);
							} catch (IOException e) {
								synchronized(DarknetPeerNode.this) {
									receivingFullNoderef = false;
								}
								Logger.error(this, "Impossible: "+e, e);
								return;
							}
							try {
								processNewNoderef(fs, false, false, true);
							} catch (FSParseException e) {
								Logger.error(this, "Peer "+DarknetPeerNode.this+" sent bogus full noderef: "+e, e);
								synchronized(DarknetPeerNode.this) {
									receivingFullNoderef = false;
								}
								return;
							}
							synchronized(DarknetPeerNode.this) {
								fullFieldSet = fs;
							}
							node.getPeers().writePeersDarknet();
						} else {
							Logger.error(this, "Failed to receive noderef from "+DarknetPeerNode.this);
						}
					} finally {
						synchronized(DarknetPeerNode.this) {
							receivingFullNoderef = false;
						}
					}
				}
			});
		} catch (RuntimeException e) {
			synchronized(this) {
				receivingFullNoderef = false;
			}
			throw e;
		} catch (Error e) {
			synchronized(this) {
				receivingFullNoderef = false;
			}
			throw e;
		}
	}

	@Override
	protected void sendInitialMessages() {
		super.sendInitialMessages();
		try {
			sendVisibility();
		} catch(NotConnectedException e) {
			Logger.error(this, "Completed handshake with " + getPeer() + " but disconnected: "+e, e);
		}
		if(!dontKeepFullFieldSet()) {
			try {
				sendAsync(DMT.createFNPGetYourFullNoderef(), null, node.getNodeStats().foafCounter);
			} catch (NotConnectedException e) {
				// Ignore
			}
		}
	}

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

    @Override
    public boolean canAcceptAnnouncements() {
	return node.passOpennetRefsThroughDarknet();
    }

    @Override
    protected void writePeers() {
	node.getPeers().writePeers(false);
    }
}
