#!/usr/bin/python3
"""ctg-timerd: the OpenRC stand-in for a systemd .timer + oneshot .service pair.

Runs under supervise-daemon (see the generated /etc/init.d scripts). Supports exactly the
timer forms ctg-ops uses: OnBootSec / OnUnitActiveSec (--boot-delay / --every), OnCalendar
(--calendar "hourly|daily|[Dow ]*-*-* HH[,HH]:MM[:SS] [UTC]", --persistent), RandomizedDelaySec
(--jitter). Never overlaps runs (a systemd oneshot is not started while active). On SIGTERM it
lets a running job finish for --stop-timeout seconds (systemd KillSignal=SIGCONT +
TimeoutStopSec behaviour) and only then kills it.
"""
import argparse, datetime as dt, os, pwd, random, signal, subprocess, sys, time

DOWS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
stop = False
child = None


def parse_calendar(spec):
    """-> (dow or None, [hours] or None, minute, second, utc)"""
    s = spec.strip().replace("_", " ").replace("*-*-* ", "")
    low = s.lower()
    if low == "hourly":
        return None, None, 0, 0, False
    if low == "daily":
        return None, [0], 0, 0, False
    parts = s.split()
    utc = False
    if parts and parts[-1].upper() == "UTC":
        utc = True
        parts = parts[:-1]
    dow = None
    if parts and parts[0][:3].lower() in DOWS:
        dow = DOWS.index(parts[0][:3].lower())
        parts = parts[1:]
    if len(parts) == 2 and parts[0] == "*-*-*":
        parts = parts[1:]
    if len(parts) != 1:
        raise SystemExit("ctg-timerd: unsupported calendar spec %r" % spec)
    h, m, *sec = parts[0].split(":")
    hours = None if h == "*" else [int(x) for x in h.split(",")]
    return dow, hours, int(m), int(sec[0]) if sec else 0, utc


def next_after(cal, now):
    """Next calendar time strictly after `now` (aware datetime)."""
    dow, hours, minute, second, utc = cal
    tz = dt.timezone.utc if utc else None
    t = now.astimezone(tz) if utc else now.astimezone()
    t = t.replace(minute=minute, second=second, microsecond=0)
    for _ in range(24 * 8 + 1):
        if (hours is None or t.hour in hours) and (dow is None or t.weekday() == dow) and t > now:
            return t
        t += dt.timedelta(hours=1)
        t = t.replace(minute=minute, second=second)
    raise SystemExit("ctg-timerd: calendar never fires")


def prev_before(cal, now):
    dow, hours, minute, second, utc = cal
    t = now.astimezone(dt.timezone.utc) if utc else now.astimezone()
    t = t.replace(minute=minute, second=second, microsecond=0)
    for _ in range(24 * 8 + 1):
        if (hours is None or t.hour in hours) and (dow is None or t.weekday() == dow) and t <= now:
            return t
        t -= dt.timedelta(hours=1)
        t = t.replace(minute=minute, second=second)
    return None


def sleep_until(deadline):
    while not stop:
        left = deadline - time.time()
        if left <= 0:
            return
        time.sleep(min(left, 1.0))


def run(cmd, user, env, stop_timeout):
    global child
    def preexec():
        if user:
            pw = pwd.getpwnam(user)
            os.initgroups(user, pw.pw_gid)
            os.setgid(pw.pw_gid)
            os.setuid(pw.pw_uid)
    e = dict(os.environ)
    e.update(env)
    try:
        child = subprocess.Popen(cmd, env=e, preexec_fn=preexec if user else None)
    except OSError as ex:
        print("ctg-timerd: cannot run %s: %s" % (cmd[0], ex), file=sys.stderr, flush=True)
        child = None
        return 127
    killed_at = None
    while True:
        try:
            return child.wait(timeout=1)
        except subprocess.TimeoutExpired:
            if stop:
                killed_at = killed_at or time.time()
                if time.time() - killed_at > stop_timeout:
                    child.kill()


def main():
    global stop
    ap = argparse.ArgumentParser()
    ap.add_argument("--name", required=True)
    ap.add_argument("--boot-delay", type=float, default=0)
    ap.add_argument("--every", type=float)
    ap.add_argument("--calendar")
    ap.add_argument("--persistent", action="store_true")
    ap.add_argument("--jitter", type=float, default=0)
    ap.add_argument("--post", action="append", default=[], help="ExecStopPost: command (no args) run after every run, success or not")
    ap.add_argument("--user")
    ap.add_argument("--env", action="append", default=[])
    ap.add_argument("--stop-timeout", type=float, default=90)
    ap.add_argument("--state-dir", default="/var/lib/ctg-timerd")
    ap.add_argument("--once", action="store_true", help="run one cycle then exit (tests)")
    ap.add_argument("cmd", nargs=argparse.REMAINDER)
    a = ap.parse_args()
    cmd = a.cmd[1:] if a.cmd[:1] == ["--"] else a.cmd
    if not cmd or (a.every is None and not a.calendar):
        ap.error("need a command and --every or --calendar")
    env = dict(kv.split("=", 1) for kv in a.env)

    def on_term(*_):
        global stop
        stop = True
    signal.signal(signal.SIGTERM, on_term)
    signal.signal(signal.SIGINT, on_term)
    os.makedirs(a.state_dir, exist_ok=True)
    stamp = os.path.join(a.state_dir, a.name + ".last")
    cal = parse_calendar(a.calendar) if a.calendar else None

    # first activation
    first = time.time() + a.boot_delay
    if cal:
        nxt = next_after(cal, dt.datetime.now(dt.timezone.utc)).timestamp()
        missed = False
        if a.persistent and os.path.exists(stamp):
            last = os.path.getmtime(stamp)
            p = prev_before(cal, dt.datetime.now(dt.timezone.utc))
            missed = p is not None and p.timestamp() > last
        first = (time.time() + 5) if missed else nxt
        if a.boot_delay and not missed:
            first = min(first, time.time() + a.boot_delay) if a.every else first
    when = first + random.uniform(0, a.jitter)
    while not stop:
        sleep_until(when)
        if stop:
            break
        started = time.time()
        rc = run(cmd, a.user, env, a.stop_timeout)
        for pc in a.post:
            run([pc], a.user, env, a.stop_timeout)
        if rc:
            print("ctg-timerd: %s exited %s" % (a.name, rc), file=sys.stderr, flush=True)
        open(stamp, "a").close(); os.utime(stamp)
        if a.once:
            return 0
        if cal:
            when = next_after(cal, dt.datetime.now(dt.timezone.utc)).timestamp() + random.uniform(0, a.jitter)
        else:
            when = max(started + a.every, time.time() + 1)
    return 0


if __name__ == "__main__":
    sys.exit(main())
