/* TrackerClient - Class that informs a tracker and gets new peers.
   Copyright (C) 2003 Mark J. Wielaard

   This file is part of Snark.
   
   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, 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.
*/

package org.klomp.snark;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.Random;
import java.util.Set;

import net.i2p.I2PAppContext;
import net.i2p.util.I2PAppThread;
import net.i2p.util.Log;

/**
 * Informs metainfo tracker of events and gets new peers for peer
 * coordinator.
 *
 * @author Mark Wielaard (mark@klomp.org)
 */
public class TrackerClient extends I2PAppThread
{
  private final Log _log = I2PAppContext.getGlobalContext().logManager().getLog(TrackerClient.class);
  private static final String NO_EVENT = "";
  private static final String STARTED_EVENT = "started";
  private static final String COMPLETED_EVENT = "completed";
  private static final String STOPPED_EVENT = "stopped";
  private static final String NOT_REGISTERED  = "torrent not registered"; //bytemonsoon

  private final static int SLEEP = 5; // 5 minutes.
  private final static int DELAY_MIN = 2000; // 2 secs.
  private final static int DELAY_MUL = 1500; // 1.5 secs.
  private final static int MAX_REGISTER_FAILS = 10; // * INITIAL_SLEEP = 15m to register
  private final static int INITIAL_SLEEP = 90*1000;
  private final static int MAX_CONSEC_FAILS = 5;    // slow down after this
  private final static int LONG_SLEEP = 30*60*1000; // sleep a while after lots of fails

  private I2PSnarkUtil _util;
  private final MetaInfo meta;
  private final PeerCoordinator coordinator;
  private final int port;

  private boolean stop;
  private boolean started;

  private List trackers;

  public TrackerClient(I2PSnarkUtil util, MetaInfo meta, PeerCoordinator coordinator)
  {
    // Set unique name.
    super("TrackerClient-" + urlencode(coordinator.getID()));
    _util = util;
    this.meta = meta;
    this.coordinator = coordinator;

    this.port = 6881; //(port == -1) ? 9 : port;

    stop = false;
    started = false;
  }

    @Override
  public void start() {
      if (stop) throw new RuntimeException("Dont rerun me, create a copy");
      super.start();
      started = true;
  }
  
  public boolean halted() { return stop; }
  public boolean started() { return started; }
  
  /**
   * Interrupts this Thread to stop it.
   */
  public void halt()
  {
    stop = true;
    this.interrupt();
  }

  private boolean verifyConnected() {
    while (!stop && !_util.connected()) {
        boolean ok = _util.connect();
        if (!ok) {
            try { Thread.sleep(30*1000); } catch (InterruptedException ie) {}
        }
    }
    return !stop && _util.connected();
  }
  
    @Override
  public void run()
  {
    String infoHash = urlencode(meta.getInfoHash());
    String peerID = urlencode(coordinator.getID());

    _log.debug("Announce: [" + meta.getAnnounce() + "] infoHash: " + infoHash);
    
    // Construct the list of trackers for this torrent,
    // starting with the primary one listed in the metainfo,
    // followed by the secondary open trackers
    // It's painful, but try to make sure if an open tracker is also
    // the primary tracker, that we don't add it twice.
    // todo: check for b32 matches as well
    trackers = new ArrayList(2);
    String primary = meta.getAnnounce();
    if (isValidAnnounce(primary)) {
        trackers.add(new Tracker(meta.getAnnounce(), true));
    } else {
        _log.warn("Skipping invalid or non-i2p announce: " + primary);
    }
    List tlist = _util.getOpenTrackers();
    if (tlist != null) {
        for (int i = 0; i < tlist.size(); i++) {
             String url = (String)tlist.get(i);
             if (!isValidAnnounce(url)) {
                _log.error("Bad announce URL: [" + url + "]");
                continue;
             }
             int slash = url.indexOf('/', 7);
             if (slash <= 7) {
                _log.error("Bad announce URL: [" + url + "]");
                continue;
             }
             if (primary.startsWith(url.substring(0, slash)))
                continue;
             String dest = _util.lookup(url.substring(7, slash));
             if (dest == null) {
                _log.error("Announce host unknown: [" + url.substring(7, slash) + "]");
                continue;
             }
             if (primary.startsWith("http://" + dest))
                continue;
             if (primary.startsWith("http://i2p/" + dest))
                continue;
             trackers.add(new Tracker(url, false));
             _log.debug("Additional announce: [" + url + "] for infoHash: " + infoHash);
        }
    }

    if (tlist.isEmpty()) {
        // FIXME really need to get this message to the gui
        stop = true;
        _log.error("No valid trackers for infoHash: " + infoHash);
        return;
    }

    long uploaded = coordinator.getUploaded();
    long downloaded = coordinator.getDownloaded();
    long left = coordinator.getLeft();

    boolean completed = (left == 0);
    int sleptTime = 0;

    try
      {
        if (!verifyConnected()) return;
        boolean runStarted = false;
        boolean firstTime = true;
        int consecutiveFails = 0;
        Random r = I2PAppContext.getGlobalContext().random();
        while(!stop)
          {
            try
              {
                // Sleep some minutes...
                // Sleep the minimum interval for all the trackers, but 60s minimum
                // except for the first time...
                int delay;
                int random = r.nextInt(120*1000);
                if (firstTime) {
                  delay = r.nextInt(30*1000);
                  firstTime = false;
                } else if (completed && runStarted)
                  delay = 3*SLEEP*60*1000 + random;
                else if (coordinator.trackerProblems != null && ++consecutiveFails < MAX_CONSEC_FAILS)
                  delay = INITIAL_SLEEP;
                else
                  // sleep a while, when we wake up we will contact only the trackers whose intervals have passed
                  delay = SLEEP*60*1000 + random;

                if (delay > 0)
                  Thread.sleep(delay);
              }
            catch(InterruptedException interrupt)
              {
                // ignore
              }

            if (stop)
              break;
       
            if (!verifyConnected()) return;
            
            uploaded = coordinator.getUploaded();
            downloaded = coordinator.getDownloaded();
            left = coordinator.getLeft();
            
            // First time we got a complete download?
            String event;
            if (!completed && left == 0)
              {
                completed = true;
                event = COMPLETED_EVENT;
              }
            else
              event = NO_EVENT;
            
            // *** loop once for each tracker
            // Only do a request when necessary.
            sleptTime = 0;
            int maxSeenPeers = 0;
            for (Iterator iter = trackers.iterator(); iter.hasNext(); ) {
              Tracker tr = (Tracker)iter.next();
              if ((!stop) && (!tr.stop) &&
                  (completed || coordinator.needPeers()) &&
                  (event.equals(COMPLETED_EVENT) || System.currentTimeMillis() > tr.lastRequestTime + tr.interval))
              {
                try
                  {
                    if (!tr.started)
                      event = STARTED_EVENT;
                    TrackerInfo info = doRequest(tr, infoHash, peerID,
                                                 uploaded, downloaded, left,
                                                 event);

                    coordinator.trackerProblems = null;
                    tr.trackerProblems = null;
                    tr.registerFails = 0;
                    tr.consecutiveFails = 0;
                    if (tr.isPrimary)
                        consecutiveFails = 0;
                    runStarted = true;
                    tr.started = true;

                    Set peers = info.getPeers();
                    tr.seenPeers = info.getPeerCount();
                    if (coordinator.trackerSeenPeers < tr.seenPeers) // update rising number quickly
                        coordinator.trackerSeenPeers = tr.seenPeers;
                    if ( (left > 0) && (!completed) ) {
                        // we only want to talk to new people if we need things
                        // from them (duh)
                        List ordered = new ArrayList(peers);
                        Collections.shuffle(ordered);
                        Iterator it = ordered.iterator();
                        while (it.hasNext()) {
                          Peer cur = (Peer)it.next();
                          // FIXME if id == us || dest == us continue;
                          // only delay if we actually make an attempt to add peer
                          if(coordinator.addPeer(cur)) {
                            int delay = DELAY_MUL;
                            delay *= ((int)cur.getPeerID().getAddress().calculateHash().toBase64().charAt(0)) % 10;
                            delay += DELAY_MIN;
                            sleptTime += delay;
                            try { Thread.sleep(delay); } catch (InterruptedException ie) {}
                          }
                        }
                    }
                  }
                catch (IOException ioe)
                  {
                    // Probably not fatal (if it doesn't last to long...)
                    _util.debug
                      ("WARNING: Could not contact tracker at '"
                       + tr.announce + "': " + ioe, Snark.WARNING);
                    tr.trackerProblems = ioe.getMessage();
                    // don't show secondary tracker problems to the user
                    if (tr.isPrimary)
                      coordinator.trackerProblems = tr.trackerProblems;
                    if (tr.trackerProblems.toLowerCase().startsWith(NOT_REGISTERED)) {
                      // Give a guy some time to register it if using opentrackers too
                      if (trackers.size() == 1) {
                        stop = true;
                        coordinator.snark.stopTorrent();
                      } else { // hopefully each on the opentrackers list is really open
                        if (tr.registerFails++ > MAX_REGISTER_FAILS)
                          tr.stop = true;
                      }
                    }
                    if (++tr.consecutiveFails == MAX_CONSEC_FAILS) {
                        tr.seenPeers = 0;
                        if (tr.interval < LONG_SLEEP)
                            tr.interval = LONG_SLEEP;  // slow down
                    }
                  }
              }
              if ((!tr.stop) && maxSeenPeers < tr.seenPeers)
                  maxSeenPeers = tr.seenPeers;
            }  // *** end of trackers loop here

            // we could try and total the unique peers but that's too hard for now
            coordinator.trackerSeenPeers = maxSeenPeers;
            if (!runStarted)
                _util.debug("         Retrying in one minute...", Snark.DEBUG);
          } // *** end of while loop
      } // try
    catch (Throwable t)
      {
        _util.debug("TrackerClient: " + t, Snark.ERROR, t);
        if (t instanceof OutOfMemoryError)
            throw (OutOfMemoryError)t;
      }
    finally
      {
        try
          {
            // try to contact everybody we can
            // Don't try to restart I2CP connection just to say goodbye
            for (Iterator iter = trackers.iterator(); iter.hasNext(); ) {
              if (!_util.connected()) return;
              Tracker tr = (Tracker)iter.next();
              if (tr.started && (!tr.stop) && tr.trackerProblems == null)
                  doRequest(tr, infoHash, peerID, uploaded,
                                         downloaded, left, STOPPED_EVENT);
            }
          }
        catch(IOException ioe) { /* ignored */ }
      }
    
  }
  
  private TrackerInfo doRequest(Tracker tr, String infoHash,
                                String peerID, long uploaded,
                                long downloaded, long left, String event)
    throws IOException
  {
    String s = tr.announce
      + "?info_hash=" + infoHash
      + "&peer_id=" + peerID
      + "&port=" + port
      + "&ip=" + _util.getOurIPString() + ".i2p"
      + "&uploaded=" + uploaded
      + "&downloaded=" + downloaded
      + "&left=" + left
      + ((! event.equals(NO_EVENT)) ? ("&event=" + event) : "");
    if (left <= 0 || event.equals(STOPPED_EVENT) || !coordinator.needPeers())
        s += "&numwant=0";
    else
        s += "&numwant=" + _util.getMaxConnections();
    _util.debug("Sending TrackerClient request: " + s, Snark.INFO);
      
    tr.lastRequestTime = System.currentTimeMillis();
    File fetched = _util.get(s);
    if (fetched == null) {
        throw new IOException("Error fetching " + s);
    }
    
    InputStream in = null;
    try {
        in = new FileInputStream(fetched);

        TrackerInfo info = new TrackerInfo(in, coordinator.getID(),
                                           coordinator.getMetaInfo());
        _util.debug("TrackerClient response: " + info, Snark.INFO);

        String failure = info.getFailureReason();
        if (failure != null)
          throw new IOException(failure);

        tr.interval = info.getInterval() * 1000;
        return info;
    } finally {
        if (in != null) try { in.close(); } catch (IOException ioe) {}
        fetched.delete();
    }
  }

  /**
   * Very lazy byte[] to URL encoder.  Just encodes almost everything, even
   * some "normal" chars.
   * By not encoding about 1/4 of the chars, we make random data like hashes about 16% smaller.
   *
   * RFC1738: 0-9a-zA-Z$-_.+!*'(),
   * Us:      0-9a-zA-Z
   *
   */
  public static String urlencode(byte[] bs)
  {
    StringBuilder sb = new StringBuilder(bs.length*3);
    for (int i = 0; i < bs.length; i++)
      {
        int c = bs[i] & 0xFF;
        if ((c >= '0' && c <= '9') ||
            (c >= 'A' && c <= 'Z') ||
            (c >= 'a' && c <= 'z')) {
            sb.append((char)c);
        } else {
            sb.append('%');
            if (c < 16)
              sb.append('0');
            sb.append(Integer.toHexString(c));
        }
      }
         
    return sb.toString();
  }

  /**
   *  @return true for i2p hosts only
   *  @since 0.7.12
   */
  static boolean isValidAnnounce(String ann) {
    URL url;
    try {
       url = new URL(ann);
    } catch (MalformedURLException mue) {
       return false;
    }
    return url.getProtocol().equals("http") &&
           (url.getHost().endsWith(".i2p") || url.getHost().equals("i2p")) &&
           url.getPort() < 0;
  }

  private static class Tracker
  {
      String announce;
      boolean isPrimary;
      long interval;
      long lastRequestTime;
      String trackerProblems;
      boolean stop;
      boolean started;
      int registerFails;
      int consecutiveFails;
      int seenPeers;

      public Tracker(String a, boolean p)
      {
          announce = a;
          isPrimary = p;
          interval = INITIAL_SLEEP;
          lastRequestTime = 0;
          trackerProblems = null;
          stop = false;
          started = false;
          registerFails = 0;
          consecutiveFails = 0;
          seenPeers = 0;
      }
  }
}
