#!/usr/bin/python3
import sys
import os
import pwd
import re
import string
from functools import reduce
import hashlib

envvar_re = re.compile(r'^([A-Za-z_0-9]+)\s*=\s*(.*)$')

MINUTES_SET = list(range(0, 60))
HOURS_SET = list(range(0, 24))
DAYS_SET = list(range(1, 32))
DOWS_SET = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
MONTHS_SET = list(range(1, 13))
TIME_UNITS_SET = ['daily', 'weekly', 'monthly', 'quarterly', 'semi-annually', 'yearly']

KSH_SHELLS = ['/bin/sh', '/bin/dash', '/bin/ksh', '/bin/bash', '/usr/bin/zsh']
REBOOT_FILE = '/run/crond.reboot'
RANDOMIZED_DELAY = @randomized_delay@

SELF = os.path.basename(sys.argv[0])

for pgm in ('/usr/sbin/sendmail', '/usr/lib/sendmail'):
    if os.path.exists(pgm):
        HAS_SENDMAIL = True
        break
else:
    HAS_SENDMAIL = False

class Persistent(object):
    yes, no, auto = range(3)

    @classmethod
    def parse(cls, value):
        value = value.strip().lower()
        if value in ['yes', 'true', '1']:
            return cls.yes
        elif value in ['auto', '']:
            return cls.auto
        else:
            return cls.no


def files(dirname):
    try:
        return list(filter(os.path.isfile, [os.path.join(dirname, f) for f in os.listdir(dirname)]))
    except OSError:
        return []

def expand_home_path(path, user):
    try:
        home = pwd.getpwnam(user).pw_dir
    except KeyError:
        return path

    parts = path.split(':')
    for i, part in enumerate(parts):
        if part.startswith('~/'):
            parts[i] = home + part[1:]
    return ':'.join(parts)

def environment_string(env):
    line = []
    for k, v in env.items():
        if ' ' in v:
            line.append('"%s=%s"' % (k, v))
        else:
            line.append('%s=%s' % (k, v))
    return ' '.join(line)

def parse_crontab(filename, withuser=True, monotonic=False):
    basename = os.path.basename(filename)
    environment = { }
    random_delay = 1
    start_hours_range = 0
    boot_delay = 0
    persistent = Persistent.yes if monotonic else Persistent.auto
    batch = False
    with open(filename, 'r', encoding='utf8') as f:
        for line in f.readlines():
            line = line.strip()
            if not line or line.startswith('#'):
                continue

            envvar = envvar_re.match(line)
            if envvar:
                value = envvar.group(2)
                value = value.strip("'").strip('"')
                if envvar.group(1) == 'RANDOM_DELAY':
                     try:
                         random_delay = int(value)
                     except ValueError:
                         log(4, 'invalid RANDOM_DELAY in %s: %s' % (filename, line))
                elif envvar.group(1) == 'START_HOURS_RANGE':
                     try:
                         start_hours_range = int(value.split('-')[0])
                     except ValueError:
                         log(4, 'invalid START_HOURS_RANGE in %s: %s' % (filename, line))
                elif envvar.group(1) == 'DELAY':
                     try:
                         boot_delay = int(value)
                     except ValueError:
                         log(4, 'invalid DELAY in %s: %s' % (filename, line))
                elif envvar.group(1) == 'PERSISTENT':
                     persistent = Persistent.parse(value)
                elif not withuser and envvar.group(1) == 'PATH':
                     environment['PATH'] = expand_home_path(value, basename)
                elif envvar.group(1) == 'BATCH':
                     batch = (value.strip().lower() in ['yes','true','1'])
                elif envvar.group(1) == 'MAILTO':
                     environment[envvar.group(1)] = value
                     if value and not HAS_SENDMAIL:
                         log(4, 'a MTA is not installed, but MAILTO is set in %s' % filename)
                else:
                     environment[envvar.group(1)] = value
                continue

            parts = line.split()
            line = ' '.join(parts)

            if monotonic:
                if len(parts) < 4:
                    yield { 'l': line }
                    continue

                period, delay, jobid = parts[0:3]
                command = ' '.join(parts[3:])
                period = {
                        '1': 'daily',
                        '7': 'weekly',
                        '30': 'monthly',
                        '31': 'monthly',
                        '@biannually': 'semi-annually',
                        '@bi-annually': 'semi-annually',
                        '@semiannually': 'semi-annually',
                        '@anually': 'yearly',
                        '@annually': 'yearly',
                        }.get(period, None) or period.lstrip('@')
                try:
                    boot_delay = int(delay)
                except ValueError:
                    log(4, 'invalid DELAY in %s: %s' % (filename, line))
                    boot_delay = 0
                if boot_delay < 0: boot_delay = 0

                valid_chars = "-_%s%s" % (string.ascii_letters, string.digits)
                jobid = ''.join(c for c in jobid if c in valid_chars)

                yield {
                        'e': environment_string(environment),
                        's': environment.get('SHELL','/bin/sh'),
                        'a': random_delay,
                        'l': line,
                        'f': filename,
                        'p': period.lower(),
                        'b': boot_delay,
                        'h': start_hours_range,
                        'P': False if persistent == Persistent.no else True,
                        'j': jobid,
                        'u': 'root',
                        'c': command,
                        'Z': batch,
                        }

            else:
                if line.startswith('@'):
                    if len(parts) < 2 + int(withuser):
                        yield { 'l': line }
                        continue

                    period = parts[0]
                    period = {
                            '@biannually': 'semi-annually',
                            '@bi-annually': 'semi-annually',
                            '@semiannually': 'semi-annually',
                            '@anually': 'yearly',
                            '@annually': 'yearly',
                            }.get(period, None) or period.lstrip('@')

                    user, command = (parts[1], ' '.join(parts[2:])) if withuser else (basename, ' '.join(parts[1:]))

                    yield {
                            'e': environment_string(environment),
                            's': environment.get('SHELL','/bin/sh'),
                            'a': random_delay,
                            'l': line,
                            'f': filename,
                            'p': period.lower(),
                            'b': boot_delay,
                            'h': start_hours_range,
                            'P': False if persistent == Persistent.no else True,
                            'j': basename,
                            'u': user,
                            'c': command,
                            'Z': batch,
                            }
                else:
                    if len(parts) < 6 + int(withuser):
                        yield { 'l': line }
                        continue

                    minutes, hours, days = parts[0:3]
                    months, dows = parts[3:5]
                    user, command = (parts[5], ' '.join(parts[6:])) if withuser else (basename, ' '.join(parts[5:]))

                    yield {
                            'e': environment_string(environment),
                            's': environment.get('SHELL','/bin/sh'),
                            'a': random_delay,
                            'l': line,
                            'f': filename,
                            'b': boot_delay,
                            'm': parse_time_unit(filename, line, minutes, MINUTES_SET),
                            'h': parse_time_unit(filename, line, hours, HOURS_SET),
                            'd': parse_time_unit(filename, line, days, DAYS_SET),
                            'w': parse_time_unit(filename, line, dows, DOWS_SET, dow_map),
                            'M': parse_time_unit(filename, line, months, MONTHS_SET, month_map),
                            'P': True if persistent == Persistent.yes else False,
                            'j': basename,
                            'u': user,
                            'c': command,
                            'Z': batch,
                            }

def parse_time_unit(filename, line, value, values, mapping=int):
    if value == '*':
        return ['*']
    try:
        base = min(values)
        # day of weeks
        if isinstance(base, str):
            base = 0
        result = sorted(reduce(lambda a, i: a.union(set(i)), list(map(values.__getitem__,
        list(map(parse_period(mapping, base), value.split(','))))), set()))
    except ValueError:
        result = []
    if not len(result):
        log(3, 'garbled time in %s [%s]: %s' % (filename, line, value))
    return result

def month_map(month):
    try:
        return int(month)
    except ValueError:
        return ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'nov', 'dec'].index(month.lower()[0:3]) + 1

def dow_map(dow):
    try:
        return ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'].index(dow[0:3].lower())
    except ValueError:
        return int(dow) % 7

def parse_period(mapping=int, base=0):
    def parser(value):
        try:
            range, step = value.split('/')
        except ValueError:
            range = value
            step = 1

        if range == '*':
            return slice(None, None, int(step))

        try:
            start, end = range.split('-')
        except ValueError:
            start = end = range

        return slice(mapping(start) - 1 + int(not(bool(base))), mapping(end) + int(not(bool(base))), int(step))

    return parser

def generate_timer_unit(job, seq):
    persistent = job['P']
    command = job['c']
    parts = command.split()
    testremoved = None
    standardoutput = None
    delay = job['b']
    daemon_reload = os.path.isfile(REBOOT_FILE)

    try:
        home = pwd.getpwnam(job['u']).pw_dir
    except KeyError:
        home = None

    # perform smart substitutions for known shells
    if job['s'] in KSH_SHELLS:
        if home and command.startswith('~/'):
            command = home + command[1:]

        if (len(parts) >= 3 and
            parts[-2] == '>' and
            parts[-1] == '/dev/null'):
            command = ' '.join(parts[0:-2])
            parts = command.split()
            standardoutput='null';

        if (len(parts) >= 2 and
            parts[-1] == '>/dev/null'):
            command = ' '.join(parts[0:-1])
            parts = command.split()
            standardoutput='null';

        if (len(parts) == 6 and
            parts[0] == '[' and
            parts[1] in ['-x','-f','-e'] and
            parts[2] == parts[5] and
            parts[3] == ']' and
            parts[4] == '&&' ):
                testremoved = parts[2]
                command = ' '.join(parts[5:])
                parts = command.split()

        if (len(parts) == 5 and
            parts[0] == 'test' and
            parts[1] in ['-x','-f','-e'] and
            parts[2] == parts[4] and
            parts[3] == '&&' ):
                testremoved = parts[2]
                command = ' '.join(parts[4:])
                parts = command.split()

        if testremoved and not os.path.isfile(testremoved): return

        if (len(parts) == 6 and
            parts[0] == '[' and
            parts[1] in ['-d','-e'] and
            parts[2] == '/run/systemd/system' and
            parts[3] == ']' and
            parts[4] == '||'): return

        if (len(parts) == 5 and
            parts[0] == 'test' and
            parts[1] in ['-d','-e'] and
            parts[2] == '/run/systemd/system' and
            parts[3] == '||'): return

        # TODO: translate  'command%line1%line2%line3
        # in '/bin/echo -e line1\\nline2\\nline3 | command'
        # to be POSIX compliant

    if 'p' in job:
        hour = job['h']

        if job['p'] == 'reboot':
            if daemon_reload: return
            if delay == 0: delay = 1
            schedule = None
            persistent = False
        elif job['p'] == 'minutely':
            schedule = job['p']
            persistent = False
        elif job['p'] == 'hourly' and delay == 0:
            schedule = 'hourly'
        elif job['p'] == 'hourly':
            schedule = '*-*-* *:%s:0' % delay
            delay = 0
        elif job['p'] == 'midnight' and delay == 0:
            schedule = 'daily'
        elif job['p'] == 'midnight':
            schedule = '*-*-* 0:%s:0' % delay
        elif job['p'] in TIME_UNITS_SET and hour == 0 and delay == 0:
            schedule = job['p']
        elif job['p'] == 'daily':
            schedule = '*-*-* %s:%s:0' % (hour, delay)
        elif job['p'] == 'weekly':
            schedule = 'Mon *-*-* %s:%s:0' % (hour, delay)
        elif job['p'] == 'monthly':
            schedule = '*-*-1 %s:%s:0' % (hour, delay)
        elif job['p'] == 'quarterly':
            schedule = '*-1,4,7,10-1 %s:%s:0' % (hour, delay)
        elif job['p'] == 'semi-annually':
            schedule = '*-1,7-1 %s:%s:0' % (hour, delay)
        elif job['p'] == 'yearly':
            schedule = '*-1-1 %s:%s:0' % (hour, delay)
        else:
            try:
               if int(job['p']) > 31:
                    # workaround for anacrontab
                    schedule = '*-1/%s-1 %s:%s:0' % (int(round(job['p']/30)), hour, delay)
               else:
                    schedule = '*-*-1/%s %s:%s:0' % (int(job['p']), hour, delay)
            except ValueError:
                    log(3, 'unknown schedule in %s: %s' % (job['f'], job['l']))
                    schedule = job['p']

    else:
        if job['w'] == ['*']:
            dows=''
        else:
            dows_sorted = []
            for day in DOWS_SET:
                if day in job['w']:
                    dows_sorted.append(day)
            dows = ','.join(dows_sorted) + ' '

        if 0 in job['M']: job['M'].remove(0)
        if 0 in job['d']: job['d'].remove(0)
        if not len(job['M']) or not len(job['d']) or not len(job['h']) or not len(job['m']):
            return
        schedule = '%s*-%s-%s %s:%s:00' % (dows, ','.join(map(str, job['M'])),
                ','.join(map(str, job['d'])), ','.join(map(str, job['h'])), ','.join(map(str, job['m'])))

    if not persistent:
        unit_id = next(seq)
    else:
        unit_id = hashlib.md5()
        unit_id.update(bytes('\0'.join([schedule, command]), 'utf-8'))
        unit_id = unit_id.hexdigest()
    unit_name = "cron-%s-%s-%s" % (job['j'], job['u'], unit_id)

    if not (len(parts) == 1 and os.path.isfile(command)):
        with open('%s/%s.sh' % (TARGET_DIR, unit_name), 'w', encoding='utf8') as f:
            f.write(command)
        command=job['s'] + ' ' + TARGET_DIR + '/' + unit_name + '.sh'

    with open('%s/%s.timer' % (TARGET_DIR, unit_name), 'w' , encoding='utf8') as f:
        f.write('[Unit]\n')
        f.write('Description=[Timer] "%s"\n' % job['l'].replace('%', '%%'))
        f.write('Documentation=man:systemd-crontab-generator(8)\n')
        f.write('PartOf=cron.target\n')
        f.write('SourcePath=%s\n' % job['f'])
        if testremoved: f.write('ConditionFileIsExecutable=%s\n' % testremoved)

        f.write('\n[Timer]\n')
        f.write('Unit=%s.service\n' % unit_name)
        if schedule: f.write('OnCalendar=%s\n' % schedule)
        else:        f.write('OnBootSec=%sm\n' % delay)
        if job['a'] != 1:
            if RANDOMIZED_DELAY:
                f.write('RandomizedDelaySec=%sm\n' % job['a'])
            else:
                f.write('AccuracySec=%sm\n' % job['a'])
        if @persistent@ and persistent: f.write('Persistent=true\n')

    try:
        os.symlink('%s/%s.timer' % (TARGET_DIR, unit_name), '%s/%s.timer' % (TIMERS_DIR, unit_name))
    except OSError as e:
        if e.errno != os.errno.EEXIST:
            raise

    with open('%s/%s.service' % (TARGET_DIR, unit_name), 'w', encoding='utf8') as f:
        f.write('[Unit]\n')
        f.write('Description=[Cron] "%s"\n' % job['l'].replace('%', '%%'))
        f.write('Documentation=man:systemd-crontab-generator(8)\n')
        f.write('SourcePath=%s\n' % job['f'])
        if '"MAILTO="' in job['e']:
            pass # mails explicitely disabled
        elif not HAS_SENDMAIL and '"MAILTO=' not in job['e']:
            pass # mails automaticaly disabled
        else:
            f.write('OnFailure=cron-failure@%i.service\n')
        if job['u'] != 'root' or job['f'] == '@statedir@/root':
            f.write('Requires=systemd-user-sessions.service\n')
            if home:
                f.write('RequiresMountsFor=%s\n' % home)

        f.write('\n[Service]\n')
        f.write('Type=oneshot\n')
        f.write('IgnoreSIGPIPE=false\n')
        if schedule and delay:
             f.write('ExecStartPre=-@libdir@/@package@/boot_delay %s\n' % delay)
        f.write('ExecStart=%s\n' % command)
        if job['e']:
             f.write('Environment=%s\n' % job['e'])
        if job['u'] != 'root':
             f.write('User=%s\n' % job['u'])
        if standardoutput:
             f.write('StandardOutput=%s\n' % standardoutput)
        if job['Z']:
             f.write('CPUSchedulingPolicy=idle\n')
             f.write('IOSchedulingClass=idle\n')

    return '%s.timer' % unit_name

def log(level, message):
    if len(sys.argv) == 4:
        with open('/dev/kmsg', 'w', encoding='utf8') as kmsg:
            kmsg.write('<%s>%s[%s]: %s\n' % (level, SELF, os.getpid(), message))
    else:
        sys.stderr.write('%s: %s\n' % (SELF, message))

seqs = {}
def count():
    n = 0
    while True:
        yield n
        n += 1

def main():
    try:
        os.makedirs(TIMERS_DIR)
    except OSError as e:
        if e.errno != os.errno.EEXIST:
            raise

    if os.path.isfile('/etc/crontab'):
        for job in parse_crontab('/etc/crontab', withuser=True):
            if 'c' not in job:
                 log(3, 'truncated line in /etc/crontab: %s' % job['l'])
                 continue
            if '/etc/cron.hourly'  in job['c']: continue
            if '/etc/cron.daily'   in job['c']: continue
            if '/etc/cron.weekly'  in job['c']: continue
            if '/etc/cron.monthly' in job['c']: continue
            generate_timer_unit(job, seqs.setdefault(job['j']+job['u'], count()))

    CRONTAB_FILES = files('/etc/cron.d')
    for filename in CRONTAB_FILES:
        basename = os.path.basename(filename)
        masked = False
        for unit_file in ('@unitdir@/%s.timer' % basename,
                          '/etc/systemd/system/%s.timer' % basename,
                          '/run/systemd/system/%s.timer' % basename):
            if os.path.exists(unit_file):
                masked = True
                if os.path.realpath(unit_file) == '/dev/null':
                    log(5, 'ignoring %s because it is masked' % filename)
                else:
                    log(5, 'ignoring %s because native timer is present' % filename)
                break
        if masked:
            continue
        if basename.startswith('.'):
            continue
        if '.dpkg-' in basename:
            log(5, 'ignoring %s' % filename)
            continue
        for job in parse_crontab(filename, withuser=True):
            if 'c' not in job:
                log(3, 'truncated line in %s: %s' % (filename, job['l']))
                continue
            generate_timer_unit(job, seqs.setdefault(job['j']+job['u'], count()))

    if os.path.isfile('/etc/anacrontab'):
        for job in parse_crontab('/etc/anacrontab', monotonic=True):
            if 'c' not in job:
                 log(3, 'truncated line in /etc/anacrontab: %s' % job['l'])
                 continue
            generate_timer_unit(job, seqs.setdefault(job['j']+job['u'], count()))


    if os.path.isdir('@statedir@'):
        # /var is avaible
        USERCRONTAB_FILES = files('@statedir@')
        for filename in USERCRONTAB_FILES:
            basename = os.path.basename(filename)
            if '.' in basename:
                continue
            else:
                for job in parse_crontab(filename, withuser=False):
                    generate_timer_unit(job, seqs.setdefault(job['j']+job['u'], count()))
        try:
            open(REBOOT_FILE,'a').close()
        except:
            pass
    else:
        # schedule rerun
        with open('%s/cron-after-var.service' % TARGET_DIR, 'w') as f:
            f.write('[Unit]\n')
            f.write('Description=Rerun systemd-crontab-generator because /var is a separate mount\n')
            f.write('Documentation=man:systemd.cron(7)\n')
            f.write('After=cron.target\n')
            f.write('ConditionDirectoryNotEmpty=@statedir@\n')

            f.write('\n[Service]\n')
            f.write('Type=oneshot\n')
            f.write('ExecStart=/bin/sh -c "systemctl daemon-reload ; systemctl try-restart cron.target"\n')

        MULTIUSER_DIR = os.path.join(TARGET_DIR, 'multi-user.target.wants')

        try:
           os.makedirs(MULTIUSER_DIR)
        except OSError as e:
           if e.errno != os.errno.EEXIST:
               raise

        try:
            os.symlink('%s/cron-after-var.service' % TARGET_DIR, '%s/cron-after-var.service' % MULTIUSER_DIR)
        except OSError as e:
            if e.errno != os.errno.EEXIST:
                raise


if __name__ == '__main__':
    if len(sys.argv) == 1 or not os.path.isdir(sys.argv[1]):
        sys.exit("Usage: %s <destination_folder>" % sys.argv[0])

    TARGET_DIR = sys.argv[1]
    TIMERS_DIR = os.path.join(TARGET_DIR, 'cron.target.wants')

    try:
        main()
    except Exception as e:
        if len(sys.argv) == 4:
            open('/dev/kmsg', 'w').write('<2> %s[%s]: global exception: %s\n' % (SELF, os.getpid(), e))
            exit(1)
        else:
            raise
