#!/usr/bin/python
# -*- coding: utf-8 -*-

"""
Provides a class giving access to the MythTV database.
"""
import os
import sys
import xml.dom.minidom as minidom
import code
import getopt
from datetime import datetime

from MythLog import *

# create logging object
log = MythLog(CRITICAL, '#%(levelname)s - %(message)s', 'MythDB')

# check for dependency
try:
    import MySQLdb
except:
    log.Msg(CRITICAL, "MySQLdb (python-mysqldb) is required but is not found.")
    sys.exit(1)

SCHEMA_VERSION = 1244


class ListData(object):
    """
    Basic class for providing simple management of lists of data.
    """
    # Requires two lists of equal length to describe input fields
    #   The first list is one of field names
    #   The second list is one of field types
    #     0: integer
    #     1: float
    #     2: bool
    #     3: string
    #     4: date -- processed by datetime.fromtimestamp(int(value))
    #   The second list can be a single string 'Pass'
    #               to pass all values untouched

    field_order = []
    field_type = []
    field = 0
    data = None
    def __getattr__(self,name):
        if name in self.__dict__:
            return self.__dict__[name]
        elif name in self.field_order:
            return self.data[name]
        else:
            raise AttributeError("'ListData' object has no attribute '%s'"%name)

    def __setattr__(self,name,value):
        if name in self.field_order:
            self.data[name] = value
        else:
            self.__dict__[name] = value

    def __getitem__(self,key):
        if isinstance(key,str):
            if key in self.data:
                return self.data[key]
        elif isinstance(key,int):
            if key in range(0,len(self.field_order)):
                if self.field_order[key] in self.data:
                    return self.data[self.field_order[key]]
        raise KeyError('Value not available in ListData')

    def __setitem__(self,key,value):
        if isinstance(key,str):
            if key in self.field_order:
                self.data[key] = value
        elif isinstance(key,int):
            if key in range(0,len(self.field_order)):
                self.data[self.field_order[key]] = value
        raise KeyError('Value not usable in ListData')

    def __init__(self, data):
        self.data = {}
        for i in range(0,len(self.field_order)):
            if data[i] == '':
                datum = None
            elif self.field_type == 'Pass':
                datum = data[i]
            elif self.field_type[i] == 0:
                datum = int(data[i])
            elif self.field_type[i] == 1:
                datum = float(data[i])
            elif self.field_type[i] == 2:
                datum = bool(data[i])
            elif self.field_type[i] == 3:
                datum = data[i]
            elif self.field_type[i] == 4:
                datum = datetime.fromtimestamp(int(data[i]))
            self.data[self.field_order[i]] = datum

    def __iter__(self): return self

    def next(self):
        if self.field == len(self.field_order):
            self.field = 0
            raise StopIteration
        self.field += 1
        return self.data[self.field_order[self.field-1]]

    def joinInt(self,high,low):
        """
        Returns a single long from a pair of signed integers.
        """
        return (high + (low<0))*2**32 + low

    def listFields(self):
        """
        Returns a tuple of all available object fields.
        """
        return tuple(self.data.keys())


class DBData(ListData):
    """
    Altered ListData adding several functions for dealing with database tables.
    """
    field_type = 'Pass'
    where = None
    wheredat = None
    select = '*'
    

    def __init__(self,table,data=None,db=None):
        self.db = db
        if not self.db:
            self.db = MythDB()

        self.table = table
        self.field_order = self.db.getTableFieldNames(self.table)

        if data:
            ListData.__init__(self,data)

    def _getData(self,data=None):
        """Updates table with given data, or that pulled from database."""
        if ((self.where is None) or \
                (self.wheredat is None)) and\
                (data is None):
            return
        if data is None:
            c = self.db.cursor()
            c.execute("""SELECT %s FROM %s WHERE %s""" \
                    % (self.select, self.table, self.where), self.wheredat)
            data = c.fetchone()
            c.close()

        if data is None:
            return
        ListData.__init__(self,data)

class DBDataWrite(DBData):
    """
    Altered DBData, with support for writing back to the database.
    'defaults' dictionary allows default values when creating creating new
            and updating old entries
    """
    defaults = {}
    def _sanitize(self,data):
        """Remove fields from dictionary that are not in database table."""
        for key in data:
            if key not in self.field_order:
                del data[key]
        for key in self.defaults:
            if key in data:
                if self.defaults[key] is None:
                    del data[key]
                elif data[key] is None:
                    data[key] = self.defaults[key]
        return data

    def _readDict(self,data):
        """Transfer data from dictionary into local data"""
        data = self._sanitize(data)
        if self.data is None:
            self.data = self.defaults
        for key in data:
            self.data[key] = data[key]
        

    def create(self,data):
        """
        Creates a new database entry using given dictionary.
        Only works on an uninitialized object.
        """
        if self.data is not None:
            raise MythError('DBDataWrite object already bound to '+\
                                                    'existing instance')
        self._readDict(data)
        data = self._sanitize(self.data)
        c = self.db.cursor()
        fields = ', '.join(data.keys())
        format_string = ', '.join(['%s' for d in data.values()])
        c.execute("""   INSERT INTO %s (%s)
                        VALUES(%s)"""\
                    % (self.table, fields, format_string), data.values())
        intid = c.lastrowid
        c.close()
        return intid

    def update(self,data=None):
        """
        Update database against local information.
        Optionally reads in dictionary before updating.
        """
        if (self.where is None) or \
                (self.wheredat is None) or \
                (self.data is None):
            return
        if data is not None:
            self._readDict(data)
        data = self._sanitize(self.data)
        c = self.db.cursor()
        format_string = ', '.join(['%s = %%s' % d for d in data])
        sql_values = self.data.values()
        sql_values.extend(self.wheredat)
            
        c.execute("""   UPDATE %s SET %s WHERE %s"""\
                % (self.table, format_string, self.where), sql_values)
        c.close()
        self._getData()

    def delete(self):
        """
        Delete video entry from database.
        """
        if (self.where is None) or \
                (self.wheredat is None) or \
                (self.data is None):
            return
        c = self.db.cursor()
        c.execute("""   DELETE FROM %s WHERE %s"""\
                % (self.table, self.where), self.wheredat)
        c.close()

class MythDB(object):
    """
    A connection to the mythtv database.
    """
    tablefields = {}
    settings = {}
    def __init__(self, args=None):
        # Setup connection variables
        dbconn = {
                'host' : None,
                'name' : None,
                'user' : None,
                'pass' : None,
                'USN'  : None,
                'PIN'  : None
                }

        # Try to read the config.xml file used by MythTV.
        config_files = [ os.path.expanduser('~/.mythtv/config.xml') ]
        if 'MYTHCONFDIR' in os.environ:
            config_files.append('%s/config.xml' % os.environ['MYTHCONFDIR'])

        found_config = False
        for config_file in config_files:
            try:
                config = minidom.parse(config_file)
            except:
                continue

            dbconn['host'] = None
            dbconn['name'] = None
            dbconn['user'] = None
            dbconn['pass'] = None
            for token in config.getElementsByTagName('Configuration')[0].\
                                getElementsByTagName('UPnP')[0].\
                                getElementsByTagName('MythFrontend')[0].\
                                getElementsByTagName('DefaultBackend')[0].\
                                childNodes:
                if token.nodeType == token.TEXT_NODE:
                    continue
                try:
                    if token.tagName == "DBHostName":
                        dbconn['host'] = token.childNodes[0].data
                    elif token.tagName == "DBName":
                        dbconn['name'] = token.childNodes[0].data
                    elif token.tagName == "DBUserName":
                        dbconn['user'] = token.childNodes[0].data
                    elif token.tagName == "DBPassword":
                        dbconn['pass'] = token.childNodes[0].data
                    elif token.tagName == "USN":
                        dbconn['USN'] = token.childNodes[0].data
                    elif token.tagName == "SecurityPin":
                        dbconn['PIN'] = token.childNodes[0].data
                except:
                    pass

            if dbconn['host'] != None and dbconn['name'] != None and \
                                          dbconn['user'] != None and \
                                          dbconn['pass'] != None:
                log.Msg(INFO, 'Using config %s', config_file)
                found_config = True
                break

        # Overrides from command line parameters
        try:
            opts, args = getopt.getopt(args, '', \
                        ['dbhost=', 'user=', 'pass=', 'database='])
            for o, a in opts:
                if o == '--dbhost':
                    dbconn['host'] = a
                if o == '--user':
                    dbconn['user'] = a
                if o == '--pass':
                    dbconn['pass'] = a
                if o == '--database':
                    dbconn['name'] = a
        except:
            pass

        if not dbconn['host'] and not found_config:
            raise MythError('Unable to find MythTV configuration file')

        try:
            self.db = MySQLdb.connect(  user=dbconn['user'],
                                        host=dbconn['host'],
                                        passwd=dbconn['pass'],
                                        db=dbconn['name'],
                                        use_unicode=True,
                                        charset='utf8')
            log.Msg(INFO, 'DB Connection info (host:%s, name:%s,'+
                            ' user:%s, pass:%s)',   dbconn['host'],
                                                    dbconn['name'],
                                                    dbconn['user'],
                                                    dbconn['pass'])
        except:
            raise MythError("Connection failed for '%s'@'%s' to database %s"+
                            "using password %s" % ( dbconn['user'], 
                                                    dbconn['host'],
                                                    dbconn['name'],
                                                    dbconn['pass']))

        # check schema version
        sver = int(self.getSetting('DBSchemaVer'))
        if SCHEMA_VERSION != sver:
            log.Msg(CRITICAL, 'DB speaks schema version %d, '+
                                'but we speak version %d', 
                                sver, SCHEMA_VERSION)
            raise MythError('Mismatched schema version')

    def __del__(self):
        self.db.close()

    def close(self): self.__del__()

    def getTableFieldNames(self, tablename):
        """Finds the table field names
        return array of field names in the order they are in the table
        return None if the table does not exist
        """
        if tablename in self.tablefields:
            return self.tablefields[tablename]
        table_names=[]
        c = self.db.cursor()
        try:
            c.execute("DESC %s" % (tablename,))
        except:
            return None

        while True: # Add field names until a break (no more fields)
            name = c.fetchone()
            if name != None:
                table_names.append(name[0])
                continue
            break
        c.close()
        self.tablefields[tablename] = table_names
        return table_names

    def getAllSettings(self, hostname=None):
        """
        Returns values for all settings.

        Returns None if there are no settings. If multiple rows are
        found (multiple hostnames), returns the value of the first one.
        """
        log.Msg(DEBUG, 'Retrieving all setting for host %s', hostname)
        c = self.db.cursor()
        if hostname is None:
            c.execute("""
                    SELECT value, data
                    FROM settings
                    WHERE hostname IS NULL""")
        else:
            c.execute("""
                    SELECT value, data
                    FROM settings
                    WHERE hostname LIKE(%s)""",
                    (hostname))
        rows = c.fetchall()
        c.close()

        if rows:
            for row in rows:
                if str(hostname) not in self.settings:
                    self.settings[str(hostname)] = {}
                if row[0] not in self.settings[str(hostname)]:
                    self.settings[str(hostname)][row[0]] = row[1]
            return rows
        else:
            return None

    def getSetting(self, value, hostname=None):
        """
        Returns the value for the given MythTV setting.

        Returns None if the setting was not found. If multiple rows are
        found (multiple hostnames), returns the value of the first one.
        """
        log.Msg(DEBUG, 'Looking for setting %s for host %s', value, hostname)
        if str(hostname) in self.settings:
            if value in self.settings[str(hostname)]:
                return self.settings[str(hostname)][value]
        else:
            self.settings[str(hostname)] = {}

        c = self.db.cursor()
        if hostname is None:
            c.execute("""
                    SELECT data
                    FROM settings
                    WHERE value LIKE(%s) AND hostname IS NULL LIMIT 1""",
                    (value))
        else:
            hostname += '%'
            c.execute("""
                    SELECT data
                    FROM settings
                    WHERE value LIKE(%s) AND hostname LIKE(%s) LIMIT 1""",
                    (value, hostname))
        row = c.fetchone()
        c.close()

        if row:
            self.settings[str(hostname)] = row[0]
            return row[0]
        else:
            return None

    def setSetting(self, value, data, hostname=None):
        """
        Sets the value for the given MythTV setting.
        """
        log.Msg(DEBUG, 'Setting %s for host %s to %s', value, hostname, data)
        c = self.db.cursor()
        ws = None
        ss = None
        t = None

        if hostname is None:
            ws = "WHERE value LIKE (%s) AND hostname IS NULL"
            ss = "(data,value) VALUES (%s,%s)"
            t = (data,value)
        else:
            hostname += '%'
            ws = "WHERE value LIKE (%s) AND hostname LIKE (%s)"
            ss = "(data,value,hostname) VALUES (%s,%s,%s)"
            t = (data,value,hostname)

        if c.execute("""UPDATE settings
                        SET data=%%s %s LIMIT 1""" % ws, t) == 0:
            c.execute("""INSERT INTO settings %s""" % ss, t)
        c.close()

    def getCast(self, chanid, starttime, roles=None):
        """
        Returns cast members for a recording
        A string for 'roles' will return a tuple of members for that role
        A tuple of strings will return a touple containing all listed roles
        No 'roles' will return a dictionary of tuples
        """
        c = self.db.cursor()
        if c.execute("""SELECT * FROM recorded WHERE 
                chanid=%s AND starttime=%s""",
                (chanid,starttim)) == 0:
            raise MythError('recording does not exist')
        if roles is None:
            length = c.execute("""SELECT name,role
                    FROM people,credits
                    WHERE people.person=credits.person
                    AND chanid=%s AND starttime=%s
                    ORDER BY role""", (chanid, starttime))
            if length == 0:
                return ()
            crole = None
            clist = []
            dict = {}
            for name,role in c.fetchall():
                if crole is None:
                    crole = role
                if crole == role:
                    clist.append(name)
                else:
                    dict[crole] = tuple(clist)
                    clist = []
                    clist.append(name)
                    crole = role
            dict[crole] = tuple(clist)
            c.close()
            return dict
        elif isinstance(roles,str):
            length = c.execute("""SELECT name
                    FROM people,credits
                    WHERE people.person=credits.person
                    AND chanid=%s AND starttime=%s
                    AND role=%s""", (chanid, starttime, roles))
            if length == 0:
                return ()
            names = []
            for name in c.fetchall():
                names.append(name[0])
            return tuple(names)
        elif isinstance(roles,tuple):
            length = c.execute("""SELECT name
                    FROM people,credits
                    WHERE people.person=credits.person
                    AND chanid=%d AND starttime=%d
                    AND role IN %s""" % (chanid, starttime, roles))
            if length == 0:
                return ()
            names = []
            for name in c.fetchall():
                names.append(name[0])
            return tuple(names)
        else:
            raise MythError('invalid input format')

    def getStorageGroup(self, groupname=None, hostname=None):
        """
        Returns tuple of dictionaries containing storage group directories
            with the fields 'id', 'group', 'host', and 'dirname'
        Takes an optional group and host for filtering
        """
        c = self.db.cursor()
        where = []
        wheredat = []
        if group:
            where.append("groupname=%s")
            wheredat.append(groupname)
        if host:
            where.append("hostname like %s")
            wheredat.append('%'+hostname)
        if len(where):
            where = 'WHERE '+'AND'.join(where)
            c.execute("""SELECT * FROM storagegroup %s ORDER BY id"""\
                        % where, wheredat)
        else:
            c.execute("""SELECT * FROM storagegroup ORDER BY id""")

        ret = []
        for row in c.fetchall():
            ret.append(StorageGroup(row,self))
        return tuple(ret)

    def getChannels(self):
        """
        Returns a tuple of channel object defined in the database
        """
        channels = []
        c = self.db.cursor()
        c.execute("""SELECT * FROM channel""")
        for row in c.fetchall():
            channels.append(Channel(row,self))
        c.close()
        return tuple(channels)

    def getChannel(self,chanid):
        """
        Returns a single channel object for the given chanid
        """
        c = self.db.cursor()
        if c.execute("""SELECT * FROM channel
                WHERE chanid=%s""", (chanid,)):
            return Channel(c.fetchone(),self)
        else:
            return None

    def getGuideData(self, chanid, date):
        """
        Returns tuple of guide data for one channel on one date.
        Date is in the format YYYYMMDD.
        """
        guide = []
        c = self.db.cursor()
        c.execute("""SELECT * FROM program
                WHERE chanid=%s AND DATE(starttime)=%s""",
                (chanid,date))
        for show in c.fetchall():
            guide.append(Guide(show,self))
        c.close()
        return tuple(guide)

    def cursor(self):
        return self.db.cursor()

class Job(DBDataWrite):
    """
    Class for managing tasks within mythtv's jobqueue
    """
    # types
    NONE         = 0x0000
    SYSTEMJOB    = 0x00ff
    TRANSCODE    = 0x0001
    COMMFLAG     = 0x0002
    USERJOB      = 0xff00
    USERJOB1     = 0x0100
    USERJOB2     = 0x0200
    USERJOB3     = 0x0300
    USERJOB4     = 0x0400
    # cmds
    RUN          = 0x0000
    PAUSE        = 0x0001
    RESUME       = 0x0002
    STOP         = 0x0004
    RESTART      = 0x0008
    # flags
    NO_FLAGS     = 0x0000
    USE_CUTLIST  = 0x0001
    LIVE_REC     = 0x0002
    EXTERNAL     = 0x0004
    # statuses
    UNKNOWN      = 0x0000
    QUEUED       = 0x0001
    PENDING      = 0x0002
    STARTING     = 0x0003
    RUNNING      = 0x0004
    STOPPING     = 0x0005
    PAUSED       = 0x0006
    RETRY        = 0x0007
    ERRORING     = 0x0008
    ABORTING     = 0x0008
    DONE         = 0x0100
    FINISHED     = 0x0110
    ABORTED      = 0x0120
    ERRORED      = 0x0130
    CANCELLED    = 0x0140

    table = 'jobqueue'
    def __init__(self, data=None, db=None):
        """
        Creates a new job object.
        Should be supplied with database if possible.
        Can be initialized in three manners, or left uninitialized:
            data = int(jobid) ---or--- data = (int(chanid),int(starttime)):
                    initialize object to existing entry in jobqueue
            data = dictionary:
                    initialize object to new job, using data from dictionary
        """
        self.db = db
        if db is None:
            self.db = MythDB()
        self.field_order = self.db.getTableFieldNames('jobqueue')

        if isinstance(data,int):
            self.where = 'id=%s'
            self.wheredat = (data,)
        elif isinstance(data,tuple) or isinstance(data,list):
            if len(data) != 2:
                raise MythError('Incorrect list input length to Job()')
            self.where = 'chanid=%s AND starttime=%s'
            self.wheredat = tuple(data)
        elif isinstance(data,dict):
            self.where = 'id=%s'
            self.wheredat = (self.create(data),)
        else:
            raise MythError('Invalid input type to Job(): '+str(type(data)))
        self._getData()
        if self.data is None:
            raise MythError('Failed to read Job() data - jobid: '\
                         % self.wheredat)

    def setComment(self,comment):
        self.comment = comment
        self.update()

    def setStatus(self,status):
        self.status = status
        self.update()

class StorageGroup(DBData):
    """
    Represents a single storage group entry
    """
    def __str__(self):
        return u"%s@%s%s" % (self.groupname,self.hostname,self.dirname)

    def __repr__(self): return self.__str__().encode('utf-8')

    def __init__(self,data,db=None):
        DBData.__init__(self,'storagegroup',data,db)
        self.where = 'id=%s'
        self.wheredat = (self.id)


class Channel(DBData):
    """
    Represents a single channel from the channel table
    """
    def __str__(self):
        return u"%s (%s)" % (self.chanid, self.name)

    def __repr__(self):
        return u"%s (%s)".encode('utf-8') % (self.chanid, self.name)

    def __init__(self,data,db=None):
        DBData.__init__(self,'channel',data,db)
        self.where = 'chanid=%s'
        self.wheredat = (self.chanid,)

class Guide(DBData):
    """
    Represents a single program from the program guide
    """
    def __str__(self):
        return u"%s (%s)" % (self.title,
                self.starttime.strftime('%Y-%m-%d %H:%M:%S'))

    def __repr__(self):
        return u"%s (%s)".encode('utf-8') % (self.title,
                self.starttime.strftime('%Y-%m-%d %H:%M:%S'))

    def __init__(self,data,db=None):
        DBData.__init__(self,'program',data,db)
        self.where = 'chanid=%s AND starttime=%s'
        self.wheredat = (self.chanid,self.starttime)

class Recording(DBDataWrite):
    """
    Class for managing and creating metadata for a single recording.
    """

    #needs function to add seekdata rebuild job
    #needs subclasses for managing recordedcredits, recordedfile,
    #           recordedmarkup, recordedprogram, recordedrating,
    #           recordedseek tables

if __name__ == '__main__':
    banner = "'mdb' is a MythDB instance."
    try:
        import readline, rlcompleter
    except:
        pass
    else:
        readline.parse_and_bind("tab: complete")
        banner = banner + " TAB completion is available."
    mdb = MythDB(sys.argv[1:])
    namespace = globals().copy()
    namespace.update(locals())
    code.InteractiveConsole(namespace).interact(banner)

# vim: tabstop=4 shiftwidth=4 expandtab:
