# -*- coding: utf-8 -*-
# Moovida - Home multimedia server
# Copyright (C) 2006-2009 Fluendo Embedded S.L. (www.fluendo.com).
# All rights reserved.
#
# This file is available under one of two license agreements.
#
# This file is licensed under the GPL version 3.
# See "LICENSE.GPL" in the root of this distribution including a special
# exception to use Moovida with Fluendo's plugins.
#
# The GPL part of Moovida is also available under a commercial licensing
# agreement from Fluendo.
# See "LICENSE.Moovida" in the root directory of this distribution package
# for details on that license.
#
# Author: Benjamin Kampmann <benjamin@fluendo.com>

"""
Scan the resource providers, add some metadata and store it in the database
"""

from elisa.core import common
from elisa.core.utils.locale_helper import system_encoding
from elisa.core.media_uri import MediaUri

from twisted.internet import reactor
from twisted.python.failure import Failure

from elisa.core.utils.cancellable_defer import cancellable_delay_coiterate

from elisa.core.utils import defer

import time, os, platform

from elisa.core.components.resource_provider import ResourceProvider

from elisa.plugins.database.scanner_models import Statistic, ScanResource
from elisa.plugins.database.database_parser import DatabaseParser
from elisa.plugins.database.models import \
         PICTURES_SECTION, MUSIC_SECTION, VIDEO_SECTION

from elisa.plugins.base.local_resource import LocalResource

from elisa.plugins.database.database_updater import SCHEMA, DatabaseUpdaterNG

from elisa.plugins.base.models.file import DirectoryModel
from elisa.plugins.base.messages.device import NewDeviceDetected

try:
    import dbus
    from elisa.plugins.database.dbus_iface import DBusInterface
except ImportError:
    dbus = None

class MediaScanner(ResourceProvider):
    """
    Asynchronous working media scanner.
    
    @ivar store: the store for the data
    @type store: L{storm.twisted.storm.DeferredStore}
    """

    default_config = {
        "delay" : 0.01,
        "scan_every" : 24,
        "ignored_extensions": ['txt', 'nfo', 'ini', 'srt', 'sub', 'directory',
            'sha1', 'md5', 'pdf', 'db', 'm3u'],
        "delay_before_start": 5,
        }
    config_doc = {
        "delay": "the delay (in seconds) between processing two files",
        "scan_every" : "the delay (in hours) between two automatic scans",
        "ignored_extensions": 'List of extensions that will be '\
            'ignored by the media scanner',
        "delay_before_start": 'the delay (in seconds) before starting the'\
                              'scanner at startup; set it to -1 to never'\
                              'start it',
        }

    supported_uri = '^media_scanner://localhost/'

    def __init__(self):
        super(MediaScanner, self).__init__()
        self.store = None
        self.config = {}
        self._current_delay = None
        self._start_delay = None
        self._current_scan_deferred = None
        self._scan_call = None

        self.scan_stat = Statistic()
        self.scan_stat.uri = MediaUri('media_scanner://localhost/statistic')
        self._pending_auto_rescan = None

        if platform.system() == 'Windows':
            from elisa.core.utils.mswin32.tools import ShortcutResolver
            self._shortcut_resolver = ShortcutResolver()

        self.running = False

    def _scan_directories(self, filter_call=None):
        # ugly hack to start the scanning process for the directories the user
        # has set up in the configuration file
        directories_section = common.application.config.get_section('directories')
        if not directories_section:
            self.debug("no default directories")
            return

        if filter_call is None:
            # skip the default configuration thingy
            filter_call = lambda path: path != '*default*'

        for section in ('music', 'pictures', 'video'):
            paths = directories_section.get(section, [])
            for path in paths:
                if filter_call(path): 
                    uri = MediaUri({'scheme': 'file', 'path': path})
                    self.debug("adding %s" % uri)
                    self.put(uri, None, section=section)

    def initialize(self):
        self._initialized = True

        def set_parser(parser):
            self.parser = parser
            self.parser.store = self.store
            # start the scanner with a delay
            delay_before_start = self.config.get('delay_before_start')
            if delay_before_start >= 0:
                self._start_delay = reactor.callLater(delay_before_start, \
                                                      self._scan_directories)
            return self
 
        def set_local_resource(local_resource):
            self.local_resource = local_resource
            return self

        bus = common.application.bus
        bus.register(self._device_add_cb, NewDeviceDetected)

        dfr = super(MediaScanner, self).initialize()
        store = common.application.store
        self.store = store

        dfr.addCallback(lambda x: self.create_schema())
        # FIXME: add configuration system. Passing through my configuration is
        # not exactly nice.
        dfr.addCallback(lambda x: DatabaseParser.create(self.config))
        dfr.addCallback(set_parser)
        dfr.addCallback(lambda x: LocalResource.create({}))
        dfr.addCallback(set_local_resource)
        # start dbus at the end
        dfr.addCallback(self._initialize_dbus)
        return dfr

    def _initialize_dbus(self, result=None):
        if dbus is None:
            # no dbus support
            return self

        bus = dbus.SessionBus()
        self.bus_name = dbus.service.BusName('com.fluendo.Elisa', bus)

        self.dbus_scanner = DBusInterface(self, bus,
                '/com/fluendo/Elisa/Plugins/Database/MediaScanner', self.bus_name)
        return self

    def _clean_dbus(self):
        if dbus is None:
            # no dbus support
            return

        bus = dbus.SessionBus()
        self.dbus_scanner.remove_from_connection(bus,
                '/com/fluendo/Elisa/Plugins/Database/MediaScanner')
        # BusName implements __del__, eew
        del self.bus_name

        # remove the reference cycle
        self.dbus_scanner = None


    def clean(self):
        self._initialized = False
        bus = common.application.bus
        bus.unregister(self._device_add_cb)
        self._clean_dbus()
        if self._current_delay and not self._current_delay.called:
            self._current_delay.cancel()

        if self._start_delay and not self._start_delay.called:
            self._start_delay.cancel()

        if self._scan_call and not self._scan_call.called:
            self._scan_call.cancel()

        def parser_cleaned(result):
            return super(MediaScanner, self).clean()

        dfr = self.parser.clean()
        dfr.addCallback(parser_cleaned)
        return dfr

    def _device_add_cb(self, msg, sender):
        if msg.model.protocol != 'file' or not msg.model.mount_point:
            return

        source_path = msg.model.mount_point
        if platform.system() == 'Windows':
            source_path = source_path.replace('\\', '/')

        filter_call = lambda path: path.startswith(source_path)
        self._scan_directories(filter_call)

    def create_schema(self):
        upgrader = DatabaseUpdaterNG(self.store)
        return upgrader.update_db()

    def running_get(self):
        return self._running

    def running_set(self, value):
        self._running = value
        self.scan_stat.running = value
        if not value:
            self._current_scan_deferred = None

    running = property(fget=running_get, fset=running_set)

    # general Resource Provider API
    def get(self, uri, context_model=None):
        """
        If the filename is C{statistic} you receive the
        L{elisa.plugins.database.scanner_models.Statistic} for this scanner
        """
        if uri.filename == 'statistic':
            return (self.scan_stat, defer.succeed(self.scan_stat))

        return (None, defer.fail(NotImplementedError()))

    def put(self, source_uri, container_uri,
            context_model=None, section=None):
        """
        put another uri into the queue and start the scanning process if it is
        not yet running

        @ivar section:  the name of the section relevant to the content that
                        is put.
        @type section:  L{str}
        """
        # FIXME check the container uri
        # FIXME no check if this uri might already in the queue...

        if source_uri.filename != '':
            # we want to have the content, so we have to add a trailing slash
            source_uri = source_uri.join('')

        def start_scanning(result, source_uri, section):
            name = os.path.basename(os.path.dirname(source_uri.path))
            s = ScanResource(source_uri, name, section=section)
            self.scan_stat.queue.put(s)

            if not self.running:
                self._scan()

            return s.defer

        dfr = self.parser.mark_deleted(source_uri.path)
        dfr.addCallback(start_scanning, source_uri, section)
        return dfr

    def delete(self, uri):
        """
        Delete a Media resource represented by a URI from the database.

        @param uri: URI pointing to the resource that should be deleted
        @type uri:  L{elisa.media_uri.MediaUri}

        @return:    a deferred fired when the resource got deleted
        @rtype:     L{elisa.core.utils.defer.Deferred}
        """
        if uri.filename != '':
            # source paths have to end with a /
            uri = uri.join('')

        dfr = self.parser.delete_files(uri.path, marked_only=False)
        return dfr

    # internally used
    def _update_stat(self, result, model, stat):
        if self._initialized:
            # scan can still be running if user exits from
            # application, so we encounter the risk to have this
            # method called while the media_scanner is being cleaned
            # up, hence the check on self._initialized
            stat.files_scanned += 1

        # deactivated as we don't use it atm and it leaks memory
        #if isinstance(result, Failure):
        #    stat.files_failed.append( (model.uri, result) )

    def _count_files(self, path):
        # Recursively estimate the total number of files in a given folder.

        def custom_walk(top):
            # A custom walk loosely based on os.walk found in python 2.6,
            # that follows symbolic links on *nix and shortcuts on windows.
            # See http://svn.python.org/view/python/trunk/Lib/os.py?rev=66142&view=markup

            # We may not have read permission for top, in which case we can't
            # get a list of the files the directory contains. os.path.walk
            # always suppressed the exception then, rather than blow up for a
            # minor reason when (say) a thousand readable directories are still
            # left to visit. That logic is copied here.
            try:
                names = os.listdir(top)
            except OSError:
                return

            dirs, nondirs = [], []
            for name in names:
                full_name = os.path.join(top, name)
                if os.path.isdir(full_name):
                    dirs.append(full_name)
                elif platform.system() == 'Windows' and \
                    os.path.splitext(name)[1].lower() == '.lnk':
                    try:
                        target = self._shortcut_resolver.resolve(full_name)
                    except pywintypes.com_error:
                        continue
                    else:
                        target = os.path.normpath(target)
                        if os.path.isdir(target):
                            dirs.append(target)
                        else:
                            nondirs.append(target)
                else:
                    nondirs.append(full_name)

            yield top, dirs, nondirs

            for path in dirs:
                for x in custom_walk(path):
                    yield x

        def count_files(top, total):
            for top, dirs, files in custom_walk(top):
                total[0] += len(files)
                yield None

        def return_total(result, total):
            return total[0]

        total = [0]
        top = os.path.abspath(path).encode(system_encoding())
        delay = self.config.get('delay', 0)
        dfr = cancellable_delay_coiterate(delay, count_files, top, total)
        dfr.addCallback(return_total, total)
        return dfr

    def _file_found(self, model, stat):
        ignored_extensions = self.config.get('ignored_extensions', [])
        ignore_file = False
        for ext in ignored_extensions:
            if model.uri.path.lower().endswith('.'+ext):
                ignore_file = True
                break
        if ignore_file:
            dfr = defer.succeed(None)
        else:
            dfr = self.parser.query_model(model, stat)
        dfr.addBoth(self._update_stat, model, stat)
        return dfr

    def _cleanup_deleted_files(self, result, scan_resource):
        return self.parser.delete_files(scan_resource.root_uri.path)

    def _scan(self, result=None):

        def run_next(result, scan_resource):
            scan_resource.state = ScanResource.SCANNING_DONE
            if not self._initialized:
                return
            self.scan_stat.currently_scanning = None
            self.scan_stat.scanned.append(scan_resource)

            if self.scan_stat.queue.empty():
                # stop processing
                self.running = False
                self._reset_auto_rescan(True)
            else:
                self._scan_call = reactor.callLater(0.1, self._scan)

        self.running = True

        scan_resource = self.scan_stat.queue.get_nowait()
        self.scan_stat.currently_scanning = scan_resource

        # clear stat
        scan_resource.last_scan = time.time()
        scan_resource.files_scanned = 0
        scan_resource.files_total = 0
        scan_resource.state = ScanResource.SCANNING_FS

        def set_total_files(result, scan_resource):
            scan_resource.files_total = result
            return self._scan_recursive(scan_resource)

        dfr = self._count_files(scan_resource.root_uri.path)
        dfr.addCallback(set_total_files, scan_resource)
        dfr.addCallback(self._cleanup_deleted_files, scan_resource)
        dfr.addCallback(run_next, scan_resource)

        self._current_scan_deferred = dfr

    def _rescan(self):
        self.debug("Starting to scan everything again")
        dfr = self._reschedule_scanned()
        dfr.addCallback(self._scan)
        return dfr

    def _reset_auto_rescan(self, restart=False):
        if self._pending_auto_rescan and self._pending_auto_rescan.active():
            self._pending_auto_rescan.cancel()

        rescan_every = self.config.get('scan_every', 0) * 3600
        if restart and rescan_every != 0:
            pending = reactor.callLater(rescan_every, self._rescan)
            self._pending_auto_rescan = pending

    def _reschedule_scanned(self):
        scan_stat = self.scan_stat

        scanned, scan_stat.scanned = scan_stat.scanned, [] 

        deleted_dfrs = []
        for stat in scanned:
            scan_stat.queue.put(stat)
            deleted_dfrs.append(self.parser.mark_deleted(stat.root_uri.path))

        if len(deleted_dfrs) == 0:
            return defer.succeed(None)

        return defer.DeferredList(deleted_dfrs)

    def _scan_recursive(self, scan_resource):

        delay = self.config.get('delay', 0)

        def stopper(data, file):
            self.warning("%s failed: %s" % (file, data))
            return None

        def is_ignored_dir(model):
            """
            Whether model is of a directory that should be ignored by the
            scanner
            """
            # We don't want to scan DVD dirs for now, as we don't handle them
            # correctly
            return model.name.lower() in ('video_ts', 'audio_ts', 'jacket_p')

        def scan_children(model, dirs):
            def iterator(model, dirs):
                while len(model.files):
                    if not self._initialized:
                        break
                    item = model.files.pop(0)
                    if isinstance(item, DirectoryModel):
                        if not is_ignored_dir(item):
                            # delay searching the subfolders for later
                            dirs.append(item.uri)
                    else:
                        dfr = self._file_found(item, scan_resource)
                        yield dfr

            return cancellable_delay_coiterate(delay, iterator, model, dirs)

        def scan_dirs(dirs):
            def iterator(dirs):
                while len(dirs):
                    uri = dirs.pop(0)
                    if uri.filename != '':
                        # we have a filename append the slash to get the
                        # content
                        uri = uri.join('')
                    model, dfr = self.local_resource.get(uri)
                    dfr.addCallback(scan_children, dirs)
                    dfr.addErrback(stopper, uri.path)
                    yield dfr

            return cancellable_delay_coiterate(delay, iterator, dirs)

        return scan_dirs([scan_resource.root_uri])
