# Copyright (C) 2007 - 2008 LottaNZB Development Team
# 
# 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; version 3.
# 
# 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., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA.

import xmlrpclib
import socket
import errno

import logging
log = logging.getLogger(__name__)

from time import sleep
from os import remove

from kiwi.python import clamp
from kiwi.utils import gsignal

from lottanzb.core import App
from lottanzb.util import Thread, GObject, gproperty, _
from lottanzb.config import GConfigSection

class Config(GConfigSection):
    update_interval = gproperty(type=float, default=1.0, minimum=1.0)

class Backend(Thread):
    updating = gproperty(type=bool, default=False)
    paused = gproperty(type=bool, default=False)
    connected = gproperty(type=bool, default=False)
    
    gsignal("updated")
    gsignal("unexpected-disconnect")
    
    def __init__(self, config):
        self.config = config
        
        # Initialize threading
        Thread.__init__(self)
        
        # The place where all Download instances are stored
        self.downloads = QueueList()
        
        # Cache information about queued downloads and downloads that are being processed
        # This is used to speed up the update_state method
        self.cache = {}
        
        self.__server = None
        
        # Initialize the remaining properties
        self.speed = 0
        self.eta = 0
        self.remaining = 0
        self.progress = 0
        
        self.log_entries = []
        
        self.start()
    
    def start(self):
        log.debug("Starting backend thread...")
        
        Thread.start(self)
    
    def run(self):
        while not self.thread_stopped.isSet():
            if self.connected:
                sleepTime = self.config.update_interval
                self.update_state()
            else:
                sleepTime = 0.1
            
            sleep(sleepTime)
        
        self.emit("completed")
    
    def stop(self):
        log.debug("Shutting down backend thread...")
        
        self.disconnectFromHella()
        Thread.stop(self)
    
    def _get_server_proxy(self, address, port, password, silent = False):
        location = address + ":" + str(port)
        uri = "http://hellanzb:" + password + "@" + location + "/"
        
        try:
            proxy = xmlrpclib.ServerProxy(uri)
            method_list = proxy.system.listMethods()
            
            for cmd in ["cancel", "clear", "continue", "clear", "continue", "dequeue",
                "down", "enqueue", "enqueuenewzbin", "enqueueurl", "force", "maxrate",
                "move", "pause", "process", "setrarpass", "shutdown", "status", "up"]:
                assert cmd in method_list
        
        except xmlrpclib.ProtocolError, e:
            if e.errcode == 401:
                raise WrongPasswordError, _("The specified password is invalid. Please check your input.")
            else:
                raise ConnectionError, str(e.errcode) + ": " + e.errmsg
        
        except (socket.gaierror, socket.error), e:
            if not silent:
                log.debug(str(e[0]) + ": " + e[1])
            
            if e[0] == errno.ECONNREFUSED:
                raise NotRunningError, _("The HellaNZB daemon doesn't seem to be running at %s.") % (location)
            else:
                raise ConnectionError, _("Could not connect to %s. This might be due to network issues.") % (address)
        
        except AssertionError, e:
            raise UnsupportedDaemonError, _("You don't seem to be running a compatible HellaNZB daemon. Plase make sure you have installed the latest version.")
        
        else:
            setattr(proxy, "resume", getattr(proxy, "continue"))
            return proxy
    
    def connectToHella(self, address, port, password):
        location = address + ":" + str(port)
        
        try:
            self.setProxy(self._get_server_proxy(address, port, password))
        except:
            log.error(_("Could not connect to the HellaNZB daemon at %s.") % (location))
            raise
        else:
            self.connected = True
            log.info(_("Connected to the HellaNZB daemon at %s.") % (location))
    
    def setProxy(self, proxy):
        self.__server = proxy
    
    def disconnectFromHella(self):
        self.__server = None
        
        self.connected = False
    
    def shutdown(self):
        self.__server.shutdown()
    
    def checkConnection(self, address, port, password):
        return self._get_server_proxy(address, port, password, True)
    
    def enqueue(self, name):
        try:
            self.update_state(self.__server.enqueue(name))
            remove(name)
        except:
            log.error(_("Could not add file to the download queue."))
        else:
            log.info(_("Added %s to the download queue.") % (name))
    
    def newzbinEnqueue(self, newzbin_id):
        try:
            self.update_state(self.__server.enqueuenewzbin(newzbin_id))
        except:
            log.error(_("Could not add file to the download queue."))
        else:
            log.info(_("Added %s to the download queue.") % (newzbin_id))
    
    def dequeue(self, download):
        try:
            if download:
                if download.state == Download.State.DOWNLOADING:
                    self.cancel()
                else:
                    # dequeue(id) doesn't return the whole state information
                    self.__server.dequeue(download.id)
                    self.update_state()
                
                log.info(_("Removed '%s' from queue.") % (download))
        except:
            log.error(_("Could not dequeue item."))
    
    def setRarPass(self, download_id, password):
        """This methods sets the password for the rar archive(s) in the item with the id provided."""
        self.update_state(self.__server.setrarpass(download_id, password))
    
    def cancel(self):
        try:
            state = self.__server.cancel()
            
            # Make sure the canceled download isn't marked as finished.
            # Todo: Could be implemented more elegantly
            self.downloads.remove(self.downloads.get_active_download())
            
            self.update_state(state)
        except:
            log.error(_("Could not cancel the current download."))
        else:
            log.info(_("Canceled current download."))
    
    def clear(self, andCancel = False):
        try:
            self.update_state(self.__server.clear(andCancel))
        except:
            log.error(_("Could not clear the queue."))
        else:
            log.info(_("Cleared queue."))
    
    def pause(self):
        try:
            self.paused = True
            self.update_state(self.__server.pause())
        except:
            log.error(_("Could not pause the download."))
        else:
            log.info(_("Paused downloads."))
    
    def resume(self):
        try:
            self.paused = False
            self.update_state(self.__server.resume())
        except:
            log.error(_("Could not resume the download."))
        else:
            log.info(_("Resuming downloads."))
    
    def move(self, download, target=None):
        incomplete = self.downloads.get_incomplete()
        
        try:
            assert download.movable()
            
            if isinstance(target, Download):
                if target in incomplete:
                    target_index = incomplete.index(target)
                else:
                    target_index = 0
            elif type(target) is int:
                target_index = clamp(target, 0, len(incomplete) - 1)
            elif not target:
                target_index = len(incomplete) - 1
            
            assert download is not incomplete[target_index]
        except:
            return
        else:
            if download is self.downloads.get_active_download():
                self.__server.force(incomplete[1].id)
                
                if target_index > 1:
                    self.__server.move(download.id, target_index)
            elif target_index == 0:
                self.__server.force(download.id)
            else:
                self.__server.move(download.id, target_index)
            
            self.update_state()
    
    def move_up(self, download, shift=1):
        self.move_down(download, shift * -1)
    
    def move_down(self, download, shift=1):
        incomplete = self.downloads.get_incomplete()
        self.move(download, incomplete.index(download) + shift)
    
    def force(self, download):
        self.update_state(self.__server.force(download.id))
    
    def set_max_rate(self, maxRate = 0):
        try:
            self.__server.maxrate(maxRate)
        except:
            log.error(_("Could not set the maxium download speed."))
        else:
            if maxRate:
                log.info(_("Set the maximum download speed to %s KiB/s.") % (maxRate))
            else:
                log.info(_("Removed download speed limit."))
    
    def get_max_rate(self):
        try:
            return self.__server.maxrate()
        except:
            return 0
    
    def update_state(self, state=None):
        if self.updating:
            return
        
        self.updating = True
        
        try:
            if state is None:
                try:
                    state = self.__server.status()
                except:
                    log.warning(_("Could not get an update from the HellaNZB "
                        "daemon, is it (still) running?"))
            
            if not state:
                self.connected = False
                self.emit("unexpected-disconnect")
                self.updating = False
                return
            
            self.paused = state["is_paused"]
            self.speed = state["rate"]
            self.eta = state["eta"]
            self.remaining = state["queued_mb"]
            self.progress = state["percent_complete"]
            self.log_entries = state["log_entries"]
            
            new_download_list = QueueList()
            
            def update_download_list(download_state, download_infos):
                if download_state is Download.State.DOWNLOADING or \
                    self.cache.get(download_state, None) != download_infos:
                    
                    for info in download_infos:
                        download = self.downloads.get_by_id(info["id"])
                        
                        if download:
                            download.update(info, download_state)
                        else:
                            download = Download(info, download_state)
                        
                        new_download_list.append(download)
                    
                    self.cache[download_state] = download_infos
                else:
                    new_download_list.extend(self.downloads.get_by_state(download_state))
            
            def just_finished(download):
                return not download in new_download_list and ( \
                    download.state == Download.State.DOWNLOADING or \
                    download.state == Download.State.PROCESSING)
            
            download_infos_by_state = [
                (Download.State.PROCESSING, state["currently_processing"]),
                (Download.State.DOWNLOADING, state["currently_downloading"]),
                (Download.State.QUEUED, state["queued"])
            ]
            
            for download_state, download_infos in download_infos_by_state:
                update_download_list(download_state, download_infos)
            
            for download in self.downloads:
                if not download in new_download_list and ( \
                    download.state == Download.State.DOWNLOADING or \
                    download.state == Download.State.PROCESSING):
                    download.state = Download.State.FINISHED
            
            self.downloads = QueueList( \
                self.downloads.get_finished() + \
                new_download_list
            )
            
            self.emit("updated")
        except Exception, e:
            raise
            # We need a more fine-grained error handling here.
            # log.error(_("Could not update LottaNZB's state."))
        
        self.updating = False
    
    def clear_finished(self):
        self.downloads.clear_finished()
        log.info(_("Cleared the finished items."))
    
    def formatETA(self, eta):
        hours = int(eta / (60 * 60))
        minutes = int((eta - (hours * 60 * 60)) / 60)
        seconds = eta - (hours * 60 * 60) - (minutes * 60)
        
        return "%.2d:%.2d:%.2d" % (hours, minutes, seconds)

class ConnectionError(Exception):
    pass

class WrongPasswordError(ConnectionError):
    pass

class NotRunningError(ConnectionError):
    pass

class UnsupportedDaemonError(ConnectionError):
    pass

class QueueList(list):
    def get_by_state(self, state):
        return [download for download in self if download.state == state]
    
    def get_active_download(self):
        try:
            return self.get_downloading()[0]
        except IndexError:
            pass
    
    def get_downloading(self):
        return self.get_by_state(Download.State.DOWNLOADING)
    
    def get_queued(self):
        return self.get_by_state(Download.State.QUEUED)
    
    def get_processing(self):
        return self.get_by_state(Download.State.PROCESSING)
    
    def get_finished(self):
        return self.get_by_state(Download.State.FINISHED)
    
    def get_incomplete(self):
        active_download = self.get_active_download()
        
        if active_download:
            return [active_download] + self.get_queued()
        else:
            return self.get_queued()
    
    def clear_finished(self):
        for download in self.get_finished():
            self.remove(download)
    
    def get_by_id(self, download_id):
        for download in self:
            if download.id == download_id:
                return download
    
    def get_total_size(self):
        total_size = 0
        
        for download in self.get_incomplete():
            total_size += download.remaining
        
        return total_size
    
    def get_total_eta(self):
        active_download = self.get_active_download()
        
        if active_download and active_download.remaining:
            return int(App().backend.eta * self.get_total_size() / active_download.remaining)
        
        return 0

class Download(GObject):
    id = gproperty(type = int)
    name = gproperty(type = str)
    nzb_name = gproperty(type = str)
    category = gproperty(type = str)
    remaining = gproperty(type = int, minimum = 0)
    progress = gproperty(type = int, minimum = 0, maximum = 100)
    recovery = gproperty(type = bool, default = False)
    password = gproperty(type = str)
    size = gproperty(type = int, minimum = 0)
    state = gproperty(type = int)
    
    def __init__(self, hellaInfo, state):
        GObject.__init__(self)
        
        self.connect("notify::state", self._handleStateChange)
        self.connect("notify::size", self._handleSizeChange)
        
        self.id = hellaInfo["id"]
        self.nzb_name = hellaInfo["nzbName"]
        
        parts = self.nzb_name.split(App().config.plugins.categories.separator)
        
        if len(parts) > 1:
            self.category = parts[0].strip()
            self.name = parts[1].strip()
        else:
            self.category = _("Unknown")
            self.name = self.nzb_name
        
        self.update(hellaInfo, state)
    
    def update(self, hellaInfo, state):
        self.recovery = hellaInfo["is_par_recovery"]
        
        if "total_mb" in hellaInfo:
            self.size = hellaInfo["total_mb"]
        
        if "rarPassword" in hellaInfo:
            self.password = hellaInfo["password"]
        
        self.state = state
    
    def dequeue(self):
        App().backend.dequeue(self)
    
    def move(self, target):
        App().backend.move(self, target)
    
    def move_up(self, shift=1):
        App().backend.move_up(self, shift)
    
    def move_down(self, shift=1):
        App().backend.move_down(self, shift)
    
    def _handleStateChange(self, *args):
        if self.state == Download.State.PROCESSING or self.state == Download.State.FINISHED:
            self.progress = 100
            self.remaining = 0
        elif self.state == Download.State.DOWNLOADING:
            self.progress = App().backend.progress
            self.remaining = App().backend.remaining
    
    def _handleSizeChange(self, *args):
        if self.size != 0 and not self.progress and not self.remaining:
            self.remaining = self.size
    
    def movable(self):
        return not (self.state == Download.State.FINISHED or self.state == Download.State.PROCESSING)
    
    def __str__(self):
        return self.name
    
    class State(object):
        QUEUED, DOWNLOADING, PROCESSING, FINISHED = range(4)