#!/usr/bin/env python

# The contents of this file are subject to the BitTorrent Open Source License
# Version 1.1 (the License).  You may not copy or use this file, in either
# source code or executable form, except in compliance with the License.  You
# may obtain a copy of the License at http://www.bittorrent.com/license/.
#
# Software distributed under the License is distributed on an AS IS basis,
# WITHOUT WARRANTY OF ANY KIND, either express or implied.  See the License
# for the specific language governing rights and limitations under the
# License.

# Original version written by John Hoffman, heavily modified for different
# multitorrent architecture by Uoti Urpala (over 40% shorter than original),
# ported to new MultiTorrent (circa 4.20) by David Harrison.

from __future__ import division
from BitTorrent.translation import _

import os
from cStringIO import StringIO
from traceback import print_exc
import logging
from BitTorrent import configfile
from BitTorrent import platform
from BitTorrent.parsedir import parsedir
from BitTorrent.MultiTorrent import MultiTorrent, Feedback, TorrentAlreadyRunning, TorrentAlreadyInQueue, TorrentNotInitialized
from BitTorrent.ConvertedMetainfo import ConvertedMetainfo
from BitTorrent import BTFailure, UserFailure
from BitTorrent.RawServer_twisted import RawServer
from BitTorrent.yielddefer import launch_coroutine, _wrap_task
from BitTorrent.ConvertedMetainfo import ConvertedMetainfo
from BitTorrent.defer import DeferredEvent
from BitTorrent.platform import encode_for_filesystem
from BitTorrent.prefs import Preferences
from time import time


#class LaunchMany(Feedback):
class LaunchMany(object):

    def __init__(self, config, display, configfile_key):
      """Starts torrents for all .torrent files in a directory tree.

         All errors are logged using Python logging to 'configfile_key' logger.

         @param config: Preferences object storing config.
         @param display: output function for stats.
      """
      # 4.4.x version of LaunchMany output exceptions to a displayer.
      # This version only outputs stats to the displayer.  We do not use
      # the logger to output stats so that a caller-provided object
      # can provide stats formatting as opposed to using the
      # logger Formatter, which is specific to exceptions, warnings, and
      # info messages.
      try:
        self.display = display.display
        self.logger = logging.getLogger(configfile_key)
        
        self.multitorrent = None
        self.rawserver = None
        self.config = Preferences().initWithDict(config)
        self.configfile_key = configfile_key

        self.torrent_dir = config['torrent_dir']

        # Ex: torrent_cache = infohash ->
        #   {path:'/a/c.torrent',file:'c.torrent',length:90911,name:'Sea'}
        self.torrent_cache = {}

        # maps path -> [(modification time, size), infohash]
        self.file_cache = {}

        # used as set containing paths of files that do not have separate
        # entries in torrent_cache either because torrent_cache already
        # contains the torrent or because the torrent file is corrupt.
        self.blocked_files = {}

        #self.torrent_list = [] 
        #self.downloads = {}

        self.hashcheck_queue = []
        self.hashcheck_store = {}
                         
        self.core_doneflag = DeferredEvent()
        self.rawserver_doneflag = DeferredEvent() 
        self.rawserver = RawServer(self.config)
        try:
   
            # set up shut-down procedure before we begin doing things that
            # can throw exceptions.
            def shutdown():
                #print "SHUTDOWNSHUTDOWNSHUTDOWN"
                self.logger.critical(_("shutting down"))
                for torrent in self.multitorrent.get_torrents():
                    try:
                        removed = self.torrent_cache[torrent.infohash]['path']
                        self.logger.info(_('dropped "%s"') % removed)
                    except KeyError:
                        pass
                    if torrent is not None:
                        torrent.shutdown()
                if self.multitorrent:
                    df = self.multitorrent.shutdown()
                    set_flag = lambda *a : self.rawserver_doneflag.set()
                    df.addCallbacks(set_flag, set_flag)
                else:
                    self.rawserver_doneflag.set()
                
            # It is safe to addCallback here, because there is only one thread,
            # but even if the code were multi-threaded, core_doneflag has not
            # been passed to anyone.  There is no chance of a race condition
            # between the DeferredEvent's callback and addCallback.
            self.core_doneflag.addCallback(
                lambda r: self.rawserver.external_add_task(0, shutdown))
   
            self.rawserver.install_sigint_handler(self.core_doneflag)
    
            data_dir = self.config['data_dir']
            #print "Creating MultiTorrent"
            self.multitorrent = MultiTorrent(self.config, self.rawserver, data_dir)
    
            self.rawserver.add_task(0, self.scan)
            self.rawserver.add_task(0, self.stats)
            self.rawserver.add_task(1, self.check_hashcheck_queue)
            
            try:
                import signal
                def handler(signum, frame):
                    self.rawserver.external_add_task(0, self.read_config)
                signal.signal(signal.SIGHUP, handler)
            except Exception, e:
                self.logger.error(_("Could not set signal handler: ") +
                                    unicode(e.args[0]))
                self.rawserver.add_task(0,self.core_doneflag.set())
  
        except UserFailure, e:
            display.exception(unicode(e.args[0]))
            self.rawserver.add_task(0,self.core_doneflag.set())
        except:
            data = StringIO()
            print_exc(file = data)
            display.exception(data.getvalue())
            self.rawserver.add_task(0,self.core_doneflag.set())
           
        # always make sure events get processed even if only for
        # shutting down.
        #print "listening forever"
        try:
            self.rawserver.listen_forever(self.rawserver_doneflag)
        except AttributeError, e:
            output.message(str(e))
            raise
        except KeyboardInterrupt: 
            raise
        
      except:
        data = StringIO()
        print_exc(file = data)
        display.exception(data.getvalue())
        self.core_doneflag.set()
        self.rawserver_doneflag.set()

    def scan(self):
        #print "LaunchMany.scan top."
        self.rawserver.add_task(self.config['parse_dir_interval'], self.scan)

        r = parsedir(self.torrent_dir, self.torrent_cache,
                     self.file_cache, self.blocked_files,
                     self.logger.error)

        #print "After parsedir"
        ( self.torrent_cache, self.file_cache, self.blocked_files,
            added, removed ) = r
        for infohash, data in removed.items():
            self.logger.info(_('dropped "%s"') % data['path'])
            self.remove(infohash)
        for infohash, data in added.items():
            #print "adding item:", data['path']
            self.logger.info(_('added "%s"'  ) % data['path'])
            #print "after self.logger.info"
            if self.config['launch_delay'] > 0:
                self.rawserver.add_task(self.config['launch_delay'], self.add, infohash, data)
            # torrent may have been known from resume state.
            elif not self.multitorrent.torrent_known(infohash):
                self.add(infohash, data)
        #print "LaunchMany.scan bottom"

    def stats(self):
        self.rawserver.add_task(self.config['display_interval'], self.stats)
        data = []
        for d in self.multitorrent.get_torrents():
            if d is not None:
                infohash = d.infohash
                try:
                    cache = self.torrent_cache[infohash]
                except KeyError:
                    self.remove(infohash)
                    continue
                if self.config['display_path']:
                    name = cache['path']
                else:
                    name = cache['name']
                size = cache['length']
                progress = '0.0%'
                peers = 0
                seeds = 0
                seedsmsg = "S"
                dist = 0.0
                uprate = 0.0
                dnrate = 0.0
                upamt = 0
                dnamt = 0
                t = 0
                msg = ''
                #if d.state in ["created", "initializing"]:
                #    status = _("waiting for hash check")
                #else:
                stats = d.get_status()
                status = stats['activity']
                progress = '%.1f%%' % (int(stats['fractionDone']*1000)/10.0)
                if d.is_running():
                    s = stats
                    dist = int(s['distributed_copies'])
                    if d.is_seeding():
                        seeds = 0 # s['numOldSeeds']
                        seedsmsg = "s"
                    else:
                        if s['numSeeds'] + s['numPeers']:
                            t = stats['timeEst']
                            if t is None:
                                t = -1
                            if t == 0:  # unlikely
                                t = 0.01
                            status = _("downloading")
                        else:
                            t = -1
                            status = _("connecting to peers")
                        seeds = s['numSeeds']
                        dnrate = stats['downRate']
                    peers = s['numPeers']
                    uprate = stats['upRate']
                    upamt = s['upTotal']
                    dnamt = s['downTotal']

                data.append(( name, status, progress, peers, seeds, seedsmsg, dist,
                          uprate, dnrate, upamt, dnamt, size, t, msg ))
        stop = self.display(data)
        if stop:
            self.core_doneflag.set()

    def remove(self, infohash):
        torrent = self.multitorrent.get_torrent(infohash)
        if torrent is not None:
            torrent.shutdown()
        self.was_stopped(infohash)

    def add(self, infohash, data):
        # data is a dict like
        # { path:'/a/b/c.torrent', file:'c.torrent', length:90911, name:'Sea',
        #   metainfo: <metainfo>}  Metainfo has bdecoded but not passed
        # to ConvertedMetainfo.
        self.hashcheck_queue.append(infohash)
        self.hashcheck_store[infohash] = ConvertedMetainfo(data['metainfo'])
        self.check_hashcheck_queue()

    def check_hashcheck_queue(self):
        if not self.hashcheck_queue:
            return
        infohash = self.hashcheck_queue.pop(0)
        metainfo = self.hashcheck_store[infohash]
        del self.hashcheck_store[infohash]
        save_incomplete_as, save_as = self.determine_filename(infohash)
        saveas_style_save_incomplete_as, saveas_style_save_as = self._determine_filename(infohash)
        torrent_path = self.torrent_cache[infohash]['path']
        self.start_torrent(torrent_path, metainfo, save_incomplete_as,save_as,
                           saveas_style_save_incomplete_as, saveas_style_save_as)

    def start_torrent(self,torrent_path,metainfo,save_incomplete_as,save_as,
                      saveas_style_save_incomplete_as=None,saveas_style_save_as=None):
        assert isinstance(metainfo, ConvertedMetainfo)
        df = launch_coroutine(_wrap_task(self.rawserver.add_task),
                              self._start_torrent, metainfo,
                              save_incomplete_as, save_as,
                              saveas_style_save_incomplete_as=saveas_style_save_incomplete_as,
                              saveas_style_save_as=saveas_style_save_as)
        df.addErrback(lambda e : self.logger.error(_("DIED: "),exc_info=e))
        return df

    def _start_torrent(self, metainfo, save_incomplete_as,save_as,
                       saveas_style_save_incomplete_as,saveas_style_save_as):
        assert isinstance(metainfo, ConvertedMetainfo)
        save_incomplete_as, junk = platform.encode_for_filesystem(save_incomplete_as)
        save_as, junk = platform.encode_for_filesystem(save_as)
        if saveas_style_save_incomplete_as is not None:
            saveas_style_save_incomplete_as, junk = platform.encode_for_filesystem(saveas_style_save_incomplete_as)
        if saveas_style_save_as is not None:
            saveas_style_save_as, junk = platform.encode_for_filesystem(saveas_style_save_as)
        try:
            df = self.multitorrent.create_torrent(metainfo,
                                              save_incomplete_as, save_as,
                                              saveas_style_save_incomplete_as,
                                              saveas_style_save_as)
            yield df
            df.getResult()
        except (TorrentAlreadyRunning, TorrentAlreadyInQueue):
            pass
        except:
            raise

        infohash = metainfo.infohash

        # find out if torrent has been recorded
        try:
            torrent = self.multitorrent.get_torrent(infohash)
            if not torrent or torrent is None:
                raise UnknownInfohash(infohash.encode("hex"))
        except:
            raise

        # function to requeue torrent if has not been initialized just yet
        def hashcheck_requeue(infohash):
            df = self.remove(infohash)
            data = self.torrent_cache[infohash]
            if self.config['launch_delay'] > 0:
                self.rawserver.add_task(self.config['launch_delay'], self.add, infohash, data)
            else:
                # wait at least 1
                self.rawserver.add_task(1, self.add, infohash, data)

        # Is it running?
        running = self.multitorrent.torrent_running(infohash)
        if not running:
            try:
                if torrent.is_initialized():
                    df = self.multitorrent.start_torrent(infohash)
                    yield df
                    df.getResult()
                else:
                    raise TorrentNotInitialized
            except TorrentAlreadyRunning:
                pass
            except TorrentNotInitialized:
                hashcheck_requeue(infohash)
            except:
                raise

        self.check_hashcheck_queue()

    def determine_saveas_style(self, infohash):
        x = self.torrent_cache[infohash]
        name = x['name']
        style = self.config['saveas_style']
        if style == 4:
            torrentname   = os.path.split(x['path'][:-8])[1]
            suggestedname = name
            if torrentname == suggestedname:
                style = 1
            else:
                style = 3
        return style

    # Specially handle the style 3 case.
    # Style 3 breaks Torrent.move so we have an intermediate
    # _determine_filename for Torrent.move benefit
    # but determine_filename for general use
    def determine_filename(self, infohash):
        x = self.torrent_cache[infohash]
        name = x['name']
        isdir = not x['metainfo']['info'].has_key('length')
        style = self.determine_saveas_style(infohash)
        saveincompleteas, saveas = self._determine_filename(infohash)

        # add filename
        if style == 3 and not isdir:
            saveas = os.path.join(saveas, name)
            saveincompleteas = os.path.join(saveincompleteas, name)

        return saveincompleteas, saveas

    # generate paths appropriate for Torrent.move method
    def _determine_filename(self, infohash):
        x = self.torrent_cache[infohash]
        name = x['name']
        savein = self.config['save_in']
        saveincompletein = self.config['save_incomplete_in']
        style = self.determine_saveas_style(infohash)

        if style == 1 or style == 3:
            if savein:
                saveas = os.path.join(savein,x['file'][:-8]) # strip '.torrent'
            else:
                saveas = x['path'][:-8] # strip '.torrent'
            if saveincompletein:
                saveincompleteas = os.path.join(saveincompletein, x['file'][:-8]) # strip '.torrent'
            else:
                saveincompleteas = x['path'][:-8] # strip '.torrent'
        else:
            if savein:
                saveas = os.path.join(savein, name)
            else:
                saveas = os.path.join(os.path.split(x['path'])[0], name)
            if saveincompletein:
                saveincompleteas = os.path.join(saveincompletein, name)
            else:
                saveincompleteas = os.path.join(os.path.split(x['path'])[0], name)

        return saveincompleteas, saveas

    def was_stopped(self, infohash):
        try:
            self.hashcheck_queue.remove(infohash)
        except:
            pass
        else:
            del self.hashcheck_store[infohash]
        self.check_hashcheck_queue()

    # Exceptions are now reported via loggers.<
    #def global_error(self, level, text):
    #    self.output.message(text)

    # Exceptions are now reported via loggers.
    #def exchandler(self, s):
    #    self.output.exception(s)

    def read_config(self):
        try:
            newvalues = configfile.get_config(self.config, self.configfile_key)
        except Exception, e:
            self.logger.error(_("Error reading config: ") + unicode(e.args[0]) )
            return
        self.logger.info(_("Rereading config file"))
        self.config.update(newvalues)
        # The set_option call can potentially trigger something that kills
        # the torrent (when writing this the only possibility is a change in
        # max_files_open causing an IOError while closing files), and so
        # the self.failed() callback can run during this loop.
        for option, value in newvalues.iteritems():
            self.multitorrent.set_option(option, value)
        for torrent in self.multitorrent.get_torrents():
            if torrent is not None:
                for option, value in newvalues.iteritems():
                    torrent.set_option(option, value)

    # rest are callbacks from torrent instances

    # DEPRECATED. 
    # 'started' is now achieved via a callback on a deferred.
    # 'failed' is now achievecd via an errback on a deferred.
    # 'exception' is now handled via the logging package.
    # Install a root log handler to catch all exceptions.
    #
    #def started(self, torrent):
    #    self.hashcheck_current = None
    #    self.check_hashcheck_queue()
    #
    #def failed(self, torrent):
    #    infohash = torrent.infohash
    #    self.was_stopped(infohash)
    #    if self.torrent_cache.has_key(infohash):
    #        self.output.message('DIED: "'+self.torrent_cache[infohash]['path']+'"')
    #
    #def exception(self, torrent, text):
    #    self.exchandler(text)
