package net.i2p.router.time;

import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.StringTokenizer;
import java.util.concurrent.CopyOnWriteArrayList;

import net.i2p.I2PAppContext;
import net.i2p.time.Timestamper;
import net.i2p.util.Addresses;
import net.i2p.util.I2PThread;
import net.i2p.util.Log;

/**
 * Periodically query a series of NTP servers and update any associated
 * listeners.  It tries the NTP servers in order, contacting them using 
 * SNTP (UDP port 123).
 *
 * @since 0.9.1 moved from net.i2p.time
 */
public class RouterTimestamper extends Timestamper {
    private final I2PAppContext _context;
    // warning, may be null
    private Log _log;
    private final List<String> _servers;
    private List<List<String>> _priorityServers;
    private final List<UpdateListener> _listeners;
    private int _queryFrequency;
    private int _concurringServers;
    private int _consecutiveFails;
    private volatile boolean _disabled;
    private final boolean _daemon;
    private volatile boolean _initialized;
    private boolean _wellSynced;
    private volatile boolean _isRunning;
    private Thread _timestamperThread;
    private final Zones _zones;
    
    private static final int MIN_QUERY_FREQUENCY = 5*60*1000;
    private static final int DEFAULT_QUERY_FREQUENCY = 11*60*1000;
    private static final String DEFAULT_SERVER_LIST = "0.pool.ntp.org,1.pool.ntp.org,2.pool.ntp.org";
    private static final boolean DEFAULT_DISABLED = true;
    /** how many times do we have to query if we are changing the clock? */
    private static final int DEFAULT_CONCURRING_SERVERS = 3;
    private static final int MAX_CONSECUTIVE_FAILS = 10;
    private static final int DEFAULT_TIMEOUT = 10*1000;
    private static final int SHORT_TIMEOUT = 5*1000;
    private static final long MAX_WAIT_INITIALIZATION = 45*1000;
    
    public static final String PROP_QUERY_FREQUENCY = "time.queryFrequencyMs";
    public static final String PROP_SERVER_LIST = "time.sntpServerList";
    public static final String PROP_DISABLED = "time.disabled";
    public static final String PROP_CONCURRING_SERVERS = "time.concurringServers";
    public static final String PROP_IP_COUNTRY = "i2np.lastCountry";
    
    /** if different SNTP servers differ by more than 10s, someone is b0rked */
    private static final int MAX_VARIANCE = 10*1000;
        
    /**
     *  Does not start. Caller MUST call startTimestamper()
     */
    public RouterTimestamper(I2PAppContext ctx) {
        this(ctx, null, true);
    }
    
    /**
     *  Does not start. Caller MUST call startTimestamper()
     */
    public RouterTimestamper(I2PAppContext ctx, UpdateListener lsnr) {
        this(ctx, lsnr, true);
    }

    /**
     *  Does not start. Caller MUST call startTimestamper()
     */
    public RouterTimestamper(I2PAppContext ctx, UpdateListener lsnr, boolean daemon) {
        super();
        // moved here to prevent problems with synchronized statements.
        _servers = new ArrayList<String>(3);
        _listeners = new CopyOnWriteArrayList<UpdateListener>();
        _context = ctx;
        _daemon = daemon;
        // DO NOT initialize _log here, stack overflow via LogManager init loop

        // Don't bother starting a thread if we are disabled.
        // This means we no longer check every 5 minutes to see if we got enabled,
        // so the property must be set at startup.
        // We still need to be instantiated since the router calls clock().getTimestamper().waitForInitialization()
        _disabled = ctx.getProperty(PROP_DISABLED, DEFAULT_DISABLED) ||
                    ctx.getBooleanProperty("i2p.vmCommSystem");
        if (_disabled) {
            _initialized = true;
            _zones = null;
            System.out.println("Warning: NTP is disabled");
            return;
        }
        if (lsnr != null)
            _listeners.add(lsnr);
        _zones = new Zones(ctx);
        updateConfig();
    }
    
    public int getServerCount() { 
        synchronized (_servers) {
            return _servers.size(); 
        }
    }
    public String getServer(int index) { 
        synchronized (_servers) {
            return _servers.get(index); 
        }
    }
    
    public int getQueryFrequencyMs() { return _queryFrequency; }
    
    public boolean getIsDisabled() { return _disabled; }
    
    public void addListener(UpdateListener lsnr) {
            _listeners.add(lsnr);
    }
    public void removeListener(UpdateListener lsnr) {
            _listeners.remove(lsnr);
    }
    public int getListenerCount() {
            return _listeners.size();
    }
    public UpdateListener getListener(int index) {
            return _listeners.get(index);
    }
    
    public void startTimestamper() {
        if (_disabled || _initialized)
            return;
        _timestamperThread = new I2PThread(this, "Timestamper", _daemon);
        _timestamperThread.setPriority(I2PThread.MIN_PRIORITY);
        _isRunning = true;
        _timestamperThread.start();
        _context.addShutdownTask(new Shutdown());
    }
    
    @Override
    public void waitForInitialization() {
        try { 
            synchronized (this) {
                if (!_initialized)
                    wait(MAX_WAIT_INITIALIZATION);
            }
        } catch (InterruptedException ie) {}
    }
    
    /**
     *  Update the time immediately.
     *  @since 0.8.8
     */
    @Override
    public void timestampNow() {
         if (_initialized && _isRunning && (!_disabled) && _timestamperThread != null)
             _timestamperThread.interrupt();
    }
    
    /** @since 0.8.8 */
    private class Shutdown implements Runnable {
        public void run() {
             _isRunning = false;
             if (_timestamperThread != null)
                 _timestamperThread.interrupt();
        }
    }
    
    @Override
    public void run() {
        boolean lastFailed = false;
        try {
            while (_isRunning) {
                // NOTE: _log is null the first time through, to prevent problems and stack overflows
                updateConfig();
                boolean preferIPv6 = Addresses.isConnectedIPv6();
                if (!_disabled) {
                    // first the servers for our country and continent, we know what country we're in...
                    if (_priorityServers != null) {
                        for (List<String> servers : _priorityServers) {
                            if (_log != null && _log.shouldDebug())
                                _log.debug("Querying servers " + servers);
                            try {
                                lastFailed = !queryTime(servers.toArray(new String[servers.size()]), SHORT_TIMEOUT, preferIPv6);
                            } catch (IllegalArgumentException iae) {
                                if (!lastFailed && _log != null && _log.shouldWarn())
                                    _log.warn("Unable to reach any regional NTP servers: " + servers);
                                lastFailed = true;
                            }
                            if (!lastFailed)
                                break;
                        }
                    }
                    // ... and then the global list, if that failed
                    if (_priorityServers == null || lastFailed) {
                        if (_log != null && _log.shouldDebug())
                            _log.debug("Querying servers " + _servers);
                        try {
                            // If we failed, maybe it's because IPv6 is blocked, so try IPv4 only
                            // also first time through, and randomly
                            boolean prefIPv6 = preferIPv6 && !lastFailed && _log != null && _context.random().nextInt(4) != 0;
                            lastFailed = !queryTime(_servers.toArray(new String[_servers.size()]), DEFAULT_TIMEOUT, prefIPv6);
                        } catch (IllegalArgumentException iae) {
                            lastFailed = true;
                        }
                    }
                }
                
                boolean wasInitialized;
                synchronized (this) {
                    wasInitialized = _initialized;
                    if (!wasInitialized)
                        _initialized = true;
                    notifyAll();
                }
                if (!wasInitialized) {
                    // let the log manager get initialized
                    try { Thread.sleep(10*1000); } catch (InterruptedException ie) {}
                    // NOW we set up logging
                    _log = _context.logManager().getLog(RouterTimestamper.class);
                    if (lastFailed) {
                        List<String> all = new ArrayList<String>(9);
                        if (_priorityServers != null) {
                            for (List<String> servers : _priorityServers) {
                                all.addAll(servers);
                            }
                        }
                        all.addAll(_servers);
                        String msg = "Unable to reach any of the NTP servers " + all +
                                     " - network disconnected? Or set time.sntpServerList=myserver1.com,myserver2.com in advanced configuration.";
                        _log.logAlways(Log.WARN, msg);
                        System.out.println("Warning: " + msg);
                    } else if (_log.shouldDebug()) {
                        _log.debug("NTP initialization successful");
                        int i = 1;
                        if (_priorityServers != null) {
                            for (List<String> servers : _priorityServers) {
                                _log.debug("NTP Server list " + (i++) + ": " + servers);
                            }
                        }
                        _log.debug("NTP Server list " + i + ": " + _servers);
                    }
                }
                long sleepTime;
                if (lastFailed) {
                    if (++_consecutiveFails >= MAX_CONSECUTIVE_FAILS)
                        sleepTime = 30*60*1000;
                    else
                        sleepTime = 30*1000;
                } else {
                    _consecutiveFails = 0;
                    sleepTime = _context.random().nextInt(_queryFrequency / 2) + _queryFrequency;
                    if (_wellSynced)
                        sleepTime *= 3;
                }
                try { Thread.sleep(sleepTime); } catch (InterruptedException ie) {}
            }
        } catch (Throwable t) {
            synchronized (this) { notifyAll(); }
            if (_log != null)
                _log.log(Log.CRIT, "Timestamper died!", t);
            t.printStackTrace();
        }
    }
    
    /**
     * True if the time was queried successfully, false if it couldn't be
     */
    private boolean queryTime(String serverList[], int perServerTimeout, boolean preferIPv6) throws IllegalArgumentException {
        long found[] = new long[_concurringServers];
        long now = -1;
        int stratum = -1;
        long expectedDelta = 0;
        _wellSynced = false;
        for (int i = 0; i < _concurringServers; i++) {
            //if (i > 0) {
            //    // this delays startup when net is disconnected or the timeserver list is bad, don't make it too long
            //    try { Thread.sleep(2*1000); } catch (InterruptedException ie) {}
            //}
            long[] timeAndStratum = NtpClient.currentTimeAndStratum(serverList, perServerTimeout, preferIPv6, _log);
            now = timeAndStratum[0];
            stratum = (int) timeAndStratum[1];
            long delta = now - _context.clock().now();
            found[i] = delta;
            if (i == 0) {
                if (Math.abs(delta) < MAX_VARIANCE) {
                    if (_log != null && _log.shouldInfo())
                        _log.info("a single SNTP query was within the tolerance (" + delta + "ms)");
                    // If less than a half second on the first try, we're in good shape
                    _wellSynced = Math.abs(delta) < 500;
                    break;
                } else {
                    // outside the tolerance, lets iterate across the concurring queries
                    expectedDelta = delta;
                }
            } else {
                if (Math.abs(delta - expectedDelta) > MAX_VARIANCE) {
                    if (_log != null && _log.shouldError()) {
                        StringBuilder err = new StringBuilder(96);
                        err.append("SNTP client variance exceeded at query ").append(i);
                        err.append(".  expected = ");
                        err.append(expectedDelta);
                        err.append(", found = ");
                        err.append(delta);
                        err.append(" all deltas: ");
                        for (int j = 0; j < found.length; j++)
                            err.append(found[j]).append(' ');
                        _log.error(err.toString());
                    }
                    return false;
                }
            }
        }
        stampTime(now, stratum);
        if (_log != null && _log.shouldDebug()) {
            StringBuilder buf = new StringBuilder(64);
            buf.append("Deltas: ");
            for (int i = 0; i < found.length; i++)
                buf.append(found[i]).append(' ');
            _log.debug(buf.toString());
        }
        return true;
    }
    
    /**
     * Notify the listeners
     *
     * @since stratum param added in 0.7.12
     */
    private void stampTime(long now, int stratum) {
        long before = _context.clock().now();
        for (UpdateListener lsnr : _listeners) {
             lsnr.setNow(now, stratum);
        }
        if (_log != null && _log.shouldDebug())
            _log.debug("Stamped the time as " + now + " (delta=" + (now-before) + ")");
    }
 
    /**
     * Reload all the config elements from the appContext.
     * No logging allowed here
     */
    private void updateConfig() {
        String serverList = _context.getProperty(PROP_SERVER_LIST);
        if ( (serverList == null) || (serverList.trim().length() <= 0) ) {
            serverList = DEFAULT_SERVER_LIST;
            String country = _context.getProperty(PROP_IP_COUNTRY);
            if (country == null) {
                country = Locale.getDefault().getCountry();
                if (country != null)
                    country = country.toLowerCase(Locale.US);
            }
            if (country != null &&  country.length() > 0 &&
                !country.equals("a1") && !country.equals("a2")) {
                _priorityServers = new ArrayList<List<String>>(2);
                List<String> p1 = new ArrayList<String>(3);
                for (int i = 0; i < 3; i++) {
                     p1.add(i + "." + country + ".pool.ntp.org");
                }
                _priorityServers.add(p1);
                String zone = _zones.getZone(country);
                if (zone != null) {
                    List<String> p2 = new ArrayList<String>(3);
                    for (int i = 0; i < 3; i++) {
                        p2.add(i + "." + zone + ".pool.ntp.org");
                    }
                    _priorityServers.add(p2);
                }
            } else {
                _priorityServers = null;
            }
        } else {
            _priorityServers = null;
        }
        _servers.clear();
        StringTokenizer tok = new StringTokenizer(serverList, ", ");
        while (tok.hasMoreTokens()) {
            String val = tok.nextToken();
            val = val.trim();
            if (val.length() > 0)
                _servers.add(val);
        }
        
        _queryFrequency = Math.max(MIN_QUERY_FREQUENCY,
                                   _context.getProperty(PROP_QUERY_FREQUENCY, DEFAULT_QUERY_FREQUENCY));
        
        _disabled = _context.getProperty(PROP_DISABLED, DEFAULT_DISABLED);
        
        _concurringServers = Math.min(4, Math.max(1,
                              _context.getProperty(PROP_CONCURRING_SERVERS, DEFAULT_CONCURRING_SERVERS)));
    }
    
/****
    public static void main(String args[]) {
        System.setProperty(PROP_DISABLED, "false");
        System.setProperty(PROP_QUERY_FREQUENCY, "30000");
        I2PAppContext.getGlobalContext();
        for (int i = 0; i < 5*60*1000; i += 61*1000) {
            try { Thread.sleep(61*1000); } catch (InterruptedException ie) {}
        }
    }
****/
}
