package net.i2p.router.tunnel.pool;

import java.io.IOException;
import java.io.Writer;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

import net.i2p.data.Destination;
import net.i2p.data.Hash;
import net.i2p.data.TunnelId;
import net.i2p.router.ClientTunnelSettings;
import net.i2p.router.JobImpl;
import net.i2p.router.RouterContext;
import net.i2p.router.TunnelInfo;
import net.i2p.router.TunnelManagerFacade;
import net.i2p.router.TunnelPoolSettings;
import net.i2p.router.tunnel.TunnelDispatcher;
import net.i2p.util.I2PThread;
import net.i2p.util.Log;
import net.i2p.util.ObjectCounter;
import net.i2p.util.SimpleTimer;

/**
 * Manage all the exploratory and client tunnel pools.
 * Run the tunnel builder and handler threads.
 */
public class TunnelPoolManager implements TunnelManagerFacade {
    private final RouterContext _context;
    private final Log _log;
    /** Hash (destination) to TunnelPool */
    private final Map<Hash, TunnelPool> _clientInboundPools;
    /** Hash (destination) to TunnelPool */
    private final Map<Hash, TunnelPool> _clientOutboundPools;
    private final TunnelPool _inboundExploratory;
    private final TunnelPool _outboundExploratory;
    private final BuildExecutor _executor;
    private final BuildHandler _handler;
    private final TunnelPeerSelector _clientPeerSelector;
    private volatile boolean _isShutdown;
    private final int _numHandlerThreads;

    private static final int MIN_KBPS_TWO_HANDLERS = 512;
    private static final int MIN_KBPS_THREE_HANDLERS = 1024;
    
    public TunnelPoolManager(RouterContext ctx) {
        _context = ctx;
        _log = ctx.logManager().getLog(TunnelPoolManager.class);
        
        //HandlerJobBuilder builder = new HandleTunnelCreateMessageJob.Builder(ctx);
        //ctx.inNetMessagePool().registerHandlerJobBuilder(TunnelCreateMessage.MESSAGE_TYPE, builder);
        //HandlerJobBuilder b = new TunnelMessageHandlerBuilder(ctx);
        //ctx.inNetMessagePool().registerHandlerJobBuilder(TunnelGatewayMessage.MESSAGE_TYPE, b);
        //ctx.inNetMessagePool().registerHandlerJobBuilder(TunnelDataMessage.MESSAGE_TYPE, b);

        _clientInboundPools = new ConcurrentHashMap<Hash, TunnelPool>(4);
        _clientOutboundPools = new ConcurrentHashMap<Hash, TunnelPool>(4);
        _clientPeerSelector = new ClientPeerSelector(ctx);

        ExploratoryPeerSelector selector = new ExploratoryPeerSelector(_context);
        TunnelPoolSettings inboundSettings = new TunnelPoolSettings(null, true, true);
        _inboundExploratory = new TunnelPool(_context, this, inboundSettings, selector);
        TunnelPoolSettings outboundSettings = new TunnelPoolSettings(null, true, false);
        _outboundExploratory = new TunnelPool(_context, this, outboundSettings, selector);
        
        // threads will be started in startup()
        _executor = new BuildExecutor(ctx, this);
        _handler = new BuildHandler(ctx, this, _executor);
        int numHandlerThreads;
        int share = TunnelDispatcher.getShareBandwidth(ctx);
        if (share >= MIN_KBPS_THREE_HANDLERS)
            numHandlerThreads = 3;
        else if (share >= MIN_KBPS_TWO_HANDLERS)
            numHandlerThreads = 2;
        else
            numHandlerThreads = 1;
        _numHandlerThreads = ctx.getProperty("router.buildHandlerThreads", numHandlerThreads);
        
        // The following are for TestJob
        long[] RATES = { 60*1000, 10*60*1000l, 60*60*1000l };
        ctx.statManager().createRequiredRateStat("tunnel.testFailedTime", "Time for tunnel test failure (ms)", "Tunnels", 
                                         RATES);
        ctx.statManager().createRateStat("tunnel.testExploratoryFailedTime", "How long did the failure of an exploratory tunnel take (max of 60s for full timeout)?", "Tunnels", 
                                         RATES);
        ctx.statManager().createRateStat("tunnel.testFailedCompletelyTime", "How long did the complete failure take (max of 60s for full timeout)?", "Tunnels", 
                                         RATES);
        ctx.statManager().createRateStat("tunnel.testExploratoryFailedCompletelyTime", "How long did the complete failure of an exploratory tunnel take (max of 60s for full timeout)?", "Tunnels", 
                                         RATES);
        ctx.statManager().createRateStat("tunnel.testSuccessLength", "How long were the tunnels that passed the test?", "Tunnels", 
                                         RATES);
        ctx.statManager().createRequiredRateStat("tunnel.testSuccessTime", "Time for tunnel test success (ms)", "Tunnels", 
                                         RATES);
        ctx.statManager().createRateStat("tunnel.testAborted", "Tunnel test could not occur, since there weren't any tunnels to test with", "Tunnels", 
                                         RATES);
    }
    
    /**
     * Pick a random inbound exploratory tunnel.
     * Warning - selectInboundExploratoryTunnel(Hash) is preferred.
     *
     * @return null if none
     */
    public TunnelInfo selectInboundTunnel() { 
        TunnelInfo info = _inboundExploratory.selectTunnel(); 
        if (info == null) {
            _inboundExploratory.buildFallback();
            // still can be null, but probably not
            info = _inboundExploratory.selectTunnel();
        }
        return info;
    }
    
    /**
     * Pick a random inbound tunnel from the given destination's pool.
     * Warning - selectOutboundTunnel(Hash, Hash) is preferred.
     *
     * @param destination if null, returns inbound exploratory tunnel
     * @return null if none
     */
    public TunnelInfo selectInboundTunnel(Hash destination) { 
        if (destination == null) return selectInboundTunnel();
        TunnelPool pool = _clientInboundPools.get(destination);
        if (pool != null) {
            return pool.selectTunnel();
        }
        if (_log.shouldLog(Log.ERROR))
            _log.error("Want the inbound tunnel for " + destination.calculateHash() +
                     " but there isn't a pool?");
        return null;
    }
    
    /**
     * Pick a random outbound exploratory tunnel.
     * Warning - selectOutboundExploratoryTunnel(Hash) is preferred.
     *
     * @return null if none
     */
    public TunnelInfo selectOutboundTunnel() { 
        TunnelInfo info = _outboundExploratory.selectTunnel();
        if (info == null) {
            _outboundExploratory.buildFallback();
            // still can be null, but probably not
            info = _outboundExploratory.selectTunnel();
        }
        return info;
    }
    
    /**
     * Pick a random outbound tunnel from the given destination's pool.
     * Warning - selectOutboundTunnel(Hash, Hash) is preferred.
     *
     * @param destination if null, returns outbound exploratory tunnel
     * @return null if none
     */
    public TunnelInfo selectOutboundTunnel(Hash destination)  {
        if (destination == null) return selectOutboundTunnel();
        TunnelPool pool = _clientOutboundPools.get(destination);
        if (pool != null) {
            return pool.selectTunnel();
        }
        return null;
    }
    
    /**
     * Pick the inbound exploratory tunnel with the gateway closest to the given hash.
     * By using this instead of the random selectTunnel(),
     * we force some locality in OBEP-IBGW connections to minimize
     * those connections network-wide.
     *
     * @param closestTo non-null
     * @return null if none
     * @since 0.8.10
     */
    public TunnelInfo selectInboundExploratoryTunnel(Hash closestTo) { 
        TunnelInfo info = _inboundExploratory.selectTunnel(closestTo); 
        if (info == null) {
            _inboundExploratory.buildFallback();
            // still can be null, but probably not
            info = _inboundExploratory.selectTunnel();
        }
        return info;
    }
    
    /**
     * Pick the inbound tunnel with the gateway closest to the given hash
     * from the given destination's pool.
     * By using this instead of the random selectTunnel(),
     * we force some locality in OBEP-IBGW connections to minimize
     * those connections network-wide.
     *
     * @param destination if null, returns inbound exploratory tunnel
     * @param closestTo non-null
     * @return null if none
     * @since 0.8.10
     */
    public TunnelInfo selectInboundTunnel(Hash destination, Hash closestTo) { 
        if (destination == null) return selectInboundExploratoryTunnel(closestTo);
        TunnelPool pool = _clientInboundPools.get(destination);
        if (pool != null) {
            return pool.selectTunnel(closestTo);
        }
        if (_log.shouldLog(Log.ERROR))
            _log.error("Want the inbound tunnel for " + destination.calculateHash() +
                     " but there isn't a pool?");
        return null;
    }
    
    /**
     * Pick the outbound exploratory tunnel with the endpoint closest to the given hash.
     * By using this instead of the random selectTunnel(),
     * we force some locality in OBEP-IBGW connections to minimize
     * those connections network-wide.
     *
     * @param closestTo non-null
     * @return null if none
     * @since 0.8.10
     */
    public TunnelInfo selectOutboundExploratoryTunnel(Hash closestTo) { 
        TunnelInfo info = _outboundExploratory.selectTunnel(closestTo);
        if (info == null) {
            _outboundExploratory.buildFallback();
            // still can be null, but probably not
            info = _outboundExploratory.selectTunnel();
        }
        return info;
    }
    
    /**
     * Pick the outbound tunnel with the endpoint closest to the given hash
     * from the given destination's pool.
     * By using this instead of the random selectTunnel(),
     * we force some locality in OBEP-IBGW connections to minimize
     * those connections network-wide.
     *
     * @param destination if null, returns outbound exploratory tunnel
     * @param closestTo non-null
     * @return null if none
     * @since 0.8.10
     */
    public TunnelInfo selectOutboundTunnel(Hash destination, Hash closestTo) {
        if (destination == null) return selectOutboundExploratoryTunnel(closestTo);
        TunnelPool pool = _clientOutboundPools.get(destination);
        if (pool != null) {
            return pool.selectTunnel(closestTo);
        }
        return null;
    }
    
    /**
     *  Expensive (iterates through all tunnels of all pools) and unnecessary.
     *  @deprecated unused
     */
    public TunnelInfo getTunnelInfo(TunnelId id) {
        TunnelInfo info = null;
        for (TunnelPool pool : _clientInboundPools.values()) {
                info = pool.getTunnel(id);
                if (info != null)
                    return info;
        }
        info = _inboundExploratory.getTunnel(id);
        if (info != null) return info;
        info = _outboundExploratory.getTunnel(id);
        if (info != null) return info;
        return null;
    }
    
    /** @return number of inbound exploratory tunnels */
    public int getFreeTunnelCount() { 
            return _inboundExploratory.size(); 
    }

    /** @return number of outbound exploratory tunnels */
    public int getOutboundTunnelCount() { 
            return _outboundExploratory.size(); 
    }

    public int getInboundClientTunnelCount() { 
        int count = 0;
        for (TunnelPool pool : _clientInboundPools.values()) {
            count += pool.listTunnels().size();
        }
        return count;
    }

    public int getOutboundClientTunnelCount() { 
        int count = 0;
        for (TunnelPool pool : _clientOutboundPools.values()) {
            count += pool.listTunnels().size();
        }
        return count;
    }

    /**
     *  Use to verify a tunnel pool is alive
     *  @since 0.7.11
     */
    public int getOutboundClientTunnelCount(Hash destination)  {
        TunnelPool pool = _clientOutboundPools.get(destination);
        if (pool != null)
            return pool.getTunnelCount();
        return 0;
    }
    
    public int getParticipatingCount() { return _context.tunnelDispatcher().getParticipatingCount(); }
    public long getLastParticipatingExpiration() { return _context.tunnelDispatcher().getLastParticipatingExpiration(); }
    
    /**
     *  @return (number of part. tunnels) / (estimated total number of hops in our expl.+client tunnels)
     *  100 max.
     *  We just use length setting, not variance, for speed
     *  @since 0.7.10
     */
    public double getShareRatio() {
        int part = getParticipatingCount();
        if (part <= 0)
            return 0d;
        List<TunnelPool> pools = new ArrayList<TunnelPool>();
        listPools(pools);
        int count = 0;
        for (int i = 0; i < pools.size(); i++) {
            TunnelPool pool = pools.get(i);
            count += pool.size() * pool.getSettings().getLength();
        }
        if (count <= 0)
            return 100d;
        return Math.min(part / (double) count, 100d);
    }


    public boolean isValidTunnel(Hash client, TunnelInfo tunnel) {
        if (tunnel.getExpiration() < _context.clock().now())
            return false;
        TunnelPool pool;
        if (tunnel.isInbound())
            pool = _clientInboundPools.get(client); 
        else
            pool = _clientOutboundPools.get(client); 
        if (pool == null)
            return false;
        return pool.listTunnels().contains(tunnel);
    }

    public TunnelPoolSettings getInboundSettings() { return _inboundExploratory.getSettings(); }
    public TunnelPoolSettings getOutboundSettings() { return _outboundExploratory.getSettings(); }
    public void setInboundSettings(TunnelPoolSettings settings) { _inboundExploratory.setSettings(settings); }
    public void setOutboundSettings(TunnelPoolSettings settings) { _outboundExploratory.setSettings(settings); }

    public TunnelPoolSettings getInboundSettings(Hash client) { 
        TunnelPool pool = _clientInboundPools.get(client); 
        if (pool != null)
            return pool.getSettings();
        else
            return null;
    }

    public TunnelPoolSettings getOutboundSettings(Hash client) { 
        TunnelPool pool = _clientOutboundPools.get(client); 
        if (pool != null)
            return pool.getSettings();
        else
            return null;
    }

    public void setInboundSettings(Hash client, TunnelPoolSettings settings) {
        setSettings(_clientInboundPools, client, settings);
    }

    public void setOutboundSettings(Hash client, TunnelPoolSettings settings) {
        setSettings(_clientOutboundPools, client, settings);
    }

    private static void setSettings(Map<Hash, TunnelPool> pools, Hash client, TunnelPoolSettings settings) {
        TunnelPool pool = pools.get(client); 
        if (pool != null) {
            pool.setSettings(settings);
        }
    }
    
    public synchronized void restart() { 
        _handler.restart();
        _executor.restart();
        shutdownExploratory();
        startup();
    }
        
    /**
     *  Used only at session startup.
     *  Do not use to change settings.
     */
    public void buildTunnels(Destination client, ClientTunnelSettings settings) {
        Hash dest = client.calculateHash();
        if (_log.shouldLog(Log.DEBUG))
            _log.debug("Building tunnels for the client " + dest + ": " + settings);
        TunnelPool inbound = null;
        TunnelPool outbound = null;

        boolean delayOutbound = false;
        // synch with removeTunnels() below
        synchronized (this) {
            inbound = _clientInboundPools.get(dest);
            if (inbound == null) {
                inbound = new TunnelPool(_context, this, settings.getInboundSettings(), 
                                         _clientPeerSelector);
                _clientInboundPools.put(dest, inbound);
            } else {
                inbound.setSettings(settings.getInboundSettings());
            }
            outbound = _clientOutboundPools.get(dest);
            if (outbound == null) {
                outbound = new TunnelPool(_context, this, settings.getOutboundSettings(), 
                                          _clientPeerSelector);
                _clientOutboundPools.put(dest, outbound);
                delayOutbound = true;
            } else {
                outbound.setSettings(settings.getOutboundSettings());
            }
        }
        inbound.startup();
        // don't delay the outbound if it already exists, as this opens up a large
        // race window with removeTunnels() below
        if (delayOutbound)
            _context.simpleScheduler().addEvent(new DelayedStartup(outbound), 1000);
        else
            outbound.startup();
    }
    
    
    private static class DelayedStartup implements SimpleTimer.TimedEvent {
        private final TunnelPool pool;

        public DelayedStartup(TunnelPool p) {
            this.pool = p;
        }

        public void timeReached() {
            this.pool.startup();
        }
    }

    /**
     *  This will be called twice, once by the inbound and once by the outbound pool.
     *  Synched with buildTunnels() above.
     */
    public synchronized void removeTunnels(Hash destination) {
        if (destination == null) return;
        if (_log.shouldLog(Log.DEBUG))
            _log.debug("Removing tunnels for the client " + destination);
        if (_context.clientManager().isLocal(destination)) {
            // race with buildTunnels() on restart of a client
            if (_log.shouldLog(Log.WARN))
                _log.warn("Not removing pool still registered with client manager: " + destination.toBase64(), new Exception("i did it"));
            return;
        }
        TunnelPool inbound = _clientInboundPools.remove(destination);
        TunnelPool outbound = _clientOutboundPools.remove(destination);
        if (inbound != null)
            inbound.shutdown();
        if (outbound != null)
            outbound.shutdown();
    }
    
    /** queue a recurring test job if appropriate */
    void buildComplete(PooledTunnelCreatorConfig cfg) {
        if (cfg.getLength() > 1 &&
            (!_context.router().gracefulShutdownInProgress()) &&
            !_context.getBooleanPropertyDefaultTrue("router.disableTunnelTesting")) {
            TunnelPool pool = cfg.getTunnelPool();
            if (pool == null) {
                // never seen this before, do we reallly need to bother
                // trying so hard to find his pool?
                _log.error("How does this not have a pool?  " + cfg, new Exception("baf"));
                if (cfg.getDestination() != null) {
                    if (cfg.isInbound()) {
                            pool = _clientInboundPools.get(cfg.getDestination());
                    } else {
                            pool = _clientOutboundPools.get(cfg.getDestination());
                    }
                } else {
                    if (cfg.isInbound()) {
                        pool = _inboundExploratory;
                    } else {
                        pool = _outboundExploratory;
                    }
                }
                cfg.setTunnelPool(pool);
            }
            _context.jobQueue().addJob(new TestJob(_context, cfg, pool));
        }
    }

    public synchronized void startup() { 
        _isShutdown = false;
        if (!_executor.isRunning()) {
            I2PThread t = new I2PThread(_executor, "BuildExecutor", true);
            t.start();
            for (int i = 1; i <= _numHandlerThreads; i++) {
                I2PThread hThread = new I2PThread(_handler, "BuildHandler " + i + '/' + _numHandlerThreads, true);
                hThread.start();
            }
        }
        
        _inboundExploratory.startup();
        _context.simpleScheduler().addEvent(new DelayedStartup(_outboundExploratory), 3*1000);
        
        // try to build up longer tunnels
        _context.jobQueue().addJob(new BootstrapPool(_context, _inboundExploratory));
        _context.jobQueue().addJob(new BootstrapPool(_context, _outboundExploratory));
    }
    
    private static class BootstrapPool extends JobImpl {
        private TunnelPool _pool;
        public BootstrapPool(RouterContext ctx, TunnelPool pool) {
            super(ctx);
            _pool = pool;
            getTiming().setStartAfter(ctx.clock().now() + 30*1000);
        }
        public String getName() { return "Bootstrap tunnel pool"; }
        public void runJob() {
            _pool.buildFallback();
        }
    }
    
    /**
     *  Cannot be restarted
     */
    public synchronized void shutdown() { 
        _handler.shutdown(_numHandlerThreads);
        _executor.shutdown();
        shutdownExploratory();
        _isShutdown = true;
    }

    private void shutdownExploratory() {
            _inboundExploratory.shutdown();
            _outboundExploratory.shutdown();
    }
    
    /** list of TunnelPool instances currently in play */
    public void listPools(List<TunnelPool> out) {
        out.addAll(_clientInboundPools.values());
        out.addAll(_clientOutboundPools.values());
        out.add(_inboundExploratory);
        out.add(_outboundExploratory);
    }

    void tunnelFailed() { _executor.repoll(); }

    BuildExecutor getExecutor() { return _executor; }

    boolean isShutdown() { return _isShutdown; }

    public int getInboundBuildQueueSize() { return _handler.getInboundBuildQueueSize(); }
    
    /** @deprecated moved to routerconsole */
    public void renderStatusHTML(Writer out) throws IOException {
    }

    /** @return total number of non-fallback expl. + client tunnels */
    private int countTunnelsPerPeer(ObjectCounter<Hash> lc) {
        List<TunnelPool> pools = new ArrayList<TunnelPool>();
        listPools(pools);
        int tunnelCount = 0;
        for (TunnelPool tp : pools) {
            for (TunnelInfo info : tp.listTunnels()) {
                if (info.getLength() > 1) {
                    tunnelCount++;
                    for (int j = 0; j < info.getLength(); j++) {
                        Hash peer = info.getPeer(j);
                        if (!_context.routerHash().equals(peer))
                            lc.increment(peer);
                    }
                }
            }
        }
        return tunnelCount;
    }

    private static final int DEFAULT_MAX_PCT_TUNNELS = 33;
    /**
     *  For reliability reasons, don't allow a peer in more than x% of
     *  client and exploratory tunnels.
     *
     *  This also will prevent a single huge-capacity (or malicious) peer from
     *  taking all the tunnels in the network (although it would be nice to limit
     *  the % of total network tunnels to 10% or so, but that appears to be
     *  too low to set as a default here... much lower than 33% will push client
     *  tunnels out of the fast tier into high cap or beyond...)
     *
     *  Possible improvement - restrict based on count per IP, or IP block,
     *  to slightly increase costs of collusion
     *
     *  @return Set of peers that should not be allowed in another tunnel
     */
    public Set<Hash> selectPeersInTooManyTunnels() {
        ObjectCounter<Hash> lc = new ObjectCounter<Hash>();
        int tunnelCount = countTunnelsPerPeer(lc);
        Set<Hash> rv = new HashSet<Hash>();
        if (tunnelCount >= 4 && _context.router().getUptime() > 10*60*1000) {
            int max = _context.getProperty("router.maxTunnelPercentage", DEFAULT_MAX_PCT_TUNNELS);
            for (Hash h : lc.objects()) {
                 if (lc.count(h) > 0 && (lc.count(h) + 1) * 100 / (tunnelCount + 1) > max)
                     rv.add(h);
            }
        }
        return rv;
    }

    /** for TunnelRenderer in router console */
    public Map<Hash, TunnelPool> getInboundClientPools() {
            return new HashMap<Hash, TunnelPool>(_clientInboundPools);
    }

    /** for TunnelRenderer in router console */
    public Map<Hash, TunnelPool> getOutboundClientPools() {
            return new HashMap<Hash, TunnelPool>(_clientOutboundPools);
    }

    /**
     *  For TunnelRenderer in router console
     *  @return non-null
     */
    public TunnelPool getInboundExploratoryPool() {
        return _inboundExploratory;
    }

    /**
     *  For TunnelRenderer in router console
     *  @return non-null
     */
    public TunnelPool getOutboundExploratoryPool() {
        return _outboundExploratory;
    }

    /**
     *  Fail all outbound tunnels with this peer as first hop,
     *  and all inbound tunnels with this peer as the last hop,
     *  baecause we can't contact it any more.
     *  This is most likely to be triggered by an outbound tunnel.
     *
     *  @since 0.8.13
     */
    public void fail(Hash peer) {
        failTunnelsWithFirstHop(_outboundExploratory, peer);
        for (TunnelPool pool : _clientOutboundPools.values()) {
            failTunnelsWithFirstHop(pool, peer);
        }
        failTunnelsWithLastHop(_inboundExploratory, peer);
        for (TunnelPool pool : _clientInboundPools.values()) {
            failTunnelsWithLastHop(pool, peer);
        }
    }

    /**
     *  Fail all (outbound) tunnels with this peer as first hop (not counting us)
     *
     *  @since 0.8.13
     */
    private void failTunnelsWithFirstHop(TunnelPool pool, Hash peer) {
        for (TunnelInfo tun : pool.listTunnels()) {
            int len = tun.getLength();
            if (len > 1 && tun.getPeer(1).equals(peer)) {
                if (_log.shouldLog(Log.WARN))
                    _log.warn("Removing OB tunnel, first hop banlisted: " + tun);
                pool.tunnelFailed(tun, peer);
            }
        }
    }

    /**
     *  Fail all (inbound) tunnels with this peer as last hop (not counting us)
     *
     *  @since 0.8.13
     */
    private void failTunnelsWithLastHop(TunnelPool pool, Hash peer) {
        for (TunnelInfo tun : pool.listTunnels()) {
            int len = tun.getLength();
            if (len > 1 && tun.getPeer(len - 2).equals(peer)) {
                if (_log.shouldLog(Log.WARN))
                    _log.warn("Removing IB tunnel, prev. hop banlisted: " + tun);
                pool.tunnelFailed(tun, peer);
            }
        }
    }
}
