#!/usr/local/bin/python3

import sys, datetime


def ignore_cron_line(s):
    return not (len(s) == 0 or
                s.startswith("#") or
                s.startswith("MAILTO") or
                s.startswith("HOME") or
                s.startswith("SHELL") or
                s.startswith("PATH"))

def strip_leading_zeroes(s):
    while len(s) > 1 and s[0] == "0":
        s = s[1:]
    return s

def explode(field, allowed):
    exploded = []

    for part in field.split(","):
        step = part.split("/", 1)

        if len(step) == 1:
            step += ("1",)
        elif not step[1].isnumeric() or int(step[1]) <= 0:
            return None

        if step[0] == "*":
            allowed_values = sorted(set(allowed.values()))
            exploded += range(allowed_values[0], allowed_values[-1] + 1,
                              int(step[1]))
            continue

        rang = list(map(strip_leading_zeroes, step[0].split("-", 1)))
        if not rang[0] in allowed:
            return None

        if len(rang) == 1:
            rang += (rang[0],)
        elif not rang[1] in allowed:
            return None

        exploded += range(allowed[rang[0]], allowed[rang[1]] + 1,
                          int(step[1]))

    return sorted(set(exploded))


maxprint = 20
isostart = datetime.datetime.now()
startdelta = 0

for arg in sys.argv[1:]:
    if "-h" in arg:
        print("""nextcron.py -- displays upcoming cronjobs

Usage:
  nextcron.py [<maxjobs>] [<isostart>] [+|-<startdelta>]
  nextcron.py [-h | --help]

Synopsis:
  crontab -l | nextcron.py [<parameters> ...]
  nextcron.py [<parameters> ...] < /etc/crontab
  cat /var/cron/tabs/* | nextcron.py [<parameters> ...]

Parameters:
  The order of parameters is irrelevant. You may add or omit them at your ease.

  <maxjobs>          Display just next <maxjobs> upcoming cronjobs; default: 20
  <isostart>         Display jobs starting at <isostart> and later instead of
                     now; valid examples of <isostart>:
                     2021-10-30 (Oct 30th, 2021)
                     2021-12-01 20:15 (quarter past 8 p.m. on Dec 1st, 2021)
                     2022-01-01 00:00:23 (23 seconds into 2022)
  +|-<startdelta>    When displaying jobs, add or substract
                     <startdelta> minutes to the start date and time, which
                     may be now or <isostart>; default: 0
  -h, --help         Show this help ;-)
""")
        sys.exit()

    if (arg.startswith("-") or arg.startswith("+")) and arg[1:].isnumeric():
        startdelta = int(arg)
    elif arg.isnumeric():
        maxprint = int(arg)
    else:
        isostart = datetime.datetime.fromisoformat(arg)

ALLOWED = (
  ("minute",
   {str(x): x for x in range(0, 60)}),
  ("hour",
   {str(x): x for x in range(0, 24)}),
  ("day",
   {str(x): x for x in range(1, 32)}),
  ("month",
   {**{str(x): x for x in range(1, 13)},
    **{"Jan": 1, "Feb": 2, "Mar": 3, "Apr":  4, "May":  5, "Jun":  6,
       "Jul": 7, "Aug": 8, "Sep": 9, "Oct": 10, "Nov": 11, "Dec": 12}}),
  ("weekday",
   {**{str(x): x-1 for x in range(1, 8)},
    **{"Sun": 6, "Mon": 0, "Tue": 1, "Wed": 2,
       "Thu": 3, "Fri": 4, "Sat": 5, "0": 6}})
)
SPECIAL = {
  "@yearly":   ("0", "0", "1", "1", "*"),
  "@annually": ("0", "0", "1", "1", "*"),
  "@monthly":  ("0", "0", "1", "*", "*"),
  "@weekly":   ("0", "0", "*", "*", "0"),
  "@daily":    ("0", "0", "*", "*", "*"),
  "@midnight": ("0", "0", "*", "*", "*"),
  "@hourly":   ("0", "*", "*", "*", "*")
}

cronjobs = []

for line in filter(ignore_cron_line, map(lambda l: l.rstrip(), sys.stdin)):
    if line.startswith("@"):
        fields = line.split(maxsplit=1)
        if len(fields) != 2:
            sys.exit("Invalid crontab entry: %s" % (line,))

        if fields[0] == "@reboot" or fields[0] == "@noauto":
            print("Ignoring %s" % (line,), file=sys.stderr)
            continue
        elif fields[0] in SPECIAL:
            fields = SPECIAL[fields[0]]
        else:
            sys.exit("Invalid time specification: %s" % (line,))
    else:
        fields = line.split(maxsplit=5)
        if len(fields) != 6:
            sys.exit("Invalid crontab entry: %s" % (line,))

    job = {"line": line,
           "weekday_and_day": (not fields[2].startswith("*") and
                               not fields[4].startswith("*"))}

    for i, (fieldname, allowed) in enumerate(ALLOWED):
        job[fieldname] = explode(fields[i], allowed)
        if job[fieldname] is None:
            sys.exit("Invalid %s field: %s" % (fieldname, line))
        
    cronjobs.append(job)

if len(cronjobs) == 0:
    sys.exit()

now = isostart + datetime.timedelta(minutes=startdelta)
now = now.replace(second=0).replace(microsecond=0)
printed = 0

while printed < maxprint:
    for job in cronjobs:
        if job["weekday_and_day"]:
           day_match = (now.day       in job["day"] or
                        now.weekday() in job["weekday"])
        else:
           day_match = (now.day       in job["day"] and
                        now.weekday() in job["weekday"])

        if (now.minute in job["minute"] and
            now.hour   in job["hour"]   and
            day_match                   and
            now.month  in job["month"]):
            print(now, ":", job["line"])
            printed += 1
            if printed >= maxprint:
                sys.exit()
    now += datetime.timedelta(minutes=1)
