package net.i2p.router.web;

import java.io.IOException;
import java.io.Serializable;
import java.io.Writer;
import java.math.BigInteger;
import java.text.Collator;
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;

import net.i2p.data.DataHelper;
import net.i2p.data.Destination;
import net.i2p.data.Hash;
import net.i2p.data.LeaseSet;
import net.i2p.data.router.RouterAddress;
import net.i2p.data.router.RouterInfo;
import net.i2p.data.router.RouterKeyGenerator;
import net.i2p.kademlia.XORComparator;
import net.i2p.router.RouterContext;
import net.i2p.router.TunnelPoolSettings;
import net.i2p.router.crypto.FamilyKeyCrypto;
import net.i2p.router.peermanager.DBHistory;
import net.i2p.router.peermanager.PeerProfile;
import net.i2p.router.tunnel.pool.TunnelPool;
import net.i2p.router.util.HashDistance;
import net.i2p.stat.Rate;
import net.i2p.stat.RateAverages;
import net.i2p.stat.RateStat;
import net.i2p.util.ConvertToHash;
import net.i2p.util.Log;
import net.i2p.util.ObjectCounter;
import net.i2p.util.Translate;
import net.i2p.util.VersionComparator;

/**
 *  For debugging only.
 *  Parts may later move to router as a periodic monitor.
 *  Adapted from NetDbRenderer.
 *
 *  @since 0.9.24
 *
 */
class SybilRenderer {

    private final RouterContext _context;
    private final DecimalFormat fmt = new DecimalFormat("#0.00");

    private static final int PAIRMAX = 20;
    private static final int MAX = 10;
    // multiplied by size - 1, will also get POINTS24 added
    private static final double POINTS32 = 5.0;
    // multiplied by size - 1, will also get POINTS16 added
    private static final double POINTS24 = 5.0;
    // multiplied by size - 1
    private static final double POINTS16 = 0.25;
    private static final double POINTS_US32 = 25.0;
    private static final double POINTS_US24 = 25.0;
    private static final double POINTS_US16 = 10.0;
    private static final double POINTS_FAMILY = -2.0;
    private static final double POINTS_BAD_OUR_FAMILY = 100.0;
    private static final double POINTS_OUR_FAMILY = -100.0;
    private static final double MIN_CLOSE = 242.0;
    private static final double PAIR_DISTANCE_FACTOR = 2.0;
    private static final double OUR_KEY_FACTOR = 4.0;
    private static final double MIN_DISPLAY_POINTS = 5.0;
    private static final double VERSION_FACTOR = 1.0;
    private static final double POINTS_BAD_VERSION = 50.0;
    private static final double POINTS_UNREACHABLE = 4.0;
    private static final double POINTS_NEW = 4.0;
    private static final double POINTS_BANLIST = 25.0;

    public SybilRenderer(RouterContext ctx) {
        _context = ctx;
    }

    /**
     *   Entry point
     */
    public String getNetDbSummary(Writer out) throws IOException {
        renderRouterInfoHTML(out, (String)null);
        return "";
    }

    private static class RouterInfoRoutingKeyComparator implements Comparator<RouterInfo>, Serializable {
         private final Hash _us;
         /** @param us ROUTING KEY */
         public RouterInfoRoutingKeyComparator(Hash us) {
             _us = us;
         }
         public int compare(RouterInfo l, RouterInfo r) {
             return HashDistance.getDistance(_us, l.getHash()).compareTo(HashDistance.getDistance(_us, r.getHash()));
        }
    }

    /**
     *  A total score and a List of reason Strings
     */
    private static class Points implements Comparable<Points> {
         private double points;
         private final List<String> reasons;
         /** @param us ROUTING KEY */
         public Points(double points, String reason) {
             this.points = points;
             reasons = new ArrayList<String>(4);
             reasons.add(reason);
         }
         public int compareTo(Points r) {
             if (points > r.points)
                 return 1;
             if (points < r.points)
                 return -1;
             return 0;
        }
    }

    private static class PointsComparator implements Comparator<Hash>, Serializable {
         private final Map<Hash, Points> _points;
         /** @param us ROUTING KEY */
         public PointsComparator(Map<Hash, Points> points) {
             _points = points;
         }
         public int compare(Hash l, Hash r) {
             // reverse
             return _points.get(r).compareTo(_points.get(l));
        }
    }

    private void addPoints(Map<Hash, Points> points, Hash h, double d, String reason) {
        Points dd = points.get(h);
        if (dd != null) {
            dd.points += d;
            dd.reasons.add("<b>" + fmt.format(d) + ":</b> " + reason);
        } else {
            points.put(h, new Points(d, "<b>" + fmt.format(d) + ":</b> " + reason));
        }
    }

    /**
     *  The whole thing
     *
     *  @param routerPrefix ignored
     */
    private void renderRouterInfoHTML(Writer out, String routerPrefix) throws IOException {
        Set<Hash> ffs = _context.peerManager().getPeersByCapability('f');
        List<RouterInfo> ris = new ArrayList<RouterInfo>(ffs.size());
        Hash us = _context.routerHash();
        Hash ourRKey = _context.router().getRouterInfo().getRoutingKey();
        for (Hash ff : ffs) {
             if (ff.equals(us))
                 continue;
             RouterInfo ri = _context.netDb().lookupRouterInfoLocally(ff);
             if (ri != null)
                 ris.add(ri);
        }
        if (ris.isEmpty()) {
            out.write("<h3>No known floodfills</h3>");
            return;
        }

        StringBuilder buf = new StringBuilder(4*1024);
        buf.append("<p><b>This is an experimental network database tool for debugging and analysis. Do not panic even if you see warnings below. " +
                   "Possible \"threats\" are summarized at the bottom, however these are unlikely to be real threats. " +
                   "If you see anything you would like to discuss with the devs, contact us on IRC #i2p-dev.</b></p>" +
                   "<ul><li><a href=\"#known\">FF Summary</a>" +
                   "</li><li><a href=\"#family\">Same Family</a>" +
                   "</li><li><a href=\"#ourIP\">IP close to us</a>" +
                   "</li><li><a href=\"#sameIP\">Same IP</a>" +
                   "</li><li><a href=\"#same24\">Same /24</a>" +
                   "</li><li><a href=\"#same16\">Same /16</a>" +
                   "</li><li><a href=\"#pairs\">Pair distance</a>" +
                   "</li><li><a href=\"#ritoday\">Close to us</a>" +
                   "</li><li><a href=\"#ritmrw\">Close to us tomorrow</a>" +
                   "</li><li><a href=\"#dht\">DHT neighbors</a>" +
                   "</li><li><a href=\"#dest\">Close to our destinations</a>" +
                   "</li><li><a href=\"#threats\">Highest threats</a>" +
                   "</li></ul>");

        renderRouterInfo(buf, _context.router().getRouterInfo(), null, true, false);
        buf.append("<h3 id=\"known\">Known Floodfills: ").append(ris.size()).append("</h3>");

        double tot = 0;
        int count = 200;
        byte[] b = new byte[32];
        for (int i = 0; i < count; i++) {
            _context.random().nextBytes(b);
            Hash h = new Hash(b);
            double d = closestDistance(h, ris);
            tot += d;
        }
        double avgMinDist = tot / count;
        buf.append("<p>Average closest floodfill distance: " + fmt.format(avgMinDist) + "</p>");
        buf.append("<p>Routing Data: \"").append(DataHelper.getUTF8(_context.routerKeyGenerator().getModData()))
           .append("\" Last Changed: ").append(new Date(_context.routerKeyGenerator().getLastChanged()));
        buf.append("</p><p>Next Routing Data: \"").append(DataHelper.getUTF8(_context.routerKeyGenerator().getNextModData()))
           .append("\" Rotates in: ").append(DataHelper.formatDuration(_context.routerKeyGenerator().getTimeTillMidnight()));
        buf.append("</p>");

        Map<Hash, Points> points = new HashMap<Hash, Points>(64);

        // IP analysis
        renderIPGroupsFamily(out, buf, ris, points);
        renderIPGroupsUs(out, buf, ris, points);
        renderIPGroups32(out, buf, ris, points);
        renderIPGroups24(out, buf, ris, points);
        renderIPGroups16(out, buf, ris, points);

        // Pairwise distance analysis
        renderPairDistance(out, buf, ris, points);

        // Distance to our router analysis
        buf.append("<h3 id=\"ritoday\">Closest Floodfills to Our Routing Key (Where we Store our RI)</h3>");
        buf.append("<p><a href=\"/netdb?caps=f&amp;sybil\">See all</a></p>");
        renderRouterInfoHTML(out, buf, ourRKey, avgMinDist, ris, points);
        RouterKeyGenerator rkgen = _context.routerKeyGenerator();
        Hash nkey = rkgen.getNextRoutingKey(us);
        buf.append("<h3 id=\"ritmrw\">Closest Floodfills to Tomorrow's Routing Key (Where we will Store our RI)</h3>");
        buf.append("<p><a href=\"/netdb?caps=f&amp;sybil\">See all</a></p>");
        renderRouterInfoHTML(out, buf, nkey, avgMinDist, ris, points);

        buf.append("<h3 id=\"dht\">Closest Floodfills to Our Router Hash (DHT Neighbors if we are Floodfill)</h3>");
        renderRouterInfoHTML(out, buf, us, avgMinDist, ris, points);

        // Distance to our published destinations analysis
        buf.append("<h3 id=\"dest\">Floodfills Close to Our Destinations</h3>");
        Map<Hash, TunnelPool> clientInboundPools = _context.tunnelManager().getInboundClientPools();
        List<Hash> destinations = new ArrayList<Hash>(clientInboundPools.keySet());
        boolean debug = _context.getBooleanProperty(HelperBase.PROP_ADVANCED);
        for (Hash client : destinations) {
            boolean isLocal = _context.clientManager().isLocal(client);
            if (!isLocal)
                continue;
            if (! _context.clientManager().shouldPublishLeaseSet(client))
                continue;
            LeaseSet ls = _context.netDb().lookupLeaseSetLocally(client);
            if (ls == null)
                continue;
            Hash rkey = ls.getRoutingKey();
            TunnelPool in = clientInboundPools.get(client);
            String name = (in != null) ? in.getSettings().getDestinationNickname() : client.toBase64().substring(0,4);
            buf.append("<h3>Closest floodfills to the Routing Key for " + DataHelper.escapeHTML(name) + " (where we store our LS)</h3>");
            buf.append("<p><a href=\"/netdb?caps=f&amp;sybil=" + ls.getHash().toBase64() + "\">See all</a></p>");
            renderRouterInfoHTML(out, buf, rkey, avgMinDist, ris, points);
            nkey = rkgen.getNextRoutingKey(ls.getHash());
            buf.append("<h3>Closest floodfills to Tomorrow's Routing Key for " + DataHelper.escapeHTML(name) + " (where we will store our LS)</h3>");
            buf.append("<p><a href=\"/netdb?caps=f&amp;sybil=" + ls.getHash().toBase64() + "\">See all</a></p>");
            renderRouterInfoHTML(out, buf, nkey, avgMinDist, ris, points);
        }

        // Profile analysis
        addProfilePoints(ris, points);
        addVersionPoints(ris, points);

        if (!points.isEmpty()) {
            List<Hash> warns = new ArrayList<Hash>(points.keySet());
            Collections.sort(warns, new PointsComparator(points));
            buf.append("<h3 id=\"threats\">Routers with Most Threat Points</h3>");
            for (Hash h : warns) {
                RouterInfo ri = _context.netDb().lookupRouterInfoLocally(h);
                if (ri == null)
                    continue;
                Points pp = points.get(h);
                double p = pp.points;
                if (p < MIN_DISPLAY_POINTS)
                    break;  // sorted
                buf.append("<p><b>Threat Points: " + fmt.format(p) + "</b><ul>");
                for (String s : pp.reasons) {
                    buf.append("<li>").append(s).append("</li>");
                }
                buf.append("</ul></p>");
                renderRouterInfo(buf, ri, null, false, false);
            }
        }

        out.write(buf.toString());
        out.flush();
        buf.setLength(0);
    }

    private static class Pair implements Comparable<Pair> {
        public final RouterInfo r1, r2;
        public final BigInteger dist;
        public Pair(RouterInfo ri1, RouterInfo ri2, BigInteger distance) {
            r1 = ri1; r2 = ri2; dist = distance;
        }
        public int compareTo(Pair p) {
            return this.dist.compareTo(p.dist);
        }
    }

    private void renderPairDistance(Writer out, StringBuilder buf, List<RouterInfo> ris, Map<Hash, Points> points) throws IOException {
        int sz = ris.size();
        List<Pair> pairs = new ArrayList<Pair>(PAIRMAX);
        double total = 0;
        for (int i = 0; i < sz; i++) {
            RouterInfo info1 = ris.get(i);
            for (int j = i + 1; j < sz; j++) {
                RouterInfo info2 = ris.get(j);
                BigInteger dist = HashDistance.getDistance(info1.getHash(), info2.getHash());
                if (pairs.isEmpty()) {
                    pairs.add(new Pair(info1, info2, dist));
                } else if (pairs.size() < PAIRMAX) {
                    pairs.add(new Pair(info1, info2, dist));
                    Collections.sort(pairs);
                } else if (dist.compareTo(pairs.get(PAIRMAX - 1).dist) < 0) {
                    pairs.set(PAIRMAX - 1, new Pair(info1, info2, dist));
                    Collections.sort(pairs);
                }
                total += biLog2(dist);
            }
        }

        double avg = total / (sz * sz / 2);
        buf.append("<h3>Average Floodfill Distance is ").append(fmt.format(avg)).append("</h3>");

        buf.append("<h3 id=\"pairs\">Closest Floodfill Pairs by Hash</h3>");
        for (Pair p : pairs) {
            double distance = biLog2(p.dist);
            double point = MIN_CLOSE - distance;
            if (point < 0)
                break;  // sorted;
            if (point >= 2) {
                // limit display
                buf.append("<p><b>Hash Distance: ").append(fmt.format(distance)).append(": </b>");
                buf.append("</p>");
                renderRouterInfo(buf, p.r1, null, false, false);
                renderRouterInfo(buf, p.r2, null, false, false);
            }
            point *= PAIR_DISTANCE_FACTOR;
            String b2 = p.r2.getHash().toBase64();
            addPoints(points, p.r1.getHash(), point, "Very close (" + fmt.format(distance) +
                          ") to other floodfill <a href=\"netdb?r=" + b2 + "\">" + b2 + "</a>");
            String b1 = p.r1.getHash().toBase64();
            addPoints(points, p.r2.getHash(), point, "Very close (" + fmt.format(distance) +
                          ") to other floodfill <a href=\"netdb?r=" + b1 + "\">" + b1 + "</a>");
        }
        out.write(buf.toString());
        out.flush();
        buf.setLength(0);
    }

    private double closestDistance(Hash h, List<RouterInfo> ris) throws IOException {
        BigInteger min = (new BigInteger("2")).pow(256);
        for (RouterInfo info : ris) {
            BigInteger dist = HashDistance.getDistance(h, info.getHash());
            if (dist.compareTo(min) < 0)
                min = dist;
        }
        return biLog2(min);
    }

    /** v4 only */
    private static byte[] getIP(RouterInfo ri) {
        for (RouterAddress ra : ri.getAddresses()) {
            byte[] rv = ra.getIP();
            if (rv != null && rv.length == 4)
                return rv;
        }
        return null;
    }

    private static class FooComparator implements Comparator<Integer>, Serializable {
         private final ObjectCounter<Integer> _o;
         public FooComparator(ObjectCounter<Integer> o) { _o = o;}
         public int compare(Integer l, Integer r) {
             // reverse by count
             int rv = _o.count(r) - _o.count(l);
             if (rv != 0)
                 return rv;
             // foward by IP
             return l.intValue() - r.intValue();
        }
    }

    private static class FoofComparator implements Comparator<String>, Serializable {
         private final ObjectCounter<String> _o;
         private final Collator _comp = Collator.getInstance();
         public FoofComparator(ObjectCounter<String> o) { _o = o;}
         public int compare(String l, String r) {
             // reverse by count
             int rv = _o.count(r) - _o.count(l);
             if (rv != 0)
                 return rv;
             // foward by name
             return _comp.compare(l, r);
        }
    }

    private void renderIPGroupsUs(Writer out, StringBuilder buf, List<RouterInfo> ris, Map<Hash, Points> points) throws IOException {
        RouterInfo us = _context.router().getRouterInfo();
        byte[] ourIP = getIP(us);
        if (ourIP == null)
            return;
        buf.append("<h3 \"ourIP\">Floodfills close to Our IP</h3>");
        boolean found = false;
        for (RouterInfo info : ris) {
            byte[] ip = getIP(info);
            if (ip == null)
                continue;
            if (ip[0] == ourIP[0] && ip[1] == ourIP[1]) {
                buf.append("<p><b>");
                if (ip[2] == ourIP[2]) {
                    if (ip[3] == ourIP[3]) {
                        buf.append("Same IP as us");
                        addPoints(points, info.getHash(), POINTS_US32, "Same IP as us");
                    } else {
                        buf.append("Same /24 as us");
                        addPoints(points, info.getHash(), POINTS_US24, "Same /24 as us");
                    }
                } else {
                    buf.append("Same /16 as us");
                    addPoints(points, info.getHash(), POINTS_US16, "Same /16 as us");
                }
                buf.append(":</b></p>");
                renderRouterInfo(buf, info, null, false, false);
                found = true;
            }
        }
        if (!found)
            buf.append("<p>None</p>");
        out.write(buf.toString());
        out.flush();
        buf.setLength(0);
    }

    private void renderIPGroups32(Writer out, StringBuilder buf, List<RouterInfo> ris, Map<Hash, Points> points) throws IOException {
        buf.append("<h3 id=\"sameIP\">Floodfills with the Same IP</h3>");
        ObjectCounter<Integer> oc = new ObjectCounter<Integer>();
        for (RouterInfo info : ris) {
            byte[] ip = getIP(info);
            if (ip == null)
                continue;
            Integer x = Integer.valueOf((int) DataHelper.fromLong(ip, 0, 4));
            oc.increment(x);
        }
        List<Integer> foo = new ArrayList<Integer>();
        for (Integer ii : oc.objects()) {
            int count = oc.count(ii);
            if (count >= 2)
                foo.add(ii);
        }
        Collections.sort(foo, new FooComparator(oc));
        boolean found = false;
        for (Integer ii : foo) {
            int count = oc.count(ii);
            int i = ii.intValue();
            int i0 = (i >> 24) & 0xff;
            int i1 = (i >> 16) & 0xff;
            int i2 = (i >> 8) & 0xff;
            int i3 = i & 0xff;
            String sip = i0 + "." + i1 + '.' + i2 + '.' + i3;
            buf.append("<p><b>").append(count).append(" floodfills with IP <a href=\"/netdb?ip=")
               .append(sip).append("&amp;sybil\">").append(sip)
               .append("</a>:</b></p>");
            for (RouterInfo info : ris) {
                byte[] ip = getIP(info);
                if (ip == null)
                    continue;
                if ((ip[0] & 0xff) != i0)
                    continue;
                if ((ip[1] & 0xff) != i1)
                    continue;
                if ((ip[2] & 0xff) != i2)
                    continue;
                if ((ip[3] & 0xff) != i3)
                    continue;
                found = true;
                renderRouterInfo(buf, info, null, false, false);
                double point = POINTS32 * (count - 1);
                addPoints(points, info.getHash(), point, "Same IP with " + (count - 1) + " other" + (( count > 2) ? "s" : ""));
            }
        }
        if (!found)
            buf.append("<p>None</p>");
        out.write(buf.toString());
        out.flush();
        buf.setLength(0);
    }

    private void renderIPGroups24(Writer out, StringBuilder buf, List<RouterInfo> ris, Map<Hash, Points> points) throws IOException {
        buf.append("<h3 id=\"same24\">Floodfills in the Same /24 (2 minimum)</h3>");
        ObjectCounter<Integer> oc = new ObjectCounter<Integer>();
        for (RouterInfo info : ris) {
            byte[] ip = getIP(info);
            if (ip == null)
                continue;
            Integer x = Integer.valueOf((int) DataHelper.fromLong(ip, 0, 3));
            oc.increment(x);
        }
        List<Integer> foo = new ArrayList<Integer>();
        for (Integer ii : oc.objects()) {
            int count = oc.count(ii);
            if (count >= 2)
                foo.add(ii);
        }
        Collections.sort(foo, new FooComparator(oc));
        boolean found = false;
        for (Integer ii : foo) {
            int count = oc.count(ii);
            int i = ii.intValue();
            int i0 = i >> 16;
            int i1 = (i >> 8) & 0xff;
            int i2 = i & 0xff;
            String sip = i0 + "." + i1 + '.' + i2 + ".0/24";
            buf.append("<p><b>").append(count).append(" floodfills with IP <a href=\"/netdb?ip=")
               .append(sip).append("&amp;sybil\">").append(sip)
               .append("</a>:</b></p>");
            for (RouterInfo info : ris) {
                byte[] ip = getIP(info);
                if (ip == null)
                    continue;
                if ((ip[0] & 0xff) != i0)
                    continue;
                if ((ip[1] & 0xff) != i1)
                    continue;
                if ((ip[2] & 0xff) != i2)
                    continue;
                found = true;
                renderRouterInfo(buf, info, null, false, false);
                double point = POINTS24 * (count - 1);
                addPoints(points, info.getHash(), point, "Same /24 IP with " + (count - 1) + " other" + (( count > 2) ? "s" : ""));
            }
        }
        if (!found)
            buf.append("<p>None</p>");
        out.write(buf.toString());
        out.flush();
        buf.setLength(0);
    }

    private void renderIPGroups16(Writer out, StringBuilder buf, List<RouterInfo> ris, Map<Hash, Points> points) throws IOException {
        buf.append("<h3 id=\"same16\">Floodfills in the Same /16 (4 minimum)</h3>");
        ObjectCounter<Integer> oc = new ObjectCounter<Integer>();
        for (RouterInfo info : ris) {
            byte[] ip = getIP(info);
            if (ip == null)
                continue;
            Integer x = Integer.valueOf((int) DataHelper.fromLong(ip, 0, 2));
            oc.increment(x);
        }
        List<Integer> foo = new ArrayList<Integer>();
        for (Integer ii : oc.objects()) {
            int count = oc.count(ii);
            if (count >= 4)
                foo.add(ii);
        }
        Collections.sort(foo, new FooComparator(oc));
        boolean found = false;
        for (Integer ii : foo) {
            int count = oc.count(ii);
            int i = ii.intValue();
            int i0 = i >> 8;
            int i1 = i & 0xff;
            String sip = i0 + "." + i1 + ".0/16";
            buf.append("<p><b>").append(count).append(" floodfills with IP <a href=\"/netdb?ip=")
               .append(sip).append("&amp;sybil\">").append(sip)
               .append("</a></b></p>");
            for (RouterInfo info : ris) {
                byte[] ip = getIP(info);
                if (ip == null)
                    continue;
                if ((ip[0] & 0xff) != i0)
                    continue;
                if ((ip[1] & 0xff) != i1)
                    continue;
                found = true;
                // limit display
                //renderRouterInfo(buf, info, null, false, false);
                double point = POINTS16 * (count - 1);
                addPoints(points, info.getHash(), point, "Same /16 IP with " + (count - 1) + " other" + (( count > 2) ? "s" : ""));
            }
        }
        if (!found)
            buf.append("<p>None</p>");
        out.write(buf.toString());
        out.flush();
        buf.setLength(0);
    }

    private void renderIPGroupsFamily(Writer out, StringBuilder buf, List<RouterInfo> ris, Map<Hash, Points> points) throws IOException {
        buf.append("<h3>Floodfills in the Same Declared Family</h3>");
        ObjectCounter<String> oc = new ObjectCounter<String>();
        for (RouterInfo info : ris) {
            String fam = info.getOption("family");
            if (fam == null)
                continue;
            oc.increment(fam);
        }
        List<String> foo = new ArrayList<String>(oc.objects());
        Collections.sort(foo, new FoofComparator(oc));
        FamilyKeyCrypto fkc = _context.router().getFamilyKeyCrypto();
        String ourFamily = fkc != null ? fkc.getOurFamilyName() : null;
        boolean found = false;
        for (String s : foo) {
            int count = oc.count(s);
            String ss = DataHelper.escapeHTML(s);
            buf.append("<p><b>").append(count).append(" floodfills in declared family \"<a href=\"/netdb?fam=")
               .append(ss).append("&amp;sybil\">").append(ss).append("</a>\"</b></p>");
            for (RouterInfo info : ris) {
                String fam = info.getOption("family");
                if (fam == null)
                    continue;
                if (!fam.equals(s))
                    continue;
                found = true;
                // limit display
                //renderRouterInfo(buf, info, null, false, false);
                double point = POINTS_FAMILY;
                if (fkc != null && s.equals(ourFamily)) {
                    if (fkc.verifyOurFamily(info))
                        addPoints(points, info.getHash(), POINTS_OUR_FAMILY, "Our family \"" + DataHelper.escapeHTML(s) + "\" with " + (count - 1) + " other" + (( count > 2) ? "s" : ""));
                    else
                        addPoints(points, info.getHash(), POINTS_BAD_OUR_FAMILY, "Spoofed our family \"" + DataHelper.escapeHTML(s) + "\" with " + (count - 1) + " other" + (( count > 2) ? "s" : ""));
                } else if (count > 1) {
                    addPoints(points, info.getHash(), point, "Same declared family \"" + DataHelper.escapeHTML(s) + "\" with " + (count - 1) + " other" + (( count > 2) ? "s" : ""));
                } else {
                    addPoints(points, info.getHash(), point, "Declared family \"" + DataHelper.escapeHTML(s) + '"');
                }
            }
        }
        if (!found)
            buf.append("<p>None</p>");
        out.write(buf.toString());
        out.flush();
        buf.setLength(0);
    }

    private static final long DAY = 24*60*60*1000L;

    private void addProfilePoints(List<RouterInfo> ris, Map<Hash, Points> points) {
        long now = _context.clock().now();
        RateAverages ra = RateAverages.getTemp();
        for (RouterInfo info : ris) {
            Hash h = info.getHash();
            if (_context.banlist().isBanlisted(h))
                addPoints(points, h, POINTS_BANLIST, "Banlisted");
            PeerProfile prof = _context.profileOrganizer().getProfileNonblocking(h);
            if (prof != null) {
                long heard = prof.getFirstHeardAbout();
                if (heard > 0) {
                    long age = Math.max(now - heard, 1);
                    if (age < 2 * DAY) {
                        // (POINTS_NEW / 48) for every hour under 48, max POINTS_NEW
                        double point = Math.min(POINTS_NEW, (2 * DAY - age) / (2 * DAY / POINTS_NEW));
                        addPoints(points, h, point,
                                  "First heard about: " + _t("{0} ago", DataHelper.formatDuration2(age)));
                    }
                }
                DBHistory dbh = prof.getDBHistory();
                if (dbh != null) {
                    RateStat rs = dbh.getFailedLookupRate();
                    if (rs != null) {
                        Rate r = rs.getRate(24*60*60*1000);
                        if (r != null) {
                            r.computeAverages(ra, false);
                            if (ra.getTotalEventCount() > 0) {
                                double avg = 100 * ra.getAverage();
                                if (avg > 40)
                                    addPoints(points, h, (avg - 40) / 6.0, "Lookup fail rate " + ((int) avg) + '%');
                            }
                        }
                    }
                }
            }
        }
    }

    private void addVersionPoints(List<RouterInfo> ris, Map<Hash, Points> points) {
        RouterInfo us = _context.router().getRouterInfo();
        if (us == null) return;
        String ourVer = us.getVersion();
        if (!ourVer.startsWith("0.9.")) return;
        ourVer = ourVer.substring(4);
        int dot = ourVer.indexOf('.');
        if (dot > 0)
            ourVer = ourVer.substring(0, dot);
        int minor;
        try {
            minor = Integer.parseInt(ourVer);
        } catch (NumberFormatException nfe) { return; }
        for (RouterInfo info : ris) {
            Hash h = info.getHash();
            String caps = info.getCapabilities();
            if (!caps.contains("R"))
                addPoints(points, h, POINTS_UNREACHABLE, "Unreachable: " + DataHelper.escapeHTML(caps));
            String hisFullVer = info.getVersion();
            if (!hisFullVer.startsWith("0.9.")) {
                addPoints(points, h, POINTS_BAD_VERSION, "Strange version " + DataHelper.escapeHTML(hisFullVer));
                continue;
            }
            String hisVer = hisFullVer.substring(4);
            dot = hisVer.indexOf('.');
            if (dot > 0)
                hisVer = hisVer.substring(0, dot);
            int hisMinor;
            try {
                hisMinor = Integer.parseInt(hisVer);
            } catch (NumberFormatException nfe) { continue; }
            int howOld = minor - hisMinor;
            if (howOld < 3)
                continue;
            addPoints(points, h, howOld * VERSION_FACTOR, howOld + " versions behind: " + DataHelper.escapeHTML(hisFullVer));
        }
    }

    private void renderRouterInfoHTML(Writer out, StringBuilder buf, Hash us, double avgMinDist,
                                      List<RouterInfo> ris, Map<Hash, Points> points) throws IOException {
        Collections.sort(ris, new RouterInfoRoutingKeyComparator(us));
        double min = 256;
        double max = 0;
        double tot = 0;
        double median = 0;
        int count = Math.min(MAX, ris.size());
        boolean isEven = (count % 2) == 0;
        int medIdx = isEven ? (count / 2) - 1 : (count / 2);
        for (int i = 0; i < count; i++) {
            RouterInfo ri = ris.get(i);
            double dist = renderRouterInfo(buf, ri, us, false, false);
            if (dist < avgMinDist) {
                if (i == 0) {
                    //buf.append("<p><b>Not to worry, but above router is closer than average minimum distance " + fmt.format(avgMinDist) + "</b></p>");
                } else if (i == 1) {
                    buf.append("<p><b>Not to worry, but above routers are closer than average minimum distance " + fmt.format(avgMinDist) + "</b></p>");
                } else if (i == 2) {
                    buf.append("<p><b>Possible Sybil Warning - above routers are closer than average minimum distance " + fmt.format(avgMinDist) + "</b></p>");
                } else {
                    buf.append("<p><b>Major Sybil Warning - above router is closer than average minimum distance " + fmt.format(avgMinDist) + "</b></p>");
                }
            }
            // this is dumb because they are already sorted
            if (dist < min)
                min = dist;
            if (dist > max)
                max = dist;
            tot += dist;
            if (i == medIdx)
                median = dist;
            else if (i == medIdx + 1 && isEven)
                median = (median + dist) / 2;
            double point = MIN_CLOSE - dist;
            if (point > 0) {
                point *= OUR_KEY_FACTOR;
                addPoints(points, ri.getHash(), point, "Very close (" + fmt.format(dist) + ") to our key " + us.toBase64());
            }
            if (i >= MAX - 1)
                break;
        }
        double avg = tot / count;
        buf.append("<p><b>Totals for " + count + " floodfills: </b>MIN=" + fmt.format(min) + " AVG=" + fmt.format(avg) + " MEDIAN=" + fmt.format(median) + " MAX=" + fmt.format(max) + "</p>\n");
        out.write(buf.toString());
        out.flush();
        buf.setLength(0);
    }

    /**
     * For debugging
     * http://forums.sun.com/thread.jspa?threadID=597652
     * @since 0.7.14
     */
    private static double biLog2(BigInteger a) {
        return NetDbRenderer.biLog2(a);
    }

    /**
     * Countries now in a separate bundle
     * @param code two-letter country code
     * @since 0.9.9
     */
    private String getTranslatedCountry(String code) {
        String name = _context.commSystem().getCountryName(code);
        return Translate.getString(name, _context, Messages.COUNTRY_BUNDLE_NAME);
    }

    /**
     *  Be careful to use stripHTML for any displayed routerInfo data
     *  to prevent vulnerabilities
     *
     *  @param us ROUTING KEY or null
     *  @param full ignored
     *  @return distance to us if non-null, else 0
     */
    private double renderRouterInfo(StringBuilder buf, RouterInfo info, Hash us, boolean isUs, boolean full) {
        String hash = info.getIdentity().getHash().toBase64();
        buf.append("<table><tr><th><a name=\"").append(hash.substring(0, 6)).append("\" ></a>");
        double distance = 0;
        if (isUs) {
            buf.append("<a name=\"our-info\" ></a><b>" + _t("Our info") + ": ").append(hash).append("</b></th></tr><tr><td>\n");
        } else {
            buf.append("<b>" + _t("Router") + ":</b> ").append(hash).append("\n");
            if (!full) {
                buf.append("[<a href=\"netdb?r=").append(hash.substring(0, 6)).append("\" >").append(_t("Full entry")).append("</a>]");
            }
            buf.append("</th><th><img src=\"/imagegen/id?s=32&amp;c=" + hash.replace("=", "%3d") + "\" height=\"32\" width=\"32\"> ");
            buf.append("</th></tr><tr><td colspan=\"2\">\n");
            if (us != null) {
                BigInteger dist = HashDistance.getDistance(us, info.getHash());
                distance = biLog2(dist);
                buf.append("<b>Hash Distance: ").append(fmt.format(distance)).append("</b><br>");
            }
        }
        buf.append("<b>Routing Key: </b>").append(info.getRoutingKey().toBase64()).append("<br>\n");
        buf.append("<b>Version: </b>").append(DataHelper.stripHTML(info.getVersion())).append("<br>\n");
        buf.append("<b>Caps: </b>").append(DataHelper.stripHTML(info.getCapabilities())).append("<br>\n");
        String fam = info.getOption("family");
        if (fam != null)
            buf.append("<b>Family: ").append(DataHelper.escapeHTML(fam)).append("</b><br>\n");
        String kls = info.getOption("netdb.knownLeaseSets");
        if (kls != null)
            buf.append("<b>Lease Sets: </b>").append(DataHelper.stripHTML(kls)).append("<br>\n");
        String kr = info.getOption("netdb.knownRouters");
        if (kr != null)
            buf.append("<b>Routers: </b>").append(DataHelper.stripHTML(kr)).append("<br>\n");
        
        long now = _context.clock().now();
        if (!isUs) {
            PeerProfile prof = _context.profileOrganizer().getProfileNonblocking(info.getHash());
            if (prof != null) {
                long heard = prof.getFirstHeardAbout();
                if (heard > 0) {
                    long age = Math.max(now - heard, 1);
                    buf.append("<b>First heard about:</b> ")
                       .append(_t("{0} ago", DataHelper.formatDuration2(age))).append("<br>\n");
                }
                heard = prof.getLastHeardAbout();
                if (heard > 0) {
                    long age = Math.max(now - heard, 1);
                    buf.append("<b>Last heard about:</b> ")
                       .append(_t("{0} ago", DataHelper.formatDuration2(age))).append("<br>\n");
                }
                heard = prof.getLastHeardFrom();
                if (heard > 0) {
                    long age = Math.max(now - heard, 1);
                    buf.append("<b>Last heard from:</b> ")
                       .append(_t("{0} ago", DataHelper.formatDuration2(age))).append("<br>\n");
                }
                DBHistory dbh = prof.getDBHistory();
                if (dbh != null) {
                    heard = dbh.getLastLookupSuccessful();
                    if (heard > 0) {
                        long age = Math.max(now - heard, 1);
                        buf.append("<b>Last lookup successful:</b> ")
                           .append(_t("{0} ago", DataHelper.formatDuration2(age))).append("<br>\n");
                    }
                    heard = dbh.getLastLookupFailed();
                    if (heard > 0) {
                        long age = Math.max(now - heard, 1);
                        buf.append("<b>Last lookup failed:</b> ")
                           .append(_t("{0} ago", DataHelper.formatDuration2(age))).append("<br>\n");
                    }
                    heard = dbh.getLastStoreSuccessful();
                    if (heard > 0) {
                        long age = Math.max(now - heard, 1);
                        buf.append("<b>Last store successful:</b> ")
                           .append(_t("{0} ago", DataHelper.formatDuration2(age))).append("<br>\n");
                    }
                    heard = dbh.getLastStoreFailed();
                    if (heard > 0) {
                        long age = Math.max(now - heard, 1);
                        buf.append("<b>Last store failed:</b> ")
                           .append(_t("{0} ago", DataHelper.formatDuration2(age))).append("<br>\n");
                    }
                }
                // any other profile stuff?
            }
        }
        long age = Math.max(now - info.getPublished(), 1);
        if (isUs && _context.router().isHidden()) {
            buf.append("<b>").append(_t("Hidden")).append(", ").append(_t("Updated")).append(":</b> ")
               .append(_t("{0} ago", DataHelper.formatDuration2(age))).append("<br>\n");
        } else {
            buf.append("<b>").append(_t("Published")).append(":</b> ")
               .append(_t("{0} ago", DataHelper.formatDuration2(age))).append("<br>\n");
        }
        buf.append("<b>").append(_t("Signing Key")).append(":</b> ")
           .append(info.getIdentity().getSigningPublicKey().getType().toString());
        buf.append("<br>\n<b>" + _t("Addresses") + ":</b> ");
        String country = _context.commSystem().getCountry(info.getIdentity().getHash());
        if(country != null) {
            buf.append("<img height=\"11\" width=\"16\" alt=\"").append(country.toUpperCase(Locale.US)).append('\"');
            buf.append(" title=\"").append(getTranslatedCountry(country)).append('\"');
            buf.append(" src=\"/flags.jsp?c=").append(country).append("\"> ");
        }
        for (RouterAddress addr : info.getAddresses()) {
            String style = addr.getTransportStyle();
            buf.append("<b>").append(DataHelper.stripHTML(style)).append(":</b> ");
            Map<Object, Object> p = addr.getOptionsMap();
            for (Map.Entry<Object, Object> e : p.entrySet()) {
                String name = (String) e.getKey();
                if (name.equals("key") || name.startsWith("ikey") || name.startsWith("itag") ||
                    name.startsWith("iport") || name.equals("mtu"))
                    continue;
                String val = (String) e.getValue();
                buf.append('[').append(_t(DataHelper.stripHTML(name))).append('=');
                if (name.equals("host"))
                    buf.append("<b>");
                buf.append(DataHelper.stripHTML(val)).append("] ");
                if (name.equals("host"))
                    buf.append("</b>");
            }
        }
        buf.append("</td></tr>\n");
        buf.append("</table>\n");
        return distance;
    }

    /**
     *  Called from NetDbRenderer
     *
     *  @since 0.9.28
     */
    public static void renderSybilHTML(Writer out, RouterContext ctx, List<Hash> sybils, String victim) throws IOException {
        if (sybils.isEmpty())
            return;
        final DecimalFormat fmt = new DecimalFormat("#0.00");
        XORComparator<Hash> xor = new XORComparator<Hash>(Hash.FAKE_HASH);
        out.write("<h3>Group Distances</h3><table><tr><th>Hash<th>Distance from previous</tr>\n");
        Collections.sort(sybils, xor);
        Hash prev = null;
        for (Hash h : sybils) {
            String hh = h.toBase64();
            out.write("<tr><td><a href=\"#" + hh.substring(0, 6) + "\"><tt>" + hh + "</tt></a><td>");
            if (prev != null) {
                BigInteger dist = HashDistance.getDistance(prev, h);
                writeDistance(out, fmt, dist);
            }
            prev = h;
            out.write("</tr>\n");
        }
        out.write("</table>\n");
        out.flush();

        RouterKeyGenerator rkgen = ctx.routerKeyGenerator();
        long now = ctx.clock().now();
        final int start = -3;
        now += start * 24*60*60*1000L;
        final int days = 10;
        Hash from = ctx.routerHash();
        if (victim != null) {
            Hash v = ConvertToHash.getHash(victim);
            if (v != null)
                from = v;
        }
        out.write("<h3>Distance to " + from.toBase64() + "</h3>");
        prev = null;
        final int limit = Math.min(10, sybils.size());
        for (int i = start; i <= days; i++) {
            out.write("<h3>Distance for " + new Date(now) +
                      "</h3><table><tr><th>Hash<th>Distance<th>Distance from previous</tr>\n");
            Hash rkey = rkgen.getRoutingKey(from, now);
            xor = new XORComparator<Hash>(rkey);
            Collections.sort(sybils, xor);
            for (int j = 0; j < limit; j++) {
                Hash h = sybils.get(j);
                String hh = h.toBase64();
                out.write("<tr><td><a href=\"#" + hh.substring(0, 6) + "\"><tt>" + hh + "</tt></a><td>");
                BigInteger dist = HashDistance.getDistance(rkey, h);
                writeDistance(out, fmt, dist);
                out.write("<td>");
                if (prev != null) {
                    dist = HashDistance.getDistance(prev, h);
                    writeDistance(out, fmt, dist);
                }
                prev = h;
                out.write("</tr>\n");
            }
            out.write("</table>\n");
            out.flush();
            now += 24*60*60*1000;
            prev = null;
        }
    }

    /** @since 0.9.28 */
    private static void writeDistance(Writer out, DecimalFormat fmt, BigInteger dist) throws IOException {
        double distance = biLog2(dist);
        if (distance < MIN_CLOSE)
            out.write("<font color=\"red\">");
        out.write(fmt.format(distance));
        if (distance < MIN_CLOSE)
            out.write("</font>");
    }

    /** translate a string */
    private String _t(String s) {
        return Messages.getString(s, _context);
    }

    /** tag only */
    private static final String _x(String s) {
        return s;
    }

    /**
     *  translate a string with a parameter
     *  This is a lot more expensive than _t(s), so use sparingly.
     *
     *  @param s string to be translated containing {0}
     *    The {0} will be replaced by the parameter.
     *    Single quotes must be doubled, i.e. ' -> '' in the string.
     *  @param o parameter, not translated.
     *    To translate parameter also, use _t("foo {0} bar", _t("baz"))
     *    Do not double the single quotes in the parameter.
     *    Use autoboxing to call with ints, longs, floats, etc.
     */
    private String _t(String s, Object o) {
        return Messages.getString(s, o, _context);
    }
}
