#
# archiver.py
#

import os, sys, socket, re, time, smtplib, calendar, StringIO, logging, logging.handlers, locale
from datetime import datetime, timedelta
import email
from email.Utils import parseaddr, formataddr, formatdate
from email.MIMEMultipart import MIMEMultipart
from email.MIMEText import MIMEText
from email.MIMEBase import MIMEBase


import cron

atom=r"[a-zA-Z0-9_#\$&'*+/=?\^`{}~|\-]+"
dot_atom=atom  +  r"(?:\."  +  atom  +  ")*"
quoted=r'"(?:\\[^\r\n]|[^\\"])*"'
local="(?:"  +  dot_atom  +  "|"  +  quoted  +  ")"
domain_lit=r"\[(?:\\\S|[\x21-\x5a\x5e-\x7e])*\]"
domain="(?:"  +  dot_atom  +  "|"  +  domain_lit  +  ")"
addr_spec=local  +  "\@"  +  domain
postfix_restricted_rfc2822_address_name=local
postfix_restricted_rfc2822_email_address=addr_spec
cyrus_mailbox_name=r"[a-zA-Z0-9_#$'=`{}~|-]+(?:\.[a-zA-Z0-9_#$'=`{}~|-]+)*"

domain_nameRE=re.compile('^'+dot_atom+'$')
email_addressRE=re.compile('^'+postfix_restricted_rfc2822_email_address+'$')
valid_ipRE=re.compile('^([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$')
valid_hostnameRE=re.compile('^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\\-]*[a-zA-Z0-9])\\.)*([A-Za-z]|[A-Za-z][A-Za-z0-9\\-]*[A-Za-z0-9])$')


boolean={ 'on':True, 'yes':True, 'true':True, '1':True, 1:True, 'off':False, 'no':False, 'false':False, '0':False, 0:False}

# ---------------------------------------------------------------------
def check_boolean(job, errors, options):
    for option, default in options:
        value=boolean.get(job.get(option, default).lower(), None)
        if value==None:
            errors[option]='boolean must have value in (on, yes, true, 1, off, no, false and 0)'
            value=default
        job[option]=value


# ---------------------------------------------------------------------------
def check_mail_config(job):
    
    errors={}

    mail=boolean.get(job.get('mail', 'yes'), None)
    if mail==None:
        errors['mail']='boolean must have value in (on, yes, true, 1, off, no, false and 0)'
    else:
        job['mail']=mail
    
    if mail:
        try:
            job['smtp_host']=smtp_host=job['smtp_host'].encode('ascii')
        except UnicodeEncodeError:
            errors['smtp_host']='invalid hostname or ip address'
        else:
            if not valid_ipRE.match(smtp_host):
                if not valid_hostnameRE.match(smtp_host):
                    errors['smtp_host']='invalid hostname or ip address'
                else:
                    try:
                        ip=socket.gethostbyname(smtp_host)
                    except socket.gaierror:
                        errors['smtp_host']='cannot resolve address'
    
        smtp_port=job['smtp_port']
        try:
            port=int(smtp_port)
        except Exception:
            errors['smtp_port']='must be an integer'
        else:
            if not (0<port and port<65535):
                errors['smtp_port']='must be an integer between 1 and 65535'
            else:
                job['smtp_port']=port
            
        sender=job.get('sender', None)
        if not sender:
            errors['sender']='option mandatory'
        elif not email_addressRE.match(sender):
            errors['sender']='invalid email address'
    
        recipients=job.get('recipients')
        if not recipients:
            errors['recipients']='option mandatory'
        else:
            recipients=recipients.split()
            bad=[]
            for recipient in recipients:
                if not email_addressRE.match(recipient):
                    bad.append(recipient)
            if bad:
                errors['recipients']='invalid email addresses: %s' % ' '.join(bad)
            else:
                job['recipients']=recipients

        attachment_size=job['attachment_size']
        try:
            size=int(attachment_size)
        except Exception:
            errors['attachment_size']='must be an integer'
        else:
            if not (0<size and size<100000):
                errors['smtp_port']='must be an integer between 1 and 100000'
            else:
                job['attachment_size']=size

    return errors


# ---------------------------------------------------------------------------
def list_dir(target_dir, log):
    total_size=0
    try: 
        dir_out=u'== directory %s\r\n  epoch   |          time          |      size     |    filename\r\n' % target_dir
        for filename in os.listdir(target_dir): # if target_dir is unicode, return os.listdir return unicode
            full_filename=os.path.join(target_dir, filename)
            stat=os.stat(full_filename)
            if os.path.isdir(full_filename):
                size='<DIR>'
            else:
                size='%15d' % stat.st_size
                total_size+=stat.st_size
            dir_out+='%d %s %15s %s\r\n' % (stat.st_mtime, time.ctime(stat.st_mtime), size, filename)
    except Exception, e:
        log.error('listing target directory: %s', e)
        dir_out='error listing directory "%s": %s\r\n' % (target_dir, e)
        
    return dir_out, total_size

# ---------------------------------------------------------------------------
def free_space(target_dir, log):
    
    try:
        if sys.platform=='win32':
            import win32file
            free_bytes, total_bytes, total_free_bytes=win32file.GetDiskFreeSpaceEx(target_dir)
        else:
            import os, statvfs
            s=os.statvfs(target_dir)
            total_free_bytes=s[statvfs.F_BSIZE]*s[statvfs.F_BAVAIL] 

    except Exception, e:
        if log:
            log.error('checking directory "%s": %s', target_dir, e)
        raise e

    return total_free_bytes

# ---------------------------------------------------------------------------
def quoted_string_list(st):
    """
    st  is a list of string separated by spaces like in shell script
    String containing space can be quoted
    Quote can be escaped with \
    r'1 two "t h r e e" "with a \" inside"' -->
                        ['1', 'two', 't h r e e', 'with a \\" inside']
    '" dont remove this line my editor string highlighting need it
    """                        
    lst=[]
    s=st.strip()
    while s:
        match=re.match(r'("(?:\\"|[^"])*"|[^ "]*)(.*)',  s)
        if match:
            item=match.groups()[0]
            if not item:
                raise ValueError, ("not a quoted string list '%s'" % (st,))
            elif item[0]=='"':
                lst.append(item[1:-1])
            else:
                lst.append(item)
            s=match.groups()[1].lstrip()
        else:
            raise ValueError, ("not a quoted string list '%s'" % (st,))
    return lst

# ---------------------------------------------------------------------------
def sendmail(sender, recipients, subject, text, attachements, smtp_host, smtp_port, attachment_size):

    attachment_size*=1024 # in K bytes
    msg=MIMEMultipart()
    msg.preamble='' # This line is not visible on mime enable MUA
    msg.epilogue=''
    
    msg['From'] = formataddr((sender, sender)) # Display name, email address
    msg['To'] =  ', '.join([ formataddr((recipient, recipient)) for recipient in recipients ])
    msg['Date'] = formatdate(localtime=True)
    msg['Subject'] = subject

    core=MIMEText(text.encode('utf-8'), 'plain', 'utf-8')
    msg.attach(core)
    
    for filename, target, data, maintype, subtype, charset in attachements:
        
        if not data and target and os.path.exists(target):
            data=open(target, 'rb').read(attachment_size)

        if data:
            data=data[:attachment_size]
            #maintype, subtype = 'application', 'octet-stream'
            if maintype=='text':
                attachement=MIMEText(data, subtype, charset)
            else:
                attachement=MIMEBase(maintype, subtype)
                attachement.set_payload(data) #, charset) #dont use the charset, here !
                email.Encoders.encode_base64(attachement)
    
        attachement.add_header('Content-Disposition', 'attachment', filename=filename)
        msg.attach(attachement)
    
    body=msg.as_string()
    
    smtp=smtplib.SMTP(smtp_host, smtp_port)
    ret=smtp.sendmail(sender, recipients, body)


# ---------------------------------------------------------------------------
def send_mail_report(job, result, manager):
    backup_status, status, attachements=result

    msg_body=job['msg_header']+'\n'+status
    subject='MKBACKUP BACKUP %s %s' % (backup_status, job['name'])
    
    attachements.extend(manager.attachements)
    attachements.append(('status.txt', None, status.encode('utf-8'), 'text', 'plain', 'utf-8'))
    log_output=manager.stop_logging()
    attachements.append(('logging.txt', None, log_output.encode('utf-8'), 'text', 'plain', 'utf-8'))                       
    
    for line in status.split('\r\n'):
        if line:
           manager.log.info('    %s', line)
    
    if job['mail']:
        sendmail(job['sender'], job['recipients'], subject, msg_body, attachements, job['smtp_host'], job['smtp_port'], job['attachment_size'])

    if backup_status=='OK':
        return 0
    else:
        return 1


# ---------------------------------------------------------------------------
def param_encode(lst, encoding):
    """return a new encoded list of string"""
    if isinstance(lst, list):
        newlst=lst[:]
        for i, v in enumerate(lst):
            newlst[i]=v.encode(encoding)
        return newlst
    elif isinstance(lst, unicode):
        return lst.encode(encoding)
    else:
        return lst

#======================================================================
#
# Destinations
#
#======================================================================
class DestinationSyntaxError(Exception):
    pass

class Destinations:
    
    variables={} # default value for exra variable in Destination 
    types={}    # allowed types in Destination

    def __init__(self, raw, archiver):
        
        self.destinations=[]
        st=raw
        self.max_weekdivisor=1
        self.archiver=archiver
        
        while st:
            if st[0]!='<':
                raise DestinationSyntaxError, 'starting "<" missing'
            
            try:
                pos=st.index('>')
            except ValueError:
                raise DestinationSyntaxError, 'a ">" is missing'
            
            selector=st[1:pos]
            st=st[pos+1:]
        
            pos=st.find('<')
            if pos>=0:
                target=st[:pos]
                st=st[pos:]
            else:
                target=st
                st=''
            
            typ, period=selector.split('=', 1)
            
            try:
                if typ.lower()=='none':
                    typ='none'
                else:
                    typ=self.archiver.types[typ.lower()]
            except KeyError:
                raise DestinationSyntaxError, 'type "%s" unknow' % (typ, )

            #print "SELECTOR<%s=%s>%s" % (typ,period,target )
            #print "CARRY=%s" % st
            period=cron.Cron(period)
            if period.weekdivisor!=None:
                self.max_weekdivisor=max(self.max_weekdivisor, period.weekdivisor)
                
            self.destinations.append((typ, period, target))


    def match(self, day, night_shift, variables={}):
        #print '---------------------------------'
        night_day=day
        if day.hour<12:
            night_day=night_day-timedelta(days=1)
        if night_shift:
            backup_day=night_day
        else:
            backup_day=day
        globals=dict(nday='%02d'%(night_day.day,), nmonth='%02d'%(night_day.month,), nyear=str(night_day.year,), nweekday=night_day.weekday(), nyearday='%03d'%(night_day.timetuple().tm_yday,),)
        globals['nweekdayname']=night_day.strftime('%a')
        globals['nmonthname']=night_day.strftime('%b')
        
        epoch=calendar.timegm(backup_day.timetuple())
        globals['epoch']=str(int(epoch))
        globals['week']='0'
        globals['nweek']='0'
        
        globals.update(self.archiver.variables)
        globals.update(variables)
            
        for typ, period, target in self.destinations:
            
            #print 'ASX typ, per, tgt', typ, period, target
            if period:
                #print 'Month', backup_day.month-1, period.months
                if period.months and not backup_day.month-1 in period.months:
                    continue
                #print 'Day of week', backup_day.isoweekday()-1, period.daysofweek
                if period.daysofweek and not backup_day.isoweekday()-1 in period.daysofweek:
                    continue
                #print 'Day of month', backup_day.day-1, period.daysofmonth
                if period.daysofmonth and not backup_day.day-1 in period.daysofmonth:
                    continue
                if period.firstdayofweek!=None:
                    nday=int(epoch/86400)-period.firstdayofweek+3
                    week=int(nday/7)%period.weekdivisor
                    if period.weekselector!=None and week!=period.weekselector:
                        continue
                    globals['week']=week
                    # The night day
                    nday=int(calendar.timegm(night_day.timetuple())/86400)-period.firstdayofweek+3
                    week=int(nday/7)%period.weekdivisor
                    if period.weekselector!=None and week!=period.weekselector:
                        continue
                    globals['nweek']=week

            #print 'MATCH', typ, target
            
            for exp in re.findall('\{([^}]*)\}', target):
                try:
                    target=target.replace('${%s}' % (exp,), str(eval(exp, globals, {})))
                except Exception, e:
                    raise DestinationSyntaxError, 'Invalid expression: "%s"' % (exp, )
            target=target.encode('utf-8')
            try:
                target=backup_day.strftime(target)
            except:
                raise DestinationSyntaxError, 'Invalid expression: "%s"' % (target, )
            target=target.decode('utf-8')

            return typ, target
            
        return None, None

# ---------------------------------------------------------------------------

class MyMemoryHandler(logging.handlers.MemoryHandler):

     def shouldFlush(self, record):
        """
        Check for buffer full and drop oldest records.
        """
        if len(self.buffer) >= self.capacity:
            self.buffer=self.buffer[-self.capacity:]
            
        return False
   

# ---------------------------------------------------------------------------
class Manager:
    
    memory_log_capacity=2000

    def __init__(self, hostname, log):
        self.hostname=hostname
        self.log=log
        self.default_encoding=locale.getdefaultlocale()[1]
        self.console_encoding=sys.stdout.encoding
        
    def start_logging(self):
        self.string_log=StringIO.StringIO()
        self.stream_log=logging.StreamHandler(self.string_log)
        self.stream_log.setFormatter(logging.Formatter('%(asctime)s %(levelname)-3.3s %(message)s', '%Y-%m-%d %H:%M:%S'))
        self.memory_log=MyMemoryHandler(self.memory_log_capacity, logging.INFO, self.stream_log)
        self.log.addHandler(self.memory_log)

    def stop_logging(self):
        self.memory_log.flush()
        self.stream_log.flush()

        self.log.removeHandler(self.memory_log)
        
        self.memory_log.close()
        self.stream_log.close()
        log_output=self.string_log.getvalue()
        self.string_log.close()
        return log_output

# ---------------------------------------------------------------------------
class Archiver:
    
    name=None
    exe=None

    types=dict()
    variables=dict()
 
    def __init__(self):
        self.night_shift=True
        self._hidden_password=[]
        
        
    def hide_password(self, st):
        """replace password by fake text""" 
        for password in self._hidden_password:
            st=st.replace(password, '********')
        return st
            
    def register_password(self, password):
        """register a new password"""
        if password:
            self._hidden_password.append(password)
    
    # -----------------------------------------------------------------------
