package net.i2p.router.crypto;

/*
 * free (adj.): unencumbered; not under the control of others
 * Written by jrandom in 2003 and released into the public domain 
 * with no warranty of any kind, either expressed or implied.  
 * It probably won't  make your computer catch on fire, or eat 
 * your children, but it might.  Use at your own risk.
 *
 */

import java.io.IOException;
import java.io.Serializable;
import java.io.Writer;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.atomic.AtomicInteger;

import net.i2p.I2PAppContext;
import net.i2p.crypto.SessionKeyManager;
import net.i2p.crypto.TagSetHandle;
import net.i2p.data.DataHelper;
import net.i2p.data.PublicKey;
import net.i2p.data.SessionKey;
import net.i2p.data.SessionTag;
import net.i2p.util.Log;
import net.i2p.util.SimpleTimer;

/**
 * Implement the session key management, but keep everything in memory (don't write 
 * to disk).  However, this being java, we cannot guarantee that the keys aren't swapped
 * out to disk so this should not be considered secure in that sense.
 *
 * The outbound and inbound sides are completely independent, each with
 * their own keys and tags.
 *
 * For a new session, outbound tags are not considered delivered until an ack is received.
 * Otherwise, the loss of the first message would render all subsequent messages
 * undecryptable. True?
 *
 * For an existing session, outbound tags are immediately considered delivered, and are
 * later revoked if the ack times out. This prevents massive stream slowdown caused by
 * repeated tag delivery after the minimum tag threshold is reached. Included tags
 * pushes messages above the ideal 1956 size by ~2KB and causes excessive fragmentation
 * and padding. As the tags are not seen by the streaming lib, they aren't accounted
 * for in the window size, and one or more of a series of large messages is likely to be dropped,
 * either due to high fragmentation or drop priorites at the tunnel OBEP.
 *
 * For this to work, the minimum tag threshold and tag delivery quanitity defined in
 * GarlicMessageBuilder must be chosen with streaming lib windows sizes in mind.
 * If a single TagSet is not delivered, there will be no stall as long as the
 * current window size is smaller than the minimum tag threshold.
 * Additional TagSets will be sent before the acked tags completely run out. See below.
 * all subsequent messages will fail to decrypt.
 * See ConnectionOptions in streaming for more information.
 *
 * There are large inefficiencies caused by the repeated delivery of tags in a new session.
 * With an initial streaming window size of 6 and 40 tags per delivery, a web server
 * would deliver up to 240 tags (7680 bytes, not including bundled leaseset, etc.)
 * in the first volley of the response.
 *
 * Could the two directions be linked somehow, such that the initial request could
 * contain a key or tags for the response?
 *
 * Should the tag threshold and quantity be adaptive?
 *
 * Todo: Switch to ConcurrentHashMaps and ReadWriteLocks, only get write lock during cleanup
 *
 */
public class TransientSessionKeyManager extends SessionKeyManager {
    private final Log _log;
    /** Map allowing us to go from the targeted PublicKey to the OutboundSession used */
    private final Map<PublicKey, OutboundSession> _outboundSessions;
    /** Map allowing us to go from a SessionTag to the containing TagSet */
    private final Map<SessionTag, TagSet> _inboundTagSets;
    protected final I2PAppContext _context;
    private volatile boolean _alive;
    /** for debugging */
    private final AtomicInteger _rcvTagSetID = new AtomicInteger();
    private final AtomicInteger _sentTagSetID = new AtomicInteger();
    private final int _tagsToSend;
    private final int _lowThreshold;

    /** 
     * Let outbound session tags sit around for this long before expiring them.
     * Inbound tag expiration is set by SESSION_LIFETIME_MAX_MS
     */
    private final static long SESSION_TAG_DURATION_MS = 12 * 60 * 1000;

    /**
     * Keep unused inbound session tags around for this long (a few minutes longer than
     * session tags are used on the outbound side so that no reasonable network lag 
     * can cause failed decrypts)
     *
     * This is also the max idle time for an outbound session.
     */
    private final static long SESSION_LIFETIME_MAX_MS = SESSION_TAG_DURATION_MS + 3 * 60 * 1000;

    /**
     * Time to send more if we are this close to expiration
     */
    private static final long SESSION_TAG_EXPIRATION_WINDOW = 90 * 1000;

    /**
     * a few MB? how about 24 MB!
     * This is the max size of _inboundTagSets.
     */
    public final static int MAX_INBOUND_SESSION_TAGS = 750 * 1000;

    /**
     *  This was 100 since 0.6.1.10 (50 before that). It's important because:
     * <pre>
     *  - Tags are 32 bytes. So it previously added 3200 bytes to an initial message.
     *  - Too many tags adds a huge overhead to short-duration connections
     *    (like http, datagrams, etc.)
     *  - Large messages have a much higher chance of being dropped due to
     *    one of their 1KB fragments being discarded by a tunnel participant.
     *  - This reduces the effective maximum datagram size because the client
     *    doesn't know when tags will be bundled, so the tag size must be
     *    subtracted from the maximum I2NP size or transport limit.
     * </pre>
     *
     *  Issues with too small a value:
     * <pre>
     *  - When tags are sent, a reply leaseset (~1KB) is always bundled.
     *    Maybe don't need to bundle more than every minute or so
     *    rather than every time?
     *  - Does the number of tags (and the threshold of 20) limit the effective
     *    streaming lib window size? Should the threshold and the number of
     *    sent tags be variable based on the message rate?
     * </pre>
     *
     *  We have to be very careful if we implement an adaptive scheme,
     *  since the key manager is per-router, not per-local-dest.
     *  Or maybe that's a bad idea, and we need to move to a per-dest manager.
     *  This needs further investigation.
     *
     *  So a value somewhat higher than the low threshold
     *  seems appropriate.
     *
     *  Use care when adjusting these values. See ConnectionOptions in streaming,
     *  and TransientSessionKeyManager in crypto, for more information.
     *
     *  @since 0.9.2 moved from GarlicMessageBuilder to per-SKM config
     */
    public static final int DEFAULT_TAGS = 40;
    /** ditto */
    public static final int LOW_THRESHOLD = 30;

    /** 
     * The session key manager should only be constructed and accessed through the 
     * application context.  This constructor should only be used by the 
     * appropriate application context itself.
     *
     */
    public TransientSessionKeyManager(I2PAppContext context) {
        this(context, DEFAULT_TAGS, LOW_THRESHOLD);
    }

    /** 
     *  @param tagsToSend how many to send at a time, may be lower or higher than lowThreshold. 1-128
     *  @param lowThreshold below this, send more. 1-128
     *  @since 0.9.2
     */
    public TransientSessionKeyManager(I2PAppContext context, int tagsToSend, int lowThreshold) {
        super(context);
        if (tagsToSend <= 0 || tagsToSend > 128 || lowThreshold <= 0 || lowThreshold > 128)
            throw new IllegalArgumentException();
        _tagsToSend = tagsToSend;
        _lowThreshold = lowThreshold;
        _log = context.logManager().getLog(TransientSessionKeyManager.class);
        _context = context;
        _outboundSessions = new HashMap<PublicKey, OutboundSession>(64);
        _inboundTagSets = new HashMap<SessionTag, TagSet>(128);
        context.statManager().createRateStat("crypto.sessionTagsExpired", "How many tags/sessions are expired?", "Encryption", new long[] { 10*60*1000, 60*60*1000, 3*60*60*1000 });
        context.statManager().createRateStat("crypto.sessionTagsRemaining", "How many tags/sessions are remaining after a cleanup?", "Encryption", new long[] { 10*60*1000, 60*60*1000, 3*60*60*1000 });
         _alive = true;
        _context.simpleTimer2().addEvent(new CleanupEvent(), 60*1000);
    }
    
    @Override
    public void shutdown() {
         _alive = false;
        synchronized (_inboundTagSets) {
            _inboundTagSets.clear();
        }
        synchronized (_outboundSessions) {
            _outboundSessions.clear();
        }
    }

    private class CleanupEvent implements SimpleTimer.TimedEvent {
        public void timeReached() {
            if (!_alive)
                return;
            long beforeExpire = _context.clock().now();
            int expired = aggressiveExpire();
            long expireTime = _context.clock().now() - beforeExpire;
            _context.statManager().addRateData("crypto.sessionTagsExpired", expired, expireTime);
            _context.simpleTimer2().addEvent(this, 60*1000);
        }
    }


    /** TagSet - used only by HTML */
    private Set<TagSet> getInboundTagSets() {
        synchronized (_inboundTagSets) {
            return new HashSet<TagSet>(_inboundTagSets.values());
        }
    }

    /** OutboundSession - used only by HTML */
    private Set<OutboundSession> getOutboundSessions() {
        synchronized (_outboundSessions) {
            return new HashSet<OutboundSession>(_outboundSessions.values());
        }
    }

/****** leftover from when we had the persistent SKM
    protected void setData(Set<TagSet> inboundTagSets, Set<OutboundSession> outboundSessions) {
        if (_log.shouldLog(Log.INFO))
            _log.info("Loading " + inboundTagSets.size() + " inbound tag sets, and " 
                      + outboundSessions.size() + " outbound sessions");
        Map<SessionTag, TagSet> tagSets = new HashMap(inboundTagSets.size());
        for (Iterator<TagSet> iter = inboundTagSets.iterator(); iter.hasNext();) {
            TagSet ts = iter.next();
            for (Iterator<SessionTag> tsIter = ts.getTags().iterator(); tsIter.hasNext();) {
                SessionTag tag = tsIter.next();
                tagSets.put(tag, ts);
            }
        }
        synchronized (_inboundTagSets) {
            _inboundTagSets.clear();
            _inboundTagSets.putAll(tagSets);
        }
        Map<PublicKey, OutboundSession> sessions = new HashMap(outboundSessions.size());
        for (Iterator<OutboundSession> iter = outboundSessions.iterator(); iter.hasNext();) {
            OutboundSession sess = iter.next();
            sessions.put(sess.getTarget(), sess);
        }
        synchronized (_outboundSessions) {
            _outboundSessions.clear();
            _outboundSessions.putAll(sessions);
        }
    }
******/

    /**
     * Retrieve the session key currently associated with encryption to the target,
     * or null if a new session key should be generated.
     *
     * Warning - don't generate a new session if this returns null, it's racy, use getCurrentOrNewKey()
     */
    @Override
    public SessionKey getCurrentKey(PublicKey target) {
        OutboundSession sess = getSession(target);
        if (sess == null) return null;
        long now = _context.clock().now();
        if (sess.getLastUsedDate() < now - SESSION_LIFETIME_MAX_MS) {
            if (_log.shouldLog(Log.INFO))
                _log.info("Expiring old session key established on " 
                          + new Date(sess.getEstablishedDate())
                          + " but not used for "
                          + (now-sess.getLastUsedDate())
                          + "ms with target " + toString(target));
            return null;
        }
        return sess.getCurrentKey();
    }

    /**
     * Retrieve the session key currently associated with encryption to the target.
     * Generates a new session and session key if not previously exising.
     *
     * @return non-null
     * @since 0.9
     */
    @Override
    public SessionKey getCurrentOrNewKey(PublicKey target) {
        synchronized (_outboundSessions) {
            OutboundSession sess = _outboundSessions.get(target);
            if (sess != null) {
                long now = _context.clock().now();
                if (sess.getLastUsedDate() < now - SESSION_LIFETIME_MAX_MS)
                    sess = null;
            }
            if (sess == null) {
                SessionKey key = _context.keyGenerator().generateSessionKey();
                createAndReturnSession(target, key);
                return key;
            }
            return sess.getCurrentKey();
        }
    }

    /**
     * Associate a new session key with the specified target.  Metrics to determine
     * when to expire that key begin with this call.
     *
     * Racy if called after getCurrentKey() to check for a current session;
     * use getCurrentOrNewKey() in that case.
     */
    @Override
    public void createSession(PublicKey target, SessionKey key) {
        createAndReturnSession(target, key);
    }

    /**
     * Same as above but for internal use, returns OutboundSession so we don't have
     * to do a subsequent getSession()
     *
     */
    private OutboundSession createAndReturnSession(PublicKey target, SessionKey key) {
        if (_log.shouldLog(Log.INFO))
            _log.info("New OB session, sesskey: " + key + " target: " + toString(target));
        OutboundSession sess = new OutboundSession(_context, _log, target, key);
        addSession(sess);
        return sess;
    }

    /**
     * Retrieve the next available session tag for identifying the use of the given
     * key when communicating with the target.  If this returns null, no tags are
     * available so ElG should be used with the given key (a new sessionKey should
     * NOT be used)
     *
     */
    @Override
    public SessionTag consumeNextAvailableTag(PublicKey target, SessionKey key) {
        OutboundSession sess = getSession(target);
        if (sess == null) {
            if (_log.shouldLog(Log.WARN))
                _log.warn("No session for " + toString(target));
            return null;
        }
        if (sess.getCurrentKey().equals(key)) {
            SessionTag nxt = sess.consumeNext();
            // logged in OutboundSession
            //if (nxt != null && _log.shouldLog(Log.DEBUG))
            //    _log.debug("OB Tag consumed: " + nxt + " with: " + key);
            return nxt;
        }
        if (_log.shouldLog(Log.WARN))
            _log.warn("Key does not match existing key, no tag");
        return null;
    }

    /**
     *  How many to send, IF we need to.
     *  @return the configured value (not adjusted for current available)
     *  @since 0.9.2
     */
    @Override
    public int getTagsToSend() { return _tagsToSend; };

    /**
     *  @return the configured value
     *  @since 0.9.2
     */
    @Override
    public int getLowThreshold() { return _lowThreshold; };

    /**
     *  @return true if we have less than the threshold or what we have is about to expire
     *  @since 0.9.2
     */
    @Override
    public boolean shouldSendTags(PublicKey target, SessionKey key, int lowThreshold) {
        return getAvailableTags(target, key) < lowThreshold ||
               getAvailableTimeLeft(target, key) < SESSION_TAG_EXPIRATION_WINDOW;
    }

    /**
     * Determine (approximately) how many available session tags for the current target
     * have been confirmed and are available
     *
     */
    @Override
    public int getAvailableTags(PublicKey target, SessionKey key) {
        OutboundSession sess = getSession(target);
        if (sess == null) { return 0; }
        if (sess.getCurrentKey().equals(key)) {
            return sess.availableTags();
        }
        return 0;
    }

    /**
     * Determine how long the available tags will be available for before expiring, in 
     * milliseconds
     */
    @Override
    public long getAvailableTimeLeft(PublicKey target, SessionKey key) {
        OutboundSession sess = getSession(target);
        if (sess == null) { return 0; }
        if (sess.getCurrentKey().equals(key)) {
            long end = sess.getLastExpirationDate();
            if (end <= 0) 
                return 0;
            else
                return end - _context.clock().now();
        }
        return 0; 
    }

    /**
     * Take note of the fact that the given sessionTags associated with the key for
     * encryption to the target have been sent. Whether to use the tags immediately
     * (i.e. assume they will be received) or to wait until an ack, is implementation dependent.
     *
     * Here, we wait for the ack if the session is new, otherwise we use right away.
     * Will this work???
     * If the tags are pipelined sufficiently, it will.
     *
     * @return the TagSetHandle. Caller MUST subsequently call failTags() or tagsAcked()
     * with this handle.
     */
    @Override
    public TagSetHandle tagsDelivered(PublicKey target, SessionKey key, Set<SessionTag> sessionTags) {
        // if this is ever null, this is racy and needs synch
        OutboundSession sess = getSession(target);
        if (sess == null) {
            if (_log.shouldLog(Log.WARN))
                _log.warn("No session for delivered TagSet to target: " + toString(target));
            sess = createAndReturnSession(target, key);
        } else {
            sess.setCurrentKey(key);
        }
        TagSet set = new TagSet(sessionTags, key, _context.clock().now(), _sentTagSetID.incrementAndGet());
        sess.addTags(set);
        if (_log.shouldLog(Log.DEBUG))
            _log.debug("Tags delivered: " + set +
                       " target: " + toString(target) /** + ": " + sessionTags */ );
        return set;
    }

    /**
     * Mark all of the tags delivered to the target up to this point as invalid, since the peer
     * has failed to respond when they should have.  This call essentially lets the system recover
     * from corrupted tag sets and crashes
     *
     * @deprecated unused and rather drastic
     */
    @Override
    @Deprecated
    public void failTags(PublicKey target) {
        removeSession(target);
    }

    /**
     * Mark these tags as invalid, since the peer
     * has failed to ack them in time.
     */
    @Override
    public void failTags(PublicKey target, SessionKey key, TagSetHandle ts) {
        OutboundSession sess = getSession(target);
        if (sess == null) {
            if (_log.shouldLog(Log.WARN))
                _log.warn("No session for failed TagSet: " + ts);
            return;
        }
        if(!key.equals(sess.getCurrentKey())) {
            if (_log.shouldLog(Log.WARN))
                _log.warn("Wrong session key (wanted " + sess.getCurrentKey() + ") for failed TagSet: " + ts);
            return;
        }
        if (_log.shouldLog(Log.WARN))
            _log.warn("TagSet failed: " + ts);
        sess.failTags((TagSet)ts);
    }

    /**
     * Mark these tags as acked, start to use them (if we haven't already)
     * If the set was previously failed, it will be added back in.
     */
    @Override
    public void tagsAcked(PublicKey target, SessionKey key, TagSetHandle ts) {
        OutboundSession sess = getSession(target);
        if (sess == null) {
            if (_log.shouldLog(Log.WARN))
                _log.warn("No session for acked TagSet: " + ts);
            return;
        }
        if(!key.equals(sess.getCurrentKey())) {
            if (_log.shouldLog(Log.WARN))
                _log.warn("Wrong session key (wanted " + sess.getCurrentKey() + ") for acked TagSet: " + ts);
            return;
        }
        if (_log.shouldLog(Log.DEBUG))
            _log.debug("TagSet acked: " + ts);
        sess.ackTags((TagSet)ts);
    }

    /**
     * Accept the given tags and associate them with the given key for decryption
     *
     * @param sessionTags modifiable; NOT copied
     */
    @Override
    public void tagsReceived(SessionKey key, Set<SessionTag> sessionTags) {
        tagsReceived(key, sessionTags, SESSION_LIFETIME_MAX_MS);
    }

    /**
     * Accept the given tags and associate them with the given key for decryption
     *
     * @param sessionTags modifiable; NOT copied
     * @param expire time from now
     * @since 0.9.7
     */
    @Override
    public void tagsReceived(SessionKey key, Set<SessionTag> sessionTags, long expire) {
        TagSet tagSet = new TagSet(sessionTags, key, _context.clock().now() + expire,
                                   _rcvTagSetID.incrementAndGet());
        if (_log.shouldLog(Log.INFO)) {
            _log.info("Received " + tagSet);
            if (_log.shouldLog(Log.DEBUG))
                _log.debug("Tags: " + DataHelper.toString(sessionTags));
        }
        TagSet old = null;
        SessionTag dupTag = null;
        synchronized (_inboundTagSets) {
            for (SessionTag tag : sessionTags) {
                old = _inboundTagSets.put(tag, tagSet);
                if (old != null) {
                    if (!old.getAssociatedKey().equals(tagSet.getAssociatedKey())) {
                        _inboundTagSets.remove(tag);
                        dupTag = tag;
                        break;
                    } else {
                        old = null; // ignore the dup
                    }
                }
            }
        }

        if (old != null) {
            // drop both old and tagSet tags
            synchronized (_inboundTagSets) {
                for (SessionTag tag : old.getTags()) {
                    _inboundTagSets.remove(tag);
                }
                for (SessionTag tag : sessionTags) {
                    _inboundTagSets.remove(tag);
                }
            }

            if (_log.shouldLog(Log.WARN)) {
                _log.warn("Multiple tags matching!  tagSet: " + tagSet + " and old tagSet: " + old + " tag: " + dupTag + "/" + dupTag);
                _log.warn("Earlier tag set creation: " + old + ": key=" + old.getAssociatedKey());
                _log.warn("Current tag set creation: " + tagSet + ": key=" + tagSet.getAssociatedKey());
            }
        }
        
        int overage = _inboundTagSets.size() - MAX_INBOUND_SESSION_TAGS;
        if (overage > 0)
            clearExcess(overage);

        //if ( (sessionTags.isEmpty()) && (_log.shouldLog(Log.DEBUG)) )
        //    _log.debug("Received 0 tags for key " + key);
        //if (false) aggressiveExpire();
    }
    
    /**
     * remove a bunch of arbitrarily selected tags, then drop all of
     * the associated tag sets.  this is very time consuming - iterating
     * across the entire _inboundTagSets map, but it should be very rare,
     * and the stats we can gather can hopefully reduce the frequency of
     * using too many session tags in the future
     *
     */
    private void clearExcess(int overage) {
        long now = _context.clock().now();
        int old = 0;
        int large = 0;
        int absurd = 0;
        int recent = 0;
        int tags = 0;
        int toRemove = overage * 2;
        _log.logAlways(Log.WARN, "TOO MANY SESSION TAGS! Starting cleanup, overage = " + overage);
        List<TagSet> removed = new ArrayList<TagSet>(toRemove);
        synchronized (_inboundTagSets) {
            for (TagSet set : _inboundTagSets.values()) {
                int size = set.getTags().size();
                if (size > 1000)
                    absurd++;
                if (size > 100)
                    large++;
                if (set.getDate() - now < 3*60*1000) {
                    // expiration is 12 minutes, so these are older than 9 minutes
                    old++;
                    removed.add(set);
                    continue;
                } else if (set.getDate() - now > 8*60*1000) {
                    // expiration is 12 minutes, so these were created in last 4 minutes
                    recent++;
                    continue;
                }

                if (removed.size() < toRemove)
                    removed.add(set);
            }
            for (int i = 0; i < removed.size(); i++) {
                TagSet cur = removed.get(i);
                for (SessionTag tag : cur.getTags()) {
                    _inboundTagSets.remove(tag);
                    tags++;
                }
            }
        }
        _log.logAlways(Log.WARN, "TOO MANY SESSION TAGS!  removed " + removed.size() 
                     + " tag sets arbitrarily, with " + tags + " tags,"
                     + "where there are " + old + " long lasting sessions, "
                     + recent + " ones created in the last few minutes, and "
                     + large + " sessions with more than 100 tags (and "
                     + absurd + " with more than 1000!), leaving a total of "
                     + _inboundTagSets.size() + " tags behind");
    }

    /**
     * Determine if we have received a session key associated with the given session tag,
     * and if so, discard it (but keep track for frequent dups) and return the decryption
     * key it was received with (via tagsReceived(...)).  returns null if no session key
     * matches
     *
     */
    @Override
    public SessionKey consumeTag(SessionTag tag) {
        TagSet tagSet;
        synchronized (_inboundTagSets) {
            tagSet = _inboundTagSets.remove(tag);
            if (tagSet == null) {
                if (_log.shouldLog(Log.DEBUG))
                    _log.debug("Cannot consume IB " + tag + " as it is not known");
                return null;
            }
            tagSet.consume(tag);
        }

        SessionKey key = tagSet.getAssociatedKey();
        if (_log.shouldLog(Log.DEBUG))
            _log.debug("IB Tag consumed: " + tag + " from: " + tagSet);
        return key;
    }

    private OutboundSession getSession(PublicKey target) {
        synchronized (_outboundSessions) {
            return _outboundSessions.get(target);
        }
    }

    private void addSession(OutboundSession sess) {
        synchronized (_outboundSessions) {
            _outboundSessions.put(sess.getTarget(), sess);
        }
    }

    private void removeSession(PublicKey target) {
        if (target == null) return;
        OutboundSession session = null;
        synchronized (_outboundSessions) {
            session = _outboundSessions.remove(target);
        }
        if ( (session != null) && (_log.shouldLog(Log.WARN)) )
            _log.warn("Removing session tags with " + session.availableTags() + " available for "
                       + (session.getLastExpirationDate()-_context.clock().now())
                       + "ms more", new Exception("Removed by"));
    }

    /**
     * Aggressively expire inbound tag sets and outbound sessions
     *
     * @return number of tag sets expired (bogus as it overcounts inbound)
     */
    private int aggressiveExpire() {
        int removed = 0;
        int remaining = 0;
        long now = _context.clock().now();

        synchronized (_inboundTagSets) {
            for (Iterator<TagSet> iter = _inboundTagSets.values().iterator(); iter.hasNext();) {
                TagSet ts = iter.next();
                // for inbound tagsets, getDate() is the expire time
                if (ts.getDate() <= now) {
                    iter.remove();
                    // bug, this counts inbound tags, not tag sets
                    removed++;
                }
            }
            remaining = _inboundTagSets.size();
        }
        _context.statManager().addRateData("crypto.sessionTagsRemaining", remaining, 0);
        if (removed > 0 && _log.shouldLog(Log.DEBUG))
            _log.debug("Expired inbound: " + removed);

        synchronized (_outboundSessions) {
            for (Iterator<OutboundSession> iter = _outboundSessions.values().iterator(); iter.hasNext();) {
                OutboundSession sess = iter.next();
                removed += sess.expireTags();
                // don't kill a new session or one that's temporarily out of tags
                if (sess.getLastUsedDate() < now - (SESSION_LIFETIME_MAX_MS / 2) &&
                    sess.availableTags() <= 0) {
                    iter.remove();
                    removed++;   // just to have a non-zero return value?
                }
            }
        }
        return removed;
    }

    @Override
    public void renderStatusHTML(Writer out) throws IOException {
        StringBuilder buf = new StringBuilder(1024);
        buf.append("<h2>Inbound sessions</h2>" +
                   "<table>");
        Set<TagSet> inbound = getInboundTagSets();
        Map<SessionKey, Set<TagSet>> inboundSets = new HashMap<SessionKey, Set<TagSet>>(inbound.size());
        // Build a map of the inbound tag sets, grouped by SessionKey
        for (TagSet ts : inbound) {
            Set<TagSet> sets = inboundSets.get(ts.getAssociatedKey());
            if (sets == null) {
                sets = new HashSet<TagSet>();
                inboundSets.put(ts.getAssociatedKey(), sets);
            }
            sets.add(ts);
        }
        int total = 0;
        long now = _context.clock().now();
        for (Map.Entry<SessionKey, Set<TagSet>> e : inboundSets.entrySet()) {
            SessionKey skey = e.getKey();
            Set<TagSet> sets = new TreeSet<TagSet>(new TagSetComparator());
            sets.addAll(e.getValue());
            buf.append("<tr><td><b>Session key</b>: ").append(skey.toBase64()).append("</td>" +
                       "<td><b># Sets:</b> ").append(sets.size()).append("</td></tr>" +
                       "<tr><td colspan=\"2\"><ul>");
            for (TagSet ts : sets) {
                int size = ts.getTags().size();
                total += size;
                buf.append("<li><b>ID: ").append(ts.getID());
                long expires = ts.getDate() - now;
                if (expires > 0)
                    buf.append(" Expires in:</b> ").append(DataHelper.formatDuration2(expires)).append(" with ");
                else
                    buf.append(" Expired:</b> ").append(DataHelper.formatDuration2(0 - expires)).append(" ago with ");
                buf.append(size).append('/').append(ts.getOriginalSize()).append(" tags remaining</li>");
            }
            buf.append("</ul></td></tr>\n");
            out.write(buf.toString());
            buf.setLength(0);
        }
        buf.append("<tr><th colspan=\"2\">Total tags: ").append(total).append(" (");
        buf.append(DataHelper.formatSize2(32*total)).append("B)</th></tr>\n" +
                   "</table>" +
                   "<h2><b>Outbound sessions</b></h2>" +
                   "<table>");
        total = 0;
        Set<OutboundSession> outbound = getOutboundSessions();
        for (Iterator<OutboundSession> iter = outbound.iterator(); iter.hasNext();) {
            OutboundSession sess = iter.next();
            Set<TagSet> sets = new TreeSet<TagSet>(new TagSetComparator());
            sets.addAll(sess.getTagSets());
            buf.append("<tr><td><b>Target public key:</b> ").append(toString(sess.getTarget())).append("<br>" +
                       "<b>Established:</b> ").append(DataHelper.formatDuration2(now - sess.getEstablishedDate())).append(" ago<br>" +
                       "<b>Ack Received?</b> ").append(sess.getAckReceived()).append("<br>" +
                       "<b>Last Used:</b> ").append(DataHelper.formatDuration2(now - sess.getLastUsedDate())).append(" ago<br>" +
                       "<b>Session key:</b> ").append(sess.getCurrentKey().toBase64()).append("</td>" +
                       "<td><b># Sets:</b> ").append(sess.getTagSets().size()).append("</td></tr>" +
                       "<tr><td colspan=\"2\"><ul>");
            for (Iterator<TagSet> siter = sets.iterator(); siter.hasNext();) {
                TagSet ts = siter.next();
                int size = ts.getTags().size();
                total += size;
                buf.append("<li><b>ID: ").append(ts.getID())
                   .append(" Sent:</b> ").append(DataHelper.formatDuration2(now - ts.getDate())).append(" ago with ");
                buf.append(size).append('/').append(ts.getOriginalSize()).append(" tags remaining; acked? ").append(ts.getAcked()).append("</li>");
            }
            buf.append("</ul></td></tr>\n");
            out.write(buf.toString());
            buf.setLength(0);
        }
        buf.append("<tr><th colspan=\"2\">Total tags: ").append(total).append(" (");
        buf.append(DataHelper.formatSize2(32*total)).append("B)</th></tr>\n" +
                   "</table>");

        out.write(buf.toString());
    }

    /**
     *  For debugging
     *  @since 0.9
     */
    private static String toString(PublicKey target) {
        if (target == null)
            return "null";
        return target.toBase64().substring(0, 20) + "...";
    }

    /**
     *  Just for the HTML method above so we can see what's going on easier
     *  Earliest first
     */
    private static class TagSetComparator implements Comparator<TagSet>, Serializable {
         public int compare(TagSet l, TagSet r) {
             int rv = (int) (l.getDate() - r.getDate());
             if (rv != 0)
                 return rv;
             return l.hashCode() - r.hashCode();
        }
    }

    /**
     *  The state for a crypto session to a single public key
     */
    private static class OutboundSession {
        private final I2PAppContext _context;
        private final Log _log;
        private final PublicKey _target;
        private SessionKey _currentKey;
        private final long _established;
        private long _lastUsed;
        /**
         *  Before the first ack, all tagsets go here. These are never expired, we rely
         *  on the callers to call failTags() or ackTags() to remove them from this list.
         *  Actually we now do a failsafe expire.
         *  Synch on _tagSets to access this.
         *  No particular order.
         */
        private final Set<TagSet> _unackedTagSets;
        /**
         *  As tagsets are acked, they go here.
         *  After the first ack, new tagsets go here (i.e. presumed acked)
         *  In order, earliest first.
         */
        private final List<TagSet> _tagSets;
        /**
         *  Set to true after first tagset is acked.
         *  Upon repeated failures, we may revert back to false.
         *  This prevents us getting "stuck" forever, using tags that weren't acked
         *  to deliver the next set of tags.
         */
        private volatile boolean _acked;
        /**
         *  Fail count
         *  Synch on _tagSets to access this.
         */
        private int _consecutiveFailures;

        private static final int MAX_FAILS = 2;

        public OutboundSession(I2PAppContext ctx, Log log, PublicKey target, SessionKey key) {
            _context = ctx;
            _log = log;
            _target = target;
            _currentKey = key;
            _established = ctx.clock().now();
            _lastUsed = _established;
            _unackedTagSets = new HashSet<TagSet>(4);
            _tagSets = new ArrayList<TagSet>(6);
        }

        /**
         *  @return list of TagSet objects
         *  This is used only by renderStatusHTML().
         *  It includes both acked and unacked TagSets.
         */
        List<TagSet> getTagSets() {
            List<TagSet> rv;
            synchronized (_tagSets) {
                rv = new ArrayList<TagSet>(_unackedTagSets);
                rv.addAll(_tagSets);
            }
            return rv;
        }

        /**
         *  got an ack for these tags
         *  For tagsets delivered after the session was acked, this is a nop
         *  because the tagset was originally placed directly on the acked list.
         *  If the set was previously failed, it will be added back in.
         */
        void ackTags(TagSet set) {
            synchronized (_tagSets) {
                if (_unackedTagSets.remove(set)) {
                    // we could perhaps use it even if not previuosly in unacked,
                    // i.e. it was expired already, but _tagSets is a list not a set...
                    _tagSets.add(set);
                } else if (!_tagSets.contains(set)) {
                    // add back (sucess after fail)
                    _tagSets.add(set);
                    if (_log.shouldLog(Log.WARN))
                        _log.warn("Ack of unknown (previously failed?) tagset: " + set);
                } else if (set.getAcked()) {
                    if (_log.shouldLog(Log.WARN))
                        _log.warn("Dup ack of tagset: " + set);
                }
                _acked = true;
                _consecutiveFailures = 0;
            }
            set.setAcked();
        }

        /** didn't get an ack for these tags */
        void failTags(TagSet set) {
            synchronized (_tagSets) {
                _unackedTagSets.remove(set);
                if (_tagSets.remove(set)) {
                    if (++_consecutiveFailures >= MAX_FAILS) {
                        // revert back to non-speculative ack mode,
                        // and force full ElG next time by reclassifying all tagsets that weren't really acked
                        _acked = false;
                        int acked = 0;
                        int unacked = 0;
                        for (Iterator<TagSet> iter = _tagSets.iterator(); iter.hasNext(); ) {
                            TagSet ts = iter.next();
                            if (!ts.getAcked()) {
                                iter.remove();
                                _unackedTagSets.add(ts);
                                unacked++;
                            } else {
                                acked++;
                            }
                        }
                        if (_log.shouldLog(Log.WARN))
                            _log.warn(_consecutiveFailures + " consecutive failed tagset deliveries to " + _currentKey
                                      + ": reverting to full ElG and un-acking " + unacked + " unacked tag sets, with "
                                      + acked + " remaining acked tag sets");
                    }
                }
            }
        }

        public PublicKey getTarget() {
            return _target;
        }

        public SessionKey getCurrentKey() {
            return _currentKey;
        }

        public void setCurrentKey(SessionKey key) {
            _lastUsed = _context.clock().now();
            if (_currentKey != null) {
                if (!_currentKey.equals(key)) {
                    synchronized (_tagSets) {
                        if (_log.shouldLog(Log.WARN)) {
                            int dropped = 0;
                            for (TagSet set : _tagSets) {
                                dropped += set.getTags().size();
                            }
                            _log.warn("Rekeyed from " + _currentKey + " to " + key 
                                      + ": dropping " + dropped + " session tags", new Exception());
                        }
                        _acked = false;
                        _tagSets.clear();
                    }
                }
            }
            _currentKey = key;

        }

        public long getEstablishedDate() {
            return _established;
        }

        public long getLastUsedDate() {
            return _lastUsed;
        }

        /**
         * Expire old tags, returning the number of tag sets removed
         */
        public int expireTags() {
            long now = _context.clock().now();
            int removed = 0;
            synchronized (_tagSets) {
                for (Iterator<TagSet> iter = _tagSets.iterator(); iter.hasNext(); ) {
                    TagSet set = iter.next();
                    if (set.getDate() + SESSION_TAG_DURATION_MS <= now) {
                        iter.remove();
                        removed++;
                    }
                }
                // failsafe, sometimes these are sticking around, not sure why, so clean them periodically
                if ((now & 0x0f) == 0) {
                    for (Iterator<TagSet> iter = _unackedTagSets.iterator(); iter.hasNext(); ) {
                        TagSet set = iter.next();
                        if (set.getDate() + SESSION_TAG_DURATION_MS <= now) {
                            iter.remove();
                            removed++;
                        }
                    }
                }
            }
            return removed;
        }

        public SessionTag consumeNext() {
            long now = _context.clock().now();
            _lastUsed = now;
            synchronized (_tagSets) {
                while (!_tagSets.isEmpty()) {
                    TagSet set = _tagSets.get(0);
                    if (set.getDate() + SESSION_TAG_DURATION_MS > now) {
                        SessionTag tag = set.consumeNext();
                        if (tag != null) {
                            if (_log.shouldLog(Log.DEBUG))
                                _log.debug("OB Tag consumed: " + tag + " from: " + set);
                            return tag;
                        } else if (_log.shouldLog(Log.INFO)) {
                            _log.info("Removing empty " + set);
                        }
                    } else {
                        if (_log.shouldLog(Log.INFO))
                            _log.info("Expired " + set);
                    }
                    _tagSets.remove(0);
                }
            }
            return null;
        }

        /** @return the total number of tags in acked TagSets */
        public int availableTags() {
            int tags = 0;
            long now = _context.clock().now();
            synchronized (_tagSets) {
                for (int i = 0; i < _tagSets.size(); i++) {
                    TagSet set = _tagSets.get(i);
                    if (set.getDate() + SESSION_TAG_DURATION_MS > now) {
                        int sz = set.getTags().size();
                        // so tags are sent when the acked tags are below
                        // 30, 17, and 4.
                        if (!set.getAcked())
                            // round up so we don't report 0 when we have 1 or 2 remaining and get the session removed
                            sz = (sz + 2) / 3;
                        tags += sz;
                    }
                }
            }
            return tags;
        }

        /**
         * Get the furthest away tag set expiration date - after which all of the  
         * tags will have expired
         *
         */
        public long getLastExpirationDate() {
            long last = 0;
            synchronized (_tagSets) {
                for (TagSet set : _tagSets) {
                    if ( (set.getDate() > last) && (!set.getTags().isEmpty()) ) 
                        last = set.getDate();
                }
            }
            if (last > 0)
                return last + SESSION_TAG_DURATION_MS;
            else
                return -1;
        }

        /**
         *  If the session has never been acked, put the TagSet on the unacked list.
         *  Otherwise, consider it good right away.
         */
        public void addTags(TagSet set) {
            _lastUsed = _context.clock().now();
            synchronized (_tagSets) {
                if (_acked)
                    _tagSets.add(set);
                else
                    _unackedTagSets.add(set);
            }
        }

        /** @since 0.9 for debugging */
        public boolean getAckReceived() {
            return _acked;
        }
    }

    private static class TagSet implements TagSetHandle {
        private final Set<SessionTag> _sessionTags;
        private final SessionKey _key;
        private final long _date;
        private final int _id;
        private final int _origSize;
        //private Exception _createdBy;
        /** did we get an ack for this tagset? Only for outbound tagsets */
        private boolean _acked;

        /**
         *  @param date For inbound: when the TagSet will expire; for outbound: creation time
         */
        public TagSet(Set<SessionTag> tags, SessionKey key, long date, int id) {
            if (key == null) throw new IllegalArgumentException("Missing key");
            if (tags == null) throw new IllegalArgumentException("Missing tags");
            _sessionTags = tags;
            _key = key;
            _date = date;
            _id = id;
            _origSize = tags.size();
            //if (true) {
            //    long now = I2PAppContext.getGlobalContext().clock().now();
            //    _createdBy = new Exception("Created by: key=" + _key.toBase64() + " on " 
            //                               + new Date(now) + "/" + now 
            //                               + " via " + Thread.currentThread().getName());
            //}
        }

        /**
         *  For inbound: when the TagSet will expire; for outbound: creation time
         */
        public long getDate() {
            return _date;
        }

        /** @since 0.9.3 for debugging */
        public long getOriginalSize() {
            return _origSize;
        }

        //void setDate(long when) {
        //    _date = when;
        //}

        /** tags still available */
        public Set<SessionTag> getTags() {
            return _sessionTags;
        }

        public SessionKey getAssociatedKey() {
            return _key;
        }

        /**
         *  Caller must synch.
         */
        public void consume(SessionTag tag) {
            _sessionTags.remove(tag);
        }

        /**
         *  For outbound only.
         *  Caller must synch.
         *  @return a tag or null
         */
        public SessionTag consumeNext() {
            Iterator<SessionTag> iter = _sessionTags.iterator();
            if (!iter.hasNext())
                return null;
            SessionTag first = iter.next();
            iter.remove();
            return first;
        }
        
        //public Exception getCreatedBy() { return _createdBy; }

        /**
         *  For outbound only.
         */
        public void setAcked() { _acked = true; }

        /**
         *  For outbound only.
         */
        public boolean getAcked() { return _acked; }
        
/******    this will return a dup if two in the same ms, so just use java
        @Override
        public int hashCode() {
            long rv = 0;
            if (_key != null) rv = _key.hashCode();
            rv = rv * 7 + _date;
            // no need to hashCode the tags, key + date should be enough
            return (int) rv;
        }
        
        @Override
        public boolean equals(Object o) {
            if ((o == null) || !(o instanceof TagSet)) return false;
            TagSet ts = (TagSet) o;
            return DataHelper.eq(ts.getAssociatedKey(), _key) 
                   //&& DataHelper.eq(ts.getTags(), getTags())
                   && ts.getDate() == _date;
        }
******/

        /** @since 0.9 for debugging */
        public int getID() {
            return _id;
        }

        @Override
        public String toString() {
            StringBuilder buf = new StringBuilder(256);
            buf.append("TagSet #").append(_id).append(" created: ").append(new Date(_date));
            buf.append(" Session key: ").append(_key);
            buf.append(" Size: ").append(_sessionTags.size());
            buf.append('/').append(_origSize);
            buf.append(" Acked? ").append(_acked);
            return buf.toString();
        }
    }
}
