#!/usr/bin/env python

from optparse import OptionParser
from datetime import datetime
from collections import deque
import sqlite3
import pipes
import logging
import ConfigParser
import os
import sys
import time

ab_root      = os.path.join(os.environ['HOME'], '.autoburn/')
ab_conf_file = os.path.join(ab_root, 'conf')
ab_db_file   = os.path.join(ab_root, 'dvd_db.sqlite')
config       = None

# This is just a shortcut
def interactive():
    return config.has_option('main', 'interactive')
    
def walkdsize(dir):
    tsum = 0
    for root, dirs, files in os.walk(dir):
        for name in files:
            try:
                tsum += os.path.getsize(os.path.join(root, name))
            except OSError:
                logging.warning("Can't access %s, skipping" \
                                % os.path.join(root, name))
    return tsum

class ConfigError(Exception):
    pass

class DvdEntry():
    def __init__(self, path):
        self.path = path
        self.ext = os.path.splitext(path)[1]
        if os.path.isdir(self.path):
            self.isdir = True
            self.size  = walkdsize(self.path)
        else:
            self.isdir = False
            self.size  = os.path.getsize(self.path)
        # Check if we're good, that is, check if we are a 'to burn' entry or not
        if self.size > Dvd.max_size():
            self.good = False
        elif self.size < config.getint('limits', 'min_fsize'):
            self.good = False
        elif self.ext == '.burned':
            self.good = False
        else:
            self.good = True
            
    def __cmp__(self, other):
        return cmp(self.size, other.size)
   
    def __str__(self):
        return "DvdEntry: {0} size: {1}".format(self.path, self.size)

class Dvd():
    def __init__(self):
        self.entries = deque()
        self.size = 0
        self.date = datetime.now().strftime("%d-%m-%y %H:%M")

    def __str__(self):
        s = '-->DVD {0} {1} ({2}%)<--\n'.format(self.date, self.size, \
                                                  self.ratio())
        for e in self.entries:
            s += os.path.basename(e.path) + '\n'
            
        s += 80 * '-'
        return s

    def __cmp__(self, other):
        return cmp(self.size, other.size)
    
    def __iter__(self):
        return iter(self.entries)
    
    def put(self, e):
        if (self.size + e.size) > self.max_size():
            return False
        
        self.entries.append(e)
        self.size += e.size
        return True

    def ratio(self):
        return int((self.size * 100) / \
            config.getfloat('limits', 'dvd_size'))

    def moveburned(self):
        for e in self.entries:
            oldname = pipes.quote(e.path)
            newname = pipes.quote(e.path + '.burned')
            cmdstr = 'mv -f {0} {1}'.format(oldname, newname)
            logging.debug('moveburned string: ' + cmdstr)
            os.system(cmdstr)

    def makeiso(self):
        if not config.has_option('main', 'isoname'):
            raise ConfigError("Missing isoname opttion")
        
        isopath = config.get('main', 'isoname')
        paths = ''
        for e in self.entries:
            paths += pipes.quote(e.path) + ' '
        app_id = pipes.quote(self.date)
        volume_id = pipes.quote(self.date)
        cmdstr = 'mkisofs -o {0} -R -J -r -graft-points -A {1} -V {2} {3}'. \
            format(isopath, app_id, volume_id, paths)
    
        logging.debug('mkisofs string: ' + cmdstr)

        if os.system(cmdstr) != 0:
            raise RuntimeError("mkisofs returned error")
        
    def burn(self):
        paths = ''
        for e in self.entries:
            paths += pipes.quote(e.path) + ' '
            
        app_id = pipes.quote(self.date)
        volume_id = pipes.quote(self.date)
        cmdstr = 'growisofs -dvd-compat -Z {0} -V {1} -A {2} -R -J -r -graft-points {3}'. \
            format(config.get('main', 'device'), app_id, volume_id, paths)
        logging.debug('burnstring = ' + cmdstr)
        
        if os.system(cmdstr) != 0:
            raise RuntimeError("growisofs returned error")

    def dbsave(self):
            conn = sqlite3.connect(ab_db_file)
            c = conn.cursor()
            c.execute(
                'insert into dvds (title, creation_date, size)' \
                    ' values (?, ?, ?)',
                (self.date, self.date, self.size))
            c.execute('select last_insert_rowid() as id_last_dvd from dvds')
            last_id = c.next()[0]
            if not last_id:
                raise RuntimeError('select statement for dvd failed.')
            for e in self.entries:
                if e.isdir:
                    df = 'D'
                else:
                    df = 'F'
                c.execute(
                    'insert into dvd_contents (id_dvd, type, full_path, size)' \
                        'values (?, ?, ?, ?)',
                    (last_id, df, e.path, e.size))
            conn.commit()
            c.close()
            conn.close()
            
    @staticmethod
    def max_size():
        return int(config.getfloat('limits', 'max_dvd_ratio') \
                       * config.getint('limits', 'dvd_size'))

    
def query_yes_no(question, default="yes"):
    valid = {"yes":"yes",   "y":"yes",  "ye":"yes",
             "no":"no",     "n":"no"}
    if default == None:
        prompt = " [y/n] "
    elif default == "yes":
        prompt = " [Y/n] "
    elif default == "no":
        prompt = " [y/N] "
    else:
        raise ValueError("invalid default answer: '%s'" % default)

    while 1:
       sys.stdout.write(question + prompt)
       choice = raw_input().lower()
       if default is not None and choice == '':
           return default
       elif choice in valid.keys():
           return valid[choice]
       else:
           sys.stdout.write("Please respond with 'yes' or 'no' "\
                                "(or 'y' or 'n').\n")

def checkbin(biname):
    if filter(lambda x: os.path.exists(os.path.join(x, biname)),
              os.environ['PATH'].split(':')):
        return True
    return False
           
def doconf():
    global config
   
    if not os.path.exists(ab_conf_file):
        raise ConfigError("No config file at %s" % ab_conf_file)

    config = ConfigParser.ConfigParser()
    config.read(ab_conf_file)
   
    for y in ['mails']:
        l = config.get('main', y).split(',')
        l = map(lambda x: x.strip(), l)
        config.set('main', y, l)
    parser = OptionParser()
    parser.add_option('-B', '--dont-burn',
                      dest    = 'dontburn',
                      action  = 'store_true',
                      default = False,
                      help    = 'Don\'t burn a media')
    parser.add_option('-d', '--device',
                      dest    = 'device',
                      action  = 'store',
                      default = config.get('main', 'device'),
                      help    = 'Device to be used (/dev/sdX)')
    parser.add_option('-i', '--interactive',
                      dest    = 'interactive',
                      action  = 'store_true',
                      default = False,
                      help    = 'Interactive mode')
    parser.add_option('-o', '--iso-image',
                      dest    = 'isoname',
                      action  = 'store',
                      default = None,
                      help    = 'Output an iso image with given name')
    parser.add_option('-m', '--mail',
                      dest = 'mails',
                      action = 'append',
                      default = [],
                      help = 'Mail address to be notified')
    parser.add_option('-M', '--dont-move',
                      dest    = 'dontmove',
                      action  = 'store_true',
                      default = False,
                      help    = 'Don\'t move entries to .burned')
    parser.add_option('-S', '--dont-save',
                      dest    = 'dontsave',
                      action  = 'store_true',
                      default = False,
                      help    = 'Don\'t save dvd in database')
    parser.add_option('-v', '--verbose',
                      dest    = 'verbose',
                      action  = 'count',
                      default = 1,
                      help    = 'Verbosity level, can be used multiple times')

    (options, args) = parser.parse_args()
   
    if not args:
        args = [os.getcwd()]
   
    config.set('main', 'sources', args)
    # Overwrite values
    if options.mails:
        config.set('main', 'mails', options.mails)
    if options.isoname:
        config.set('main', 'isoname', options.isoname)
    if options.dontburn:
        config.set('main', 'dontburn', 'true')
    if options.dontmove:
        config.set('main', 'dontmove', 'true')
    if options.dontsave:
        config.set('main', 'dontsave', 'true')
    if options.interactive:
        config.set('main', 'interactive', 'true')
    
    # Sanity check on arguments
    for d in config.get('main', 'sources'):
        if not os.path.isdir(d):
            sys.stderr.write('{0} is not a directory\n'.format(d))
    
    # Main sanity check
    missing = [x for x in ['device', 'mails', 'db'] \
                   if not config.get('main', x)]
    if missing:
        raise ConfigError("Missing config options in main section, %s" \
                              % str(missing))
      
    # Limits sanity check
    missing = [x for x in ['min_dvd_ratio', 'max_dvd_ratio', \
                               'min_fsize', 'dvd_size'] \
                   if not config.get('limits', x)]
    if missing:
        raise ConfigError("Missing config options in limits section, %s" \
                             % str(missing))
    FORMAT="%(levelname)s:%(message)s"
    # Verbosity
    if options.verbose == 1:
        logging.basicConfig(level = logging.WARNING, format = FORMAT)
    elif options.verbose == 2:
        logging.basicConfig(level = logging.INFO, format = FORMAT)
    else:
        logging.basicConfig(level = logging.DEBUG, format = FORMAT)
    
def main():
   if not checkbin('growisofs'):
       raise 'I can\'t find growisofs, check if you have it in PATH'
   if not checkbin('mkisofs'):
       raise 'I can\'t find mkisofs, check if you have it in PATH'
   
   check_conf()
   doconf()
   
   good    = []
   bad     = []
   dvdlist = []
   
   logging.debug('Considering sources {0}'. \
                     format(config.get('main', 'sources')))
   for src in config.get('main', 'sources'):
       for e in os.listdir(src):
           d = DvdEntry(os.path.join(src, e))
           if d.good:
               good.append(d)
           else:
               bad.append(d)
               
   logging.info("== Filtered out entries (bad) == ")
   for d in bad:
       logging.debug(d)
       
   logging.debug("== In entries (good) == ")
   for d in good:
       logging.debug(d)
   dvd = Dvd()

   while good:
       e = good.pop()
       if dvd.put(e):
           continue
       # Won't fit anymore, get a new dvd
       dvdlist.append(dvd)
       if not good:
           break
       dvd = Dvd()
   dvdlist.sort()
   dvdlist.reverse()
   
   total_size = 0
   total_files = 0
   for d in dvdlist:
       total_size = total_size + dvd.size
       total_files = total_files + len(d.entries)
       logging.info(d)

   if not dvdlist:
       print 'Could not assemble a single DVD'
       sys.exit(1)

   d = dvdlist.pop(0)
   print 'Searched {0} bytes in {1} files/dirs '. \
       format(total_size, total_files)
   print 'The following is the fullest DVD I can make:'
   print d
   if config.has_option('main', 'isoname'):
       if not interactive() or \
               query_yes_no('Proceed making ISO ?') == 'yes':
           logging.debug('Will make ISO')
           d.makeiso()
       else:
           logging.debug('Will not make ISO')

   if config.has_option('main', 'dontburn') or \
           (interactive() and query_yes_no('Proceed with burning ?') == 'no'):
       logging.debug('Will not burn dvd')
   else:
       logging.debug('Will burn dvd')
       d.burn()
           
   # Move files to .burned
   if config.has_option('main', 'dontmove') or \
           (interactive() and \
                query_yes_no('Move burned files to .burned ?') == 'no'):
       logging.debug('Will not move files to .burned')
   else:
       logging.debug('Will move files to .burned')
       d.moveburned()
       
   # Save to database
   if config.has_option('main', 'dontsave') or \
           (interactive() and \
                query_yes_no('Save recorded dvd in database ?') == 'no'):
       logging.debug('Will not save dvd in database')
   else:
       logging.debug('Will save dvd in database')
       d.dbsave()

   sys.exit(0)

def check_conf():
    if os.path.exists(ab_conf_file):
        return
    
    print 'I can see this is the first time you are running me, I will create \
the config file in {0} and the database in {1}.'.format(ab_conf_file, ab_db_file)
    if query_yes_no('Is that ok ?') == 'no':
        sys.exit(0)

    if not os.path.exists(ab_root):
        os.mkdir(ab_root)

    cf = ConfigParser.ConfigParser()
    cf.add_section('main')
    cf.add_section('limits')
    cf.set('main', 'device', '/dev/dvdrw')
    cf.set('main', 'mails', 'foo@bar.com')
    cf.set('main', 'db', ab_db_file)
    cf.set('limits', 'min_dvd_ratio', 0.90)
    cf.set('limits', 'max_dvd_ratio', 0.985)
    cf.set('limits', 'min_fsize', 10000000)
    cf.set('limits', 'dvd_size', 4700000000)
    f = open(ab_conf_file, 'w')
    cf.write(f)
    f.close()
    
    # Init database
    if not os.path.exists(ab_db_file):
        db_create()
        
    print 'Setup done, tune the config file to your needs'
    sys.exit(0)

def db_create():
    conn = sqlite3.connect(ab_db_file)
    c = conn.cursor()
    c.executescript("""
-- DVDs Table
   create table dvds (
       id_dvd integer primary key asc autoincrement,
       title text not null,
       creation_date varchar(16) not null,   -- Timestamp: YYYYMMDDHHMMSS
       size integer not null                 -- Size in KB
   );


-- DVD Contents Table
   create table dvd_contents (
       id_dvd_content integer primary key asc autoincrement,
       id_dvd integer,
       type varchar(1) not null,      -- 'D' for Directory or 'F' for File
       full_path text not null,
       size integer not null,         -- Size in KB
       foreign key (id_dvd) references id_dvd (dvds)
   );
""")
    conn.commit()
    c.close()
    conn.close()
    
if __name__ == "__main__":
    main()
