# -*- coding: utf-8 -*-
# Elisa - Home multimedia server
# Copyright (C) 2006-2008 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 Elisa with Fluendo's plugins.
#
# The GPL part of Elisa is also available under a commercial licensing
# agreement from Fluendo.
# See "LICENSE.Elisa" in the root directory of this distribution package
# for details on that license.

"""
Media database, keeping references of elisa medias and their metadata
"""


__maintainer__ = 'Philippe Normand <philippe@fluendo.com>'

from elisa.core.component import Component
from elisa.core import log, common, db_backend
from elisa.extern import natural_sort
from elisa.core.media_uri import MediaUri, quote, unquote
from elisa.base_components.media_provider import NotifyEvent
from elisa.core.observers.dict import DictObservable

import os
import threading

DB_CONNECTIONS = {}
CURRENT_DB_VERSION=1

# FIXME : schema is incomplete
SQL_SCHEMA="""\
CREATE TABLE core_meta (
    version INTEGER UNIQUE
);

CREATE TABLE core_source (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    uri TEXT NOT NULL UNIQUE,
    short_name TEXT,
    available INT DEFAULT 1
);

CREATE TABLE core_media (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    uri TEXT UNIQUE NOT NULL,
    short_name TEXT,
    media_node_id INT DEFAULT 0,
    source_id INT DEFAULT 0,
    format TEXT DEFAULT '',
    typ TEXT DEFAULT '',
    deleted INT DEFAULT 0,
    rating INT DEFAULT 50,
    date_added DATETIME,
    last_played DATETIME DEFAULT NULL,
    fs_mtime DATETIME DEFAULT NULL,
    updated INT DEFAULT 0
);

CREATE TABLE core_audio_media (
    media_id INT UNIQUE,
    artist TEXT DEFAULT 'unknown artist',
    album TEXT DEFAULT 'unknown album',
    song TEXT DEFAULT '',
    track INT DEFAULT 0,
    cover_uri TEXT DEFAULT ''
);

CREATE TABLE core_video_media (
    media_id INT UNIQUE
);

CREATE TABLE core_image_media (
    media_id INT UNIQUE
);

CREATE UNIQUE INDEX INDX_SOURCE_ID ON core_source(id);

CREATE UNIQUE INDEX INDX_MEDIA_URI ON core_media(uri);
CREATE INDEX INDX_MEDIA_SOURCEID ON core_media(source_id);
CREATE INDEX INDX_MEDIA_MD_ID ON core_media(media_node_id);
CREATE INDEX INDX_MEDIA_ARTIST ON core_audio_media(artist);

CREATE INDEX INDX_AUDIO_MEDIA_ID ON core_audio_media(media_id);
CREATE INDEX INDX_VIDEO_MEDIA_ID ON core_video_media(media_id);
CREATE INDEX INDX_IMAGE_MEDIA_ID ON core_image_media(media_id);
"""


class MediaDB(Component):
    """ Elisa Media database store

    I'm keeping a cache of media source hierarchies in a database. I
    use the L{elisa.core.db_backend} to communicate with supported
    database backends.

    Media sources are basically roots of media locations, like ~/Music
    folder for audio content for instance. Media sources are referenced in
    the "source" db table.

    Medias can be both files and directories. Each Media has a parent
    media id and a source id. Content-type specific information are
    stored in diferent tables for audio, video and images.

    """

    # available metadata: key = name used in URI, value = db field
    available_meta = {
                      'artists'    : 'artist',
                      'albums'    : 'album',
                      'files'    : 'uri'
    }

    # class attributes
    _db_connections_lock = threading.Lock()
    _db_connections = {}

    def __init__(self, backend=None, first_load=False):
        """
        Initialize our _backend instance variable. If backend is None
        we retrieve one in the Application.

        @keyword backend:    the database backend to use
        @type backend:       L{elisa.core.db_backend.DBBackend} or
                             None (to use application's)
        @keyword first_load: is it the first time the db is loaded?
        @type first_load:    bool
        """
        self.log_category = "media_db"
        super(MediaDB, self).__init__()
        
        if not backend:
            backend = common.application.db_backend

        self._backend = backend

        if first_load:
            self._check_schema()

    @classmethod
    def get_connection(cls):
        db = None

        try:
            cls._db_connections_lock.acquire()
            
            current_thread = threading.currentThread()
            thread_id = id(current_thread)
            
            first_load = False
            try:
                backend = cls._db_connections[thread_id]
            except KeyError:
                first_load = True
                opts = cls._get_db_options()
                try:
                    backend = db_backend.DBBackend(**opts)
                except db_backend.DBBackendError, error:
                    # FIXME: use log somehow
                    print 'db creation failed', error
                else:
                    cls._db_connections[thread_id] = backend

            if backend:
                db = MediaDB(backend, first_load)
        finally:
            cls._db_connections_lock.release()

        return db
   
    @classmethod
    def _get_db_options(cls):
        config = common.application.config
        
        # copy the section so we can modify it in memory
        opts = dict(config.get_section('media_db:db'))

        # SQLite db should be in same directory as the elisa config file.
        if opts['db_backend'] == 'sqlite':
            if not os.path.isabs(opts['database']):
                config_dir = config.get_config_dir()
                opts['database'] = os.path.join(config_dir, opts['database'])
            
        return opts 

    def close(self):
        """
        Disconnect the backend
        """
        self._backend.disconnect()

    def _check_schema(self):
        version = 0
        query = "select version from core_meta"
        version_row = self._backend.sql_execute(query, quiet=True)
        if version_row:
            version = version_row[0].version

        if version < CURRENT_DB_VERSION:
            # FIXME: handle future db schema upgrades
            self._reset()

    def _reset(self):
        self.debug("Resetting the database")

        # drop existing tables
        for table_name in db_backend.table_names(SQL_SCHEMA):
            self._backend.sql_execute("drop table %s" % table_name, quiet=True)

        # create tables
        for sql in SQL_SCHEMA.split(';'):
            if sql:
                self._backend.sql_execute(sql)


        specific_sql = db_backend.BACKEND_SPECIFIC_SQL.get(self._backend.name)
        if specific_sql:
            for query in specific_sql.split(';'):
                self._backend.sql_execute(query)

        self._backend.sql_execute("insert into core_meta(version) values(?)",
                                  CURRENT_DB_VERSION)
        self._backend.save_changes()
        self._backend.reconnect()

    def get_files_count_for_source_uri(self, source_uri):
        """
        DOCME
        """
        request = "select count(*) as c from core_media media, " \
                "core_source source where source.uri = '%s' and " \
                "media.source_id = source.id" % source_uri
        count = self._backend.sql_execute(request)[0].c
        return count


    def prepare_source_for_update(self, source):
        """
        DOCME
        """
        # TODO: move this to media_db
        self.info('Preparing %s' % source.uri)
        req = 'update core_media set updated=0 where source_id=%s and deleted=0' % source.id
        self._backend.sql_execute(req)

    def hide_un_updated_medias_for_source(self, source):
        """
        DOCME
        """
        # TODO: move this to media_db
        req = 'select uri, typ, format from core_media where updated=0 and source_id=%s' % source.id
        rows = self._backend.sql_execute(req)
        count = len(rows)
        if count:
            self.info("Deleting %s medias from db" % count)
            req = 'update core_media set updated=1, deleted=1 where source_id=%s and updated=0' % source.id
            self._backend.sql_execute(req)
        return rows

    def add_source(self, uri):
        """ Add a new media source in the database

        Add a new row in the "source" table. Source scanning is not handled
        by this method. See the L{elisa.core.media_scanner.MediaScanner}
        for that.

        @param uri:        the URI identifying the media source
        @type uri:         L{elisa.core.media_uri.MediaUri}
        @returns:          the newly created source.
        @rtype:            L{elisa.extern.db_row.DBRow}
        """
        self.info("Adding source %s" % uri)
        short_name = uri.label
        request = "insert into core_source(uri, short_name) values(?, ?)"
        self._backend.sql_execute(request, uri, short_name)
        source = self.get_source_for_uri(uri)
        now = 'current_timestamp'
        request = "insert into core_media(uri, updated, date_added, media_node_id, short_name, source_id) values (?, 1, %s, -1, ?, ?)" % now
        args = (uri, short_name, source.id)
        #self._backend.sql_execute(request, *args)
        self._backend.save_changes()
        #REVIEW: is the cost of saving changes high ? if we add a lot of sources
        #in a row, it might be better to call one time the saving. Could be a
        #boolean parameter
        return source

    def hide_source(self, uri):
        """ Mark a source as unavailable in the database.

        Update the "available" flag of the given source record in the "source"
        table. Return True if source was correctly hidden

        @param uri:        the URI identifying the media source
        @type uri:         L{elisa.core.media_uri.MediaUri}
        @rtype:        bool
        """
        request = "update core_source set available=0 where uri='%s'" % uri
        self._backend.sql_execute(request)
        return True

    def show_source(self, uri):
        """ Mark a source as available in the database.

        Update the "available" flag of the given source record in the "source"
        table. Return True if source was correctly shown

        @param uri:        the URI identifying the media source
        @type uri:         L{elisa.core.media_uri.MediaUri}
        @rtype:        bool
        """
        request = "update core_source set available=1 where uri='%s'" % uri
        self._backend.sql_execute(request)
        return True

    def is_source(self, row):
        """
        DOCME
        """
        return row.has_key('available')

    def add_media(self, uri, short_name, source_id, content_type, **extra):
        """ Add a new media in the "media" table and in specialized tables

        There's one specialized table for each content-type (audio,
        video, picture). The Media can be either a file or a directory.

        If the media is already on database but marked as unavailable
        or deleted it will be marked as available and undeleted. In
        that case not further insert will be done.

        @param uri:          the URI identifying the media
        @type uri:           L{elisa.core.media_uri.MediaUri}
        @param short_name:   display-friendly media name
        @type short_name:    string
        @param parent:       the source or media to register the media in
        @type parent:        L{elisa.extern.db_row.DBRow}
        @param content_type: the media content-type ('directory', 'audio', etc)
        @type content_type:  string
        @param extra:        extra row attributes
        @type extra:         dict

        @returns:            True if inserted, False if updated
        @rtype:              bool

        @todo: complete keywords list
        """

        """
        if not force_insert:
            node = self.get_media_information(uri)
            if node:
                if node.deleted and node.artist != 'unknown artist':
                    do_update = True
        else:
            do_insert = True
        """
        
        media_exist = self.media_exists(uri)
        
        extra['source_id'] = source_id
        metadata = extra.get('metadata',{})
        if 'metadata' in extra:
            del extra['metadata']
        
        if not media_exist:

            extras_spc, extras_val, extras_marks = self._kw_extract(extra)
            if extras_spc:
                req = "insert or replace into core_media(uri, updated, date_added, short_name,typ, %s) values (?,1, current_timestamp, ?,?,%s)"
                req = req % (extras_spc, extras_marks)
            else:
                req = "insert or replace into core_media(uri, updated, date_added, short_name,typ) values (?, 1, current_timestamp, ?,?)"

            id = self._backend.insert(req, uri, short_name, content_type,
                                      *extras_val)

            # add new row in specialized table
            format = extra.get('format')
            if format:
                spec, values, marks = self._kw_extract(metadata)
                if spec and values:
                    req = "insert or replace into core_%s_media(media_id,%s) values (?,%s)"
                    req = req % (format, spec, marks)
                    args = (id,) + tuple(values)
                else:
                    req = "insert or replace into core_%s_media(media_id) values (?)" % format
                    args = (id,)
                self._backend.insert(req, *args)
            return True

        else:
            pass
            #FIXME update is not implemented
            #extra.update(deleted=0, updated=1)
            #self.update_media(node, **extra)
            
        return False

    def del_media_node(self, media, force_drop=False):
        """ Mark a media as deleted in database.

        FIXME: document force_drop

        @param media: the media to mark as deleted.
        @type media:  L{elisa.extern.db_row.DBRow}
        @rtype:       bool
        """
        deleted = False
        if not media.deleted:
            if force_drop:
                self.info("Dropping Media %r from DB", media.uri)
                if media.format:
                    req = "delete from core_%s_media where media_node_id=?"
                    self._backend.sql_execute(req, media.id)
                req = "delete from core_media where id=?"
                self._backend.sql_execute(req, media.id)
            else:
                self.info("Marking the Media %r as unavailable in DB", media.uri)
                req = "update core_media set deleted=1 where id=?"
                self._backend.sql_execute(req, media.id)
            deleted = True
        return deleted

    def get_source_for_uri(self, uri):
        """ Find in which media source the given uri is registered with.

        The URI has to be referenced in the "source" table.

        @param uri: the URI to search in the "source" table
        @type uri:  L{elisa.core.media_uri.MediaUri}
        @rtype:     L{elisa.extern.db_row.DBRow}
        """
        row = None
        request = "select * from core_source where uri=?"
        rows = self._backend.sql_execute(request, uri)
        if rows:
            row = rows[0]
        return row


    def media_exists(self, uri):
        return False
        request = u"select count(*) from core_media where uri=?"
        rows = self._backend.sql_execute(request, uri)
        
        print rows
        return False

        
    def get_media_information(self, uri, extended=True, media_type=None):
        """ Find in database the media corresponding with the given URI.

        The URI has to be referenced in the "media" table.

        @param uri: the URI to search in the "media" table
        @type uri:  L{elisa.core.media_uri.MediaUri}
        @rtype:     L{elisa.extern.db_row.DBRow}
        """
        media = None
        if extended:
            if not media_type:
                request = u"select * from core_media where uri=?"
                rows = self._backend.sql_execute(request, uri)
                if rows:
                    media_row = rows[0]
                    if media_row.format:
                        media_type = media_row.format
                    else:
                        media = media_row
            if media_type and not media:
                req = "select m.*, s.* from core_media m, core_%s_media s where uri=? and id=media_id"
                req = req % media_type #, media_row.id)
                result = self._backend.sql_execute(req, uri)
                if result:
                    media = result[0]
        if not media:
            request = u"select * from core_media where uri=?"
            rows = self._backend.sql_execute(request, uri)
            if rows:
                media = rows[0]

        return media

    def get_medias(self, source=None, media_type=None):
        req = 'select * from core_media where'
        args = ()
        if source:
            where = 'source_id=%s' % source.id
        elif media_type:
            where = 'format=?'
            args = (media_type,)
        else:
            where = 'media_node_id=-1'
        req = "%s %s and deleted=0" % (req, where)
        return self._backend.sql_execute(req, *args)


    def get_media_with_id(self, media_id):
        """ Fetch the media with given id in the database

        @param media_id: the identifier of the Media i have to dig in the db
        @type media_id:  int
        @rtype:          L{elisa.extern.db_row.DBRow}
        """
        media = None
        request = "select * from core_media where id=%s limit 1" % media_id
        result = self._backend.sql_execute(request)
        if result:
            media = result[0]
        return media


    def update_media(self, media, **new_values):
        """ Update some attributes in database of the given media

        @todo: document valid keys of new_values dict

        @param media:        the media I'm checking
        @type media:         L{elisa.extern.db_row.DBRow}
        @param new_values:   attributes to update. Keys have to match "media"
                             table column names.
        @type new_values:    dict
        """
        table = "core_media"
        parsed = {}
        for k,v in new_values.iteritems():
            if type(v) == long:
                v = int(v)
            if v is not None:
                parsed[k] = v
        values = ', '.join(["%s=?" % k for k in parsed.keys()])
        if values:
            req = "update %s set %s where id=?" % (table, values)
            args = parsed.values() + [media.id,]
            self._backend.sql_execute(req, *args)

    def update_media_metadata(self, media, **metadata):
        """
        DOCME
        """
        format = media.format
        if not format:
            format = self._guess_format_from_metadata(metadata)
        if format:
            table = "core_%s_media" % format

            req = "select count(media_id) as c, %s.* from %s, core_media where media_id=%s and id=media_id"
            result = self._backend.sql_execute(req % (table, table, media.id))
            if not result or result[0].c == 0:
                spec, values, marks = self._kw_extract(metadata)
                if spec:
                    req = "insert into %s(media_id,%s) values(?,%s)"
                    req = req % (table, spec, marks)
                    args = (media.id, ) + tuple(values)
                else:
                    req = "insert into %s(media_id) values(?)" % table
                    args = (media.id,)
                self._backend.sql_execute(req, *args)
            else:
                media = result[0]
                parsed = {}
                for k,v in metadata.iteritems():
                    if type(v) == long:
                        v = int(v)
                    if v is not None:
                        parsed[k] = v
                values = ', '.join(["%s=?" % k for k in parsed.keys()])
                if self._node_changed(media, parsed) and values:
                    req = "update %s set %s where media_id=?" % (table, values)
                    args = tuple(parsed.values()) + (media.media_id,)
                    self._backend.sql_execute(req, *args)
##                     for k,v in parsed.iteritems():
##                         setattr(parent, k, v)


    def get_next_location(self, uri, root_uri):
        # FIXME : bahhhhhhhhhhhhhhhh
        # to many resources used for this

        select_attribute, path_values = self._parse_uri(root_uri)
        query, args = self._build_request('uri', path_values)
        rows = self._backend.sql_execute(query, *args)
        return_next = False

        if rows and str(uri) == str(root_uri):
            return MediaUri(rows[0][0])

        for row in rows:
            if return_next == True:
                return MediaUri(row[0])

            if row[0] == str(uri):
                return_next = True

        return None

    def _parse_uri(self, uri):

        current_attribute = ''
        select_attribute = ''
        path_values = {}
        for element in uri.path.split('/'):
            if element != '':
                if current_attribute == '' and self.available_meta.has_key(element):
                    current_attribute = element
                    select_attribute = current_attribute
                elif current_attribute != '':
                    path_values[current_attribute] = element
                    current_attribute = ''
                    select_attribute = ''
                else:
                    self.warning("uri %s is invalid, metadata '%s' not supported" % (uri, element) )
                    return (False, False)

        return (select_attribute, path_values)


    def _build_request(self, select_clause, path_values, start=0, item_count=-1):
        #start to build request
        args = []
        where_clause = ''
        for key in path_values:
            if path_values[key] != '':
                val = path_values[key]
                if self.available_meta[key] != 'uri':
                    val = unquote(path_values[key])

                where_clause += " %s=? and" % \
                                    (self.available_meta[key])
                args.append(val)

        limit_clause = ''
        if start > 0 and item_count == -1:
            limit_clause = ' limit %i' % start
        elif start > 0 and item_count > 1:
            limit_clause += ' limit %i,%i' % start,item_count

        #remove last 'and' in where_clause
        where_clause += ' C.id=A.media_id'
        # FIXME artist != '' due to a biug in metadata : must be filled with unknown_artist
        where_clause += " and A.artist != '' and A.album != ''"
        query = "select distinct %s from core_media C, core_audio_media A where%s" % (select_clause, where_clause)
        query += limit_clause
        query += ' order by 1'

        return query, args


    def get_uris_by_meta_uri(self, uri, children, start=0, item_count=-1):
        """
        This function can handle an URI with the elisa:// scheme.
        It returns a list of uris matching the request defined
        in uri's path

        @param uri:     uri representing an elisa:// scheme
        @type uri:      L{elisa.core.media_uri.MediaUri}
        @param children:     uri representing an elisa:// scheme
        @type children:      list of tuple (uri, info)
        @rtype:         list of tuple (string, L{elisa.core.media_uri.MediaUri}, int)
        """

        if not self.has_children(uri):
            return children

        select_attribute, path_values = self._parse_uri(uri)

        if select_attribute == False:
            return children

        uri_source = unicode(uri)
        #ADD / at the end of the URI
        if uri_source[-1] != '/':
            uri_source += '/'
        
        # FIXME theses rules mut be automatic with the new database scheme
        if select_attribute == '':
            if path_values.has_key('albums'):
                select_attribute = 'files'
            elif path_values.has_key('artists') and not path_values.has_key('albums'):
                select_attribute = 'albums'
                uri_source += 'albums/'
            
        #Case A : I know the attribute to return
        if select_attribute == '':
            for key in self.available_meta:
                if key not in path_values.keys():
                    child_uri = uri_source + key
                    metadata = DictObservable()
                    children.append( (MediaUri(unicode(child_uri)), metadata) )
        else:   
            
            if select_attribute == 'files':
                select_clause = 'track, song, uri, cover_uri, artist, album'
            else:
                select_clause = self.available_meta[select_attribute]

            query, args = self._build_request(select_clause, path_values, start, item_count)
            rows = self._backend.sql_execute(query, *args)
 
            for row in rows:
                child_label = None
                if select_attribute == 'files':
                    child_uri = row[2]
                    track = row[0]
                    if track > 0:
                        child_label = "%s - %s" % (str(track).zfill(2),
                                                   row[1])
                    else:
                        child_label = row[1]
                else:
                    child_uri = uri_source + quote(row[0])

                metadata = DictObservable()
                if select_attribute == 'albums':
                    metadata['album'] = row[0]
                    metadata['artist'] = self.get_artist_from_album(row[0])
                elif select_attribute == 'files':
                    metadata['album'] = row[5]
                    metadata['artist'] = row[4]
                    default_image = row[3]
                    if default_image == u'':
                        default_image = None
                    else:
                        default_image = MediaUri(default_image)
                    metadata['default_image'] = default_image
                
                new_uri = MediaUri(unicode(child_uri))
                if child_label:
                    new_uri.label = child_label
                    
                children.append( (new_uri, metadata) )

        return children


    def has_children(self, uri):
        if 'files' not in uri.path.split('/'):
            return True

        no_slash_uri = str(uri)
        if no_slash_uri[-1] == '/':
            no_slash_uri = uri[0:-1]

        if no_slash_uri.endswith('files'):
            return True

        return False

    def get_artist_from_album(self, album):
        """
        DOCME
        """
        # FIXME temporary function : can be removed when de DB  will re change
        # FIXME artist != '' due to a biug in metadata : must be filled with unknown_artist
        request = "select artist from core_audio_media where album=? and artist != '' limit 1"
        return self._backend.sql_execute(request, unquote(album))[0][0]

    def _guess_format_from_metadata(self, metadata):
        formats = {'audio': self._backend.table_columns('core_audio_media'),
                   'video': self._backend.table_columns('core_video_media'),
                   'picture': self._backend.table_columns('core_image_media')
                   }
        format = ''
        keys = set(metadata.keys())
        for fmt, col_names in formats.iteritems():
            if keys.issubset(col_names):
                format = fmt
                break
        return format

    def _node_changed(self, node, values):
        changed = False
        for key, value in values.iteritems():
            stored_value = getattr(node, key)
            if stored_value != value:
                changed = True
                break

        return changed


    def _kw_extract(self, kw):
        values = []
        spec = []

        for col_name, value in kw.iteritems():
            if value == None:
                continue
            spec.append(col_name)
            if type(value) == long:
                value = int(value)
            if type(value) not in (int, str, unicode):
                value = repr(value)
            values.append(value)

        spec = ','.join(spec)
        #values = ','.join(values)
        marks = ','.join('?' * len(values))
        if marks.endswith(','):
            marks = marks[:-1]
        return (spec, values, marks)
