/*
 *  PHEX - The pure-java Gnutella-servent.
 *  Copyright (C) 2001 - 2004 Gregor Koukkoullis ( phex <at> kouk <dot> de )
 *
 *  This program is free software; you can redistribute it and/or modify
 *  it under the terms of the GNU General Public License as published by
 *  the Free Software Foundation; either version 2 of the License, or
 *  (at your option) any later version.
 *
 *  This program is distributed in the hope that it will be useful,
 *  but WITHOUT ANY WARRANTY; without even the implied warranty of
 *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *  GNU General Public License for more details.
 *
 *  You should have received a copy of the GNU General Public License
 *  along with this program; if not, write to the Free Software
 *  Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
 * 
 *  --- CVS Information ---
 *  $Id: CaughtHostsContainer.java,v 1.33 2004/09/03 23:25:16 gregork Exp $
 */
package phex.host;

import java.io.*;
import java.util.*;

import phex.common.*;
import phex.connection.NetworkManager;
import phex.event.*;
import phex.gwebcache.*;
import phex.msg.MsgPong;
import phex.security.PhexSecurityManager;
import phex.utils.*;

/**
 * Responsible for holding all caught hosts.
 */
public class CaughtHostsContainer
{
    public static final short HIGH_PRIORITY = 2;
    public static final short NORMAL_PRIORITY = 1;
    public static final short LOW_PRIORITY = 0;
    
    private static final HostAddress[] DEFAULT_STARTUP_HOSTS =
        {
            new HostAddress( "connect1.gnutellanet.com", 6346 ),
            new HostAddress( "connect2.gnutellanet.com", 6346 ),
            new HostAddress( "connect3.gnutellanet.com", 6346 ),
            new HostAddress( "connect4.gnutellanet.com", 6346 ),
            new HostAddress( "connect1.bearshare.net", 6346 ),
            new HostAddress( "connect2.bearshare.net", 6346 ),
            new HostAddress( "connect3.bearshare.net", 6346 )
        };
    
    private PriorityQueue caughtHosts;
    private HashSet uniqueCaughtHosts;
    
    /**
     * The CatchedHostCache provides a container with a limited size.
     * The container stores CaughtHosts ordered by successful connection
     * probability. When the container is full, the element with the
     * lowest priority is dropped.
     */
    private CatchedHostCache catchedHostCache;
    
    /**
     * All listeners interested in events.
     */
    private ArrayList listenerList = new ArrayList( 2 );
    
    private boolean hasChangedSinceLastSave;

    
    ////////////////////////////////START Auto connect host attributes ///////////////////////////////
    private ArrayList autoConnectHosts;
    ////////////////////////////////END Auto connect host attributes ///////////////////////////////
    
    public CaughtHostsContainer()
    {
        int[] capacities = new int[3];
        capacities[ HIGH_PRIORITY ] = (int)Math.round( ServiceManager.sCfg.mNetMaxHostToCatch
            * 2.0 / 3.0 );
        capacities[ NORMAL_PRIORITY ] = (int)Math.round( (ServiceManager.sCfg.mNetMaxHostToCatch
            - capacities[HIGH_PRIORITY]) * 2.0 / 3.0 );
        capacities[ LOW_PRIORITY ] = ServiceManager.sCfg.mNetMaxHostToCatch
            - capacities[HIGH_PRIORITY] - capacities[NORMAL_PRIORITY];
        caughtHosts = new PriorityQueue( capacities );

        uniqueCaughtHosts = new HashSet();
        catchedHostCache = new CatchedHostCache();
        autoConnectHosts = new ArrayList();
        
        Environment.getInstance().scheduleTimerTask(
            new SaveHostsContainerTimer(), SaveHostsContainerTimer.TIMER_PERIOD,
            SaveHostsContainerTimer.TIMER_PERIOD );
    }
    
    /**
     *
     */
    public void initializeCaughtHostsContainer()
    {
        caughtHosts.clear();
        catchedHostCache.clear();
        removeAllAutoConnectHosts();
        hasChangedSinceLastSave = false;
        loadHostsFromFile();
        loadAutoConnectHostsFromFile();
    }

    
    /**
     * Adds a caught host based on the information from a pong message.
     * @param pongMsg the pong message to add the caught host from.
     */
    public synchronized void addCaughtHost( MsgPong pongMsg )
    {
        byte[] pongIP = pongMsg.getIP();
        int port = pongMsg.getPort();
        HostAddress address = new HostAddress( pongIP, port );
        
        boolean valid = isValidCaughtHostAddress( address );
        if ( !valid )
        {
            return;
        }
        
        CaughtHost caughtHost = new CaughtHost( address );
        int dailyUptime = pongMsg.getDailyUptime();
        if ( dailyUptime > 0 )
        {
            caughtHost.setDailyUptime( dailyUptime );
        }
        
        short priority;
        if ( pongMsg.isUltrapeerMarked() )
        {
            priority = CaughtHostsContainer.HIGH_PRIORITY;
        }
        else
        {
            priority = CaughtHostsContainer.LOW_PRIORITY;
        }

        addPersistentCaughtHost( caughtHost );
        addToCaughtHostFetcher( caughtHost, priority );
    }
    
    /**
     * Adds a host address with given priority to the caught hosts.
     */
    public synchronized void addCaughtHost( HostAddress address, short priority )
    {
        boolean valid = isValidCaughtHostAddress( address );
        if ( !valid )
        {
            return;
        }        
        CaughtHost caughtHost = new CaughtHost( address );        
        addPersistentCaughtHost( caughtHost );
        addToCaughtHostFetcher( caughtHost, priority );
    }
    
    /**
     * Adds the caught host to the host catcher that is used for
     * upcomming network connections.
     * The host is added to the top of its priority slot in the host catcher.
     * @param caughtHost the CaughtHost to add.
     * @param priority the priority slot to add the host to.
     */
    private synchronized void addToCaughtHostFetcher( CaughtHost caughtHost,
        short priority )
    {
        // if host is not already in the list
        if ( !uniqueCaughtHosts.contains( caughtHost ) )
        {
            uniqueCaughtHosts.add( caughtHost );

            Object removed = caughtHosts.addToHead( caughtHost, priority );
            if ( removed != null )
            {// cause aging of dropped elements.
                uniqueCaughtHosts.remove( removed );
            }
            fireCaughtHostsChanged( );
        }
    }
    
    /**
     * Adds the caught host to the caught host cache that is stored in the
     * phex.hosts file. The position where the host is added depends on its
     * prioity in the cache.
     * If the host is already part of the cache the daily uptime is updated. 
     * @param caughtHost the caught host to add or update with.
     */
    private synchronized void addPersistentCaughtHost( CaughtHost caughtHost )
    {        
        HostAddress address = caughtHost.getHostAddress();
        CaughtHost existingHost = (CaughtHost)catchedHostCache.getCaughHost( address );
        if ( existingHost == null )
        {
            catchedHostCache.add( caughtHost );
        }
        else
        {
            // update daily uptime..
            // to maintain correct order first remove... 
            catchedHostCache.remove( existingHost );
            // then modify...
            if ( caughtHost.getDailyUptime() > 0 )
            {
                existingHost.setDailyUptime( caughtHost.getDailyUptime() );
            }
            // then add...
            catchedHostCache.add( existingHost );
        }
        hasChangedSinceLastSave = true;
    }
    
    /**
     * The number of caught hosts that are ready to be used for upcomming
     * network connections.
     * @return the number of caught hosts.
     */
    public synchronized int getCaughtHostsCount()
    {
        return caughtHosts.getSize();
    }
    
    /**
     * Removes and returns the next caught host for network connection.
     * A call to this method also tryes to ensure that there are always
     * enought hosts availble for networks connections. 
     * @return the netxt top most host ready for network connection. 
     */
    public synchronized HostAddress getNextCaughtHost()
    {
        CaughtHost host;
        ensureMinCaughHosts();
        if ( !caughtHosts.isEmpty() )
        {
            host = (CaughtHost)caughtHosts.removeMaxPriority();
            return host.getHostAddress();
        }
        // host list is empty
        addAutoConnectHosts();
        // actually addAutoConnectHosts() should have made sure there is a host
        // available but for some user configs all hosts might have been filtered.
        // In this case we need to wait until a GWebCache reports IP's hopefully.
        if ( !caughtHosts.isEmpty() )
        {
            host = (CaughtHost)caughtHosts.removeMaxPriority();
            return host.getHostAddress();
        }
        return null;
    }
    
    /**
     * Reports the connection status of a just tried network connection to a
     * HostAddress, this is used to update the CatchedHostCache.
     * @param hostAddress the host address to report the connection status for.
     * @param successfulConnection indicates if the connection was successful or not.
     */
    public synchronized void reportConnectionStatus( HostAddress hostAddress,
        boolean successfulConnection )
    {
        boolean valid = isValidCaughtHostAddress( hostAddress );
        if ( !valid )
        {
            return;
        }
        CaughtHost existingHost = catchedHostCache.getCaughHost( hostAddress );
        
        if ( existingHost == null )
        {
            existingHost = new CaughtHost( hostAddress ); 
        }
        else
        {
            // to maintain correct order first remove... 
            catchedHostCache.remove( existingHost );
        }
        
        // then modify...
        if (successfulConnection)
        {
            existingHost.setLastSuccessfulConnection( System.currentTimeMillis() );
        }
        else
        {
            existingHost.setLastFailedConnection( System.currentTimeMillis() );
        }

        // then add...
        catchedHostCache.add( existingHost );
        hasChangedSinceLastSave = true;
    }

    /**
     * Makes sure that at least one percent of the max number of hosts
     * are in the host catcher otherwise a GWebCache is queried.
     */
    private void ensureMinCaughHosts()
    {
        int minCount = (int)Math.ceil(
            (double)ServiceManager.sCfg.mNetMaxHostToCatch/100.0 );
        if ( caughtHosts.getSize() < minCount )
        {
            // connect GWebCache for new hosts...
            GWebCacheManager.getInstance().invokeQueryMoreHostsRequest();
        }
    }
    
    /**
     * Validates a host address if it is acceptable for the host catcher.
     * A valid address has a valid port and ip, has no localhost ip or 
     * private ip, and has a port that is not user banned.
     * @param address the address to validate
     * @return true if the address is valid, false otherwise.
     */
    private boolean isValidCaughtHostAddress( HostAddress address )
    {
        // invalid port.. can't connect to...
        if ( address.getPort() == -1 || address.isLocalHost()
            || !address.isValidIP() || address.isPrivateIP() )
        {
            return false;
        }
        if ( IPUtils.isPortInUserInvalidList( address ) )
        {
            return false;
        }
        return true;
    }
    
    /**
     * Loads the hosts file phex.hosts.
     */
    private void loadHostsFromFile()
    {
        Logger.logMessage( Logger.FINE, Logger.NETWORK,
            "Loading hosts file." );
        try
        {
            NetworkManager networkMgr = NetworkManager.getInstance();
            File file = networkMgr.getGnutellaNetwork().getHostsFile();
            BufferedReader br;
            if ( file.exists() )
            {
                br = new BufferedReader( new FileReader(file) );
            }
            else
            {
                Logger.logMessage( Logger.FINE, Logger.NETWORK,
                    "Load default hosts file." );
                InputStream inStream = ClassLoader.getSystemResourceAsStream(
                    "phex/resources/phex.hosts" );
                if ( inStream != null )
                {
                    br = new BufferedReader( new InputStreamReader( inStream ) );
                }
                else
                {
                    Logger.logMessage( Logger.FINE, Logger.NETWORK,
                        "Default Phex Hosts file not found." );
                    return;
                }
            }

            PhexSecurityManager securityMgr = PhexSecurityManager.getInstance();
            String line;
            short usedPriority = LOW_PRIORITY;
            while ( (line = br.readLine()) != null )
            {
                if ( line.startsWith("#") )
                {
                    continue;
                }
                
                CaughtHost caughtHost = parseCaughtHostFromLine( line );
                if ( caughtHost == null )
                {
                    continue;
                }
                
                byte access = securityMgr.controlHostAddressAccess( caughtHost.getHostAddress() );
                switch ( access )
                {
                    case PhexSecurityManager.ACCESS_DENIED:
                    case PhexSecurityManager.ACCESS_STRONGLY_DENIED:
                        // skip host address...
                        continue;
                }
                if ( IPUtils.isPortInUserInvalidList( caughtHost.getHostAddress() ) )
                {
                    continue;
                }
                addPersistentCaughtHost( caughtHost );
                if ( usedPriority != HIGH_PRIORITY && caughtHosts.isFull( usedPriority ) )
                {
                    // goes from lowest priority (0) to highest (2)
                    usedPriority ++;
                }
                addToCaughtHostFetcher( caughtHost, usedPriority );
            }
            br.close();
        }
        catch ( IOException exp )
        {
            Logger.logWarning( exp );
        }
    }
    
    /**
     * Returns the CaughtHost that can be parsed from the line, or 
     * null if parsing failed for some reason.
     * @param line
     * @return
     */
    private CaughtHost parseCaughtHostFromLine( String line )
    {
        // tokenize line
        // line format can be:
        // IP:port         or:
        // IP:port,lastFailedConnection,lastSuccessfulConnection,dailyUptime
        StringTokenizer tokenizer = new StringTokenizer( line, "," );
        int tokenCount = tokenizer.countTokens();
        
        String hostAddressStr;
        int dailyUptime;
        long lastFailedconnection;
        long lastSuccessfulConnection;
        if ( tokenCount == 1 )
        {
            hostAddressStr = line;
            dailyUptime = -1;
            lastFailedconnection = -1;
            lastSuccessfulConnection = -1;                    
        }
        else if ( tokenCount == 4 )
        {
            hostAddressStr = tokenizer.nextToken();
            try
            {
                lastFailedconnection = Long.parseLong( tokenizer.nextToken() );
            }
            catch ( NumberFormatException exp )
            {
                lastFailedconnection = -1;
            }
            try
            {
                lastSuccessfulConnection = Long.parseLong( tokenizer.nextToken() );
            }
            catch ( NumberFormatException exp )
            {
                lastSuccessfulConnection = -1;
            }
            try
            {
                dailyUptime = Integer.parseInt( tokenizer.nextToken() );
            }
            catch ( NumberFormatException exp )
            {
                dailyUptime = -1;
            }
        }
        else
        {// Unknown format
            Logger.logWarning( Logger.NETWORK, "Unknown HostCache line format: " + line );
            return null;
        }
        byte[] ip = HostAddress.parseIP( hostAddressStr );
        if ( ip == null )
        {
            return null;
        }
        int port = HostAddress.parsePort( hostAddressStr );
        if ( port == -1 )
        {
            return null;
        }
        HostAddress hostAddress = new HostAddress( ip, port );
        CaughtHost caughtHost = new CaughtHost( hostAddress );
        
        if ( dailyUptime > 0 )
        {
            caughtHost.setDailyUptime( dailyUptime );
        }
        if ( lastFailedconnection > 0 )
        {
            caughtHost.setLastFailedConnection( lastFailedconnection );
        }
        if ( lastSuccessfulConnection > 0 )
        {
            caughtHost.setLastSuccessfulConnection( lastSuccessfulConnection );
        }
        
        return caughtHost;
    }
    
    /**
     * Blocking operation which saves the caught hosts and auto connect hosts
     * if they changed since the last save operation.
     */
    public synchronized void saveHostsContainer( )
    {
        if ( !hasChangedSinceLastSave )
        {
            return;
        }
        saveCaughtHosts();
        saveAutoConnectHosts();
        hasChangedSinceLastSave = false;
    }

    /**
     * The caught hosts are saved from the persistent host cache container in 
     * reverse order. Since when loaded back the last element will be on top of
     * the caught host priority queue. 
     */
    private synchronized void saveCaughtHosts()
    {
        try
        {
            NetworkManager networkMgr = NetworkManager.getInstance();
            File file = networkMgr.getGnutellaNetwork().getHostsFile();
            BufferedWriter bw = new BufferedWriter(new FileWriter(file));

            Iterator iterator = catchedHostCache.iterator();
            while( iterator.hasNext() )
            {
                CaughtHost host = (CaughtHost)iterator.next();
                HostAddress hostAddress = host.getHostAddress();
                if ( !isInStartupHosts( hostAddress ) )
                {
                    // line format can be:
                    // IP:port         or:
                    // IP:port,lastFailedConnection,lastSuccessfulConnection,
                    //         dailyUptime
                    bw.write( hostAddress.getFullHostName() +
                        "," + host.getLastFailedConnection() +
                        "," + host.getLastSuccessfulConnection() +
                        "," + host.getDailyUptime()  );
                    
                    bw.newLine();
                }
            }
            bw.close();
        }
        catch ( IOException exp )
        {
            Logger.logError( exp );
        }
    }
    
    private boolean isInStartupHosts( HostAddress hostAddress )
    {
        for ( int i = 0; i < DEFAULT_STARTUP_HOSTS.length; i++ )
        {
            if ( hostAddress.equals( DEFAULT_STARTUP_HOSTS[i] ) )
            {
                return true;
            }
        }
        int length = autoConnectHosts.size();
        for ( int i = 0; i < length; i++ )
        {
            if ( hostAddress.equals( autoConnectHosts.get( i ) ) )
            {
                return true;
            }
        }
        return false;
    }
    
    //////////////////////START Auto connect host methods //////////////////////
    
    public synchronized int getAutoConnectHostsCount()
    {
        return autoConnectHosts.size();
    }

    public synchronized HostAddress getAutoConnectHostAt( int index )
    {
        if ( index >= autoConnectHosts.size() )
        {
            return null;
        }
        return (HostAddress) autoConnectHosts.get( index );
    }

    public synchronized void removeAllAutoConnectHosts()
    {
        for ( int i = autoConnectHosts.size() - 1; i >= 0; i-- )
        {
            removeAutoConnectHost( (HostAddress) autoConnectHosts.get( i ) );
        }
    }

    /**
     * Adds a auto connect host.
     */
    public synchronized void addAutoConnectHost( HostAddress host )
    {
        insertAutoConnectHost( host, autoConnectHosts.size() );
    }

    /**
     * inserts a auto connect host and fires the add event..
     */
    private synchronized void insertAutoConnectHost( HostAddress host, int position )
    {
        // if host is not already in the list
        if ( !autoConnectHosts.contains( host ) )
        {
            autoConnectHosts.add( position, host );
            hasChangedSinceLastSave = true;
            fireAutoConnectHostAdded( position );
        }
    }

    public synchronized void removeAutoConnectHost( HostAddress host )
    {
        int position = autoConnectHosts.indexOf( host );
        if ( position >= 0 )
        {
            autoConnectHosts.remove( position );
            fireAutoConnectHostRemoved( position );
            hasChangedSinceLastSave = true;
        }
    }
    
    private synchronized void addAutoConnectHosts()
    {
        NetworkManager networkMgr = NetworkManager.getInstance();
        if ( networkMgr.getGnutellaNetwork() instanceof GeneralGnutellaNetwork )
        {
            int size = autoConnectHosts.size();
            for ( int i = size - 1; i >= 0; i-- )
            {
                CaughtHost caughtHost = new CaughtHost( (HostAddress)autoConnectHosts.get( i ) );
                addToCaughtHostFetcher( caughtHost, HIGH_PRIORITY );
            }
            if ( size == 0 )
            {
                addDefaultAutoConnectHosts();
            }
        }
    }

    private synchronized void addDefaultAutoConnectHosts()
    {
        NetworkManager networkMgr = NetworkManager.getInstance();
        if ( networkMgr.getGnutellaNetwork() instanceof GeneralGnutellaNetwork )
        {
            for (int i = DEFAULT_STARTUP_HOSTS.length - 1; i >= 0 ; i--)
            {
                CaughtHost caughtHost = new CaughtHost( DEFAULT_STARTUP_HOSTS[i] );
                addToCaughtHostFetcher( caughtHost, HIGH_PRIORITY );
            }
        }
    }
    
    private void loadAutoConnectHostsFromFile()
    {
        try
        {
            File file = Environment.getInstance().getPhexConfigFile(
                EnvironmentConstants.AUTOCONNECT_HOSTS_FILE_NAME );
            if ( !file.exists() )
            {
                copyDefaultAutoConnectHosts();
                return;
            }
            BufferedReader br = new BufferedReader( new FileReader( file ) );
            String line;

            while ( (line = br.readLine()) != null)
            {
                if ( line.startsWith("#") )
                {
                    continue;
                }
                try
                {
                    insertAutoConnectHost( new HostAddress( line ),
                        autoConnectHosts.size() );
                }
                catch ( MalformedHostAddressException exp )
                {
                }
            }
            br.close();
        }
        catch ( IOException exp )
        {
            Logger.logError( exp );
        }
    }
    
    private void copyDefaultAutoConnectHosts()
    {
        for ( int i = 0; i < DEFAULT_STARTUP_HOSTS.length; i++ )
        {
            addAutoConnectHost( DEFAULT_STARTUP_HOSTS[i] );
        }
    }
    
    private synchronized void saveAutoConnectHosts()
    {
        try
        {
            File file = Environment.getInstance().getPhexConfigFile(
                EnvironmentConstants.AUTOCONNECT_HOSTS_FILE_NAME );
            BufferedWriter bw = new BufferedWriter(new FileWriter(file));

            for ( int i = 0; i < autoConnectHosts.size(); i++ )
            {
                HostAddress host = (HostAddress)autoConnectHosts.get(i);
                bw.write( host.getFullHostName() );
                bw.newLine();
            }
            bw.close();
        }
        catch ( IOException exp )
        {
            Logger.logError( exp );
        }
    }

    ////////////////////////END Auto connect host methods //////////////////////
    
    
    ///////////////////// START event handling methods ////////////////////////
    public void addCaughtHostsChangeListener( CaughtHostsChangeListener listener )
    {
        listenerList.add( listener );
    }

    public void removeCaughtHostsChangeListener( CaughtHostsChangeListener listener )
    {
        listenerList.remove( listener );
    }

    /*private void fireCaughtHostAdded( final int position )
    {
        // invoke update in event dispatcher
        AsynchronousDispatcher.invokeLater(
        new Runnable()
        {
            public void run()
            {
                Object[] listeners = listenerList.toArray();
                CaughtHostsChangeListener listener;
                // Process the listeners last to first, notifying
                // those that are interested in this event
                for ( int i = listeners.length - 1; i >= 0; i-- )
                {
                    listener = (CaughtHostsChangeListener)listeners[ i ];
                    listener.caughtHostAdded( position );
                }
            }
        });
    }

    private void fireCaughtHostRemoved( final int position )
    {
        // invoke update in event dispatcher
        AsynchronousDispatcher.invokeLater(
        new Runnable()
        {
            public void run()
            {
                Object[] listeners = listenerList.toArray();
                CaughtHostsChangeListener listener;
                // Process the listeners last to first, notifying
                // those that are interested in this event
                for ( int i = listeners.length - 1; i >= 0; i-- )
                {
                    listener = (CaughtHostsChangeListener)listeners[ i ];
                    listener.caughtHostRemoved( position );
                }
            }
        });
    }*/

    private void fireCaughtHostsChanged( )
    {
        // invoke update in event dispatcher
        AsynchronousDispatcher.invokeLater(
        new Runnable()
        {
            public void run()
            {
                Object[] listeners = listenerList.toArray();
                CaughtHostsChangeListener listener;
                // Process the listeners last to first, notifying
                // those that are interested in this event
                for ( int i = listeners.length - 1; i >= 0; i-- )
                {
                    listener = (CaughtHostsChangeListener)listeners[ i ];
                    listener.caughtHostsChanged( );
                }
            }
        });
    }

    private void fireAutoConnectHostAdded( final int position )
    {
        // invoke update in event dispatcher
        AsynchronousDispatcher.invokeLater(
        new Runnable()
        {
            public void run()
            {
                Object[] listeners = listenerList.toArray();
                CaughtHostsChangeListener listener;
                // Process the listeners last to first, notifying
                // those that are interested in this event
                for ( int i = listeners.length - 1; i >= 0; i-- )
                {
                    listener = (CaughtHostsChangeListener)listeners[ i ];
                    listener.autoConnectHostAdded( position );
                }
            }
        });
    }

    private void fireAutoConnectHostRemoved( final int position )
    {
        // invoke update in event dispatcher
        AsynchronousDispatcher.invokeLater(
        new Runnable()
        {
            public void run()
            {
                Object[] listeners = listenerList.toArray();
                CaughtHostsChangeListener listener;
                // Process the listeners last to first, notifying
                // those that are interested in this event
                for ( int i = listeners.length - 1; i >= 0; i-- )
                {
                    listener = (CaughtHostsChangeListener)listeners[ i ];
                    listener.autoConnectHostRemoved( position );
                }
            }
        });
    }

    ////////////////////// END event handling methods //////////////////////////
    
    ////////////////////// START inner classes //////////////////////////
    
    private class SaveHostsContainerRunner implements Runnable
    {
        public void run()
        {
            saveHostsContainer();
        }
    }
    
    private class SaveHostsContainerTimer extends TimerTask
    {
        // once per minute
        public static final long TIMER_PERIOD = 1000 * 60;
        
        public void run()
        {
            try
            {
                // trigger the save inside a background job to not
                // slow down the timer too much
                ThreadPool.getInstance().addJob( new SaveHostsContainerRunner(),
                    "SaveHostsContainer" );
            }
            catch ( Throwable th )
            {
                Logger.logError( th );
            }
        }
    }
}