# Copyright (C) 2007 - 2009 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 shutil import copy
from os import remove
from os.path import basename

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

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

class Config(ConfigSection):
    update_interval = gproperty(type=float, default=1.0, minimum=1.0)
    
    delete_enqueued_nzb_files = gproperty(
        type    = bool,
        default = False,
        nick    = "Delete NZB files added to the download queue",
        blurb   = "Setting this value to True doesn't mean that the NZB "
                  "file is completely removed from the system, but that it "
                  "can only be found in the HellaNZB's QUEUE_DIR."
                  "Because it's very uncommon for files to disappear as"
                  "soon as a user clicks on them, this option is set to "
                  "False by default.")

class Backend(Thread):
    updating = gproperty(type=bool, default=False)
    paused = gproperty(type=bool, default=False)
    connected = gproperty(type=bool, default=False)
    
    address = gproperty(type=str)
    port = gproperty(type=int)
    password = gproperty(type=str)
    
    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. Please 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
            
            self.address = address
            self.port = port
            self.password = password
            
            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, file_name, new_file_name=""):
        """
        Adds an NZB file to the download queue.
        
        If LottaNZB is connected to a HellaNZB daemon running on the local
        machine, we can directly pass the filename to it. Otherwise, the
        whole content of the NZB file has to be transferred using XMLRPC,
        which may take some time.
        
        If `new_file_name` is specified, the NZB file will be renamed to
        the value of this argument. Please note that the file extension
        must already be part of it.
        
        If the configuration option `backend.delete_enqueue_nzb_files` is set
        to `True` the NZB file will be removed from the disk after it has
        been added to the download queue.
        """
        
        try:
            if self.address in ("localhost", "127.0.0.1"):
                if new_file_name and basename(file_name) != new_file_name:
                    temp_file_name = resources.get_user_temp(new_file_name)
                    
                    # Create a temporary copy of the NZB file with the new
                    # name.
                    copy(file_name, temp_file_name)
                    
                    self.update_state(self.__server.enqueue(temp_file_name))
                    
                    # Delete the temporary file.
                    remove(temp_file_name)
                else:
                    self.update_state(self.__server.enqueue(file_name))
            else:
                nzb_data = open(file_name, "r").read()
                
                if new_file_name:
                    file_name = new_file_name
                
                self.update_state(self.__server.enqueue(file_name, nzb_data))
        except:
            log.error(_("Could not add file to the download queue."))
        else:
            log.info(_("Added %s to the download queue.") % (file_name))
            
            if self.config.delete_enqueued_nzb_files:
                log.debug(_("Removing enqueued NZB file %s..."), file_name)
                remove(file_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):
        if not download.movable:
            if download.state is Download.State.FINISHED:
                log.info(_("Finished downloads cannot be moved."))
            elif download.state is Download.State.PROCESSING:
                log.info(_("Downloads that are being processed cannot be "
                   "moved."))
            
            return
        
        incomplete = self.downloads.get_incomplete()
        
        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 target is None:
            # Move the download at the bottom of the queue
            target_index = len(incomplete) - 1
        
        # If the following expression is `False`, the download is already
        # at the right place.
        if download is not incomplete[target_index]:
            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)
                        
                        if download_state is Download.State.DOWNLOADING:
                            download.progress = self.progress
                            download.remaining = self.remaining
                        
                        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)
    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 set_property(self, key, value):
        """
        Make sure that the progress of a download is always between 0% and 100%.
        
        HellaNZB sporadically reports a progress of 101%, especially when
        dealing with small downloads.
        """
        
        if key == "progress":
            if value > 100:
                value = 100
            elif value < 0:
                value = 0
        
        GObject.set_property(self, key, value)
    
    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
    
    def _handleSizeChange(self, *args):
        if self.size != 0 and not self.progress and not self.remaining:
            self.remaining = self.size
    
    @property
    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)
