#!/usr/bin/env python3
"""cronpup-sync: add CronPup monitoring to the jobs in your crontab in one go.

    export CRONPUP_API_KEY=cp_...            # Settings -> Create API key
    python3 cronpup-sync.py                  # dry run: shows what would change
    python3 cronpup-sync.py --apply          # create monitors + rewrite the crontab (a backup is saved first)

Options:
    --tz Europe/Berlin     timezone of this server's cron (default: CRON_TZ in the crontab, $TZ, /etc/timezone, else UTC)
    --grace 10             grace period in minutes (default 10)
    --only REGEX           only jobs whose command matches REGEX
    --file F / --write W   read/write a crontab file instead of `crontab -l` / `crontab -` (for testing or /etc/cron.d)
    --base URL             CronPup base URL (default https://cronpup.com)
    --wrapper PATH         path of cronpup-run on this host (default: cronpup-run on $PATH)
    --system               the file has a user column (/etc/crontab, /etc/cron.d/*); use with --file

Each selected line `SCHED COMMAND` becomes `SCHED WRAPPER PING_URL SHELL -c 'COMMAND'` (SHELL from the crontab's SHELL=,
default sh), so CronPup sees start, exit code, duration and the output tail. Jobs that already ping CronPup are skipped,
and so are lines containing an unescaped % (cron feeds the text after % to stdin; wrap those by hand). Re-running is safe:
monitors are matched by slug, so nothing is duplicated. Requires Python 3.8+, no packages.
"""
import argparse
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
import time
import urllib.request

MACROS = {"@yearly", "@annually", "@monthly", "@weekly", "@daily", "@midnight", "@hourly"}
ENV_RE = re.compile(r"^\s*[A-Za-z_][A-Za-z0-9_]*\s*=")


def parse_line(line, system=False):
    """Return (schedule, user_prefix, command) for a job line, else None.

    With system=True (files in /etc/cron.d or /etc/crontab) the field after the schedule is the user and is kept as-is.
    """
    s = line.strip()
    if not s or s.startswith("#") or ENV_RE.match(s):
        return None
    nsched = 1 if s.split()[0] in MACROS else 5  # @reboot is not in MACROS: it has no schedule to monitor
    if s.split()[0].startswith("@") and nsched == 5:
        return None
    parts = s.split(None, nsched + (1 if system else 0))
    if len(parts) < nsched + 1 + (1 if system else 0):
        return None
    if nsched == 5 and not all(re.fullmatch(r"[\w*/,\-]+", p) for p in parts[:5]):
        return None
    sched = " ".join(parts[:nsched])
    user = parts[nsched] + " " if system else ""
    return sched, user, parts[-1]


UNESCAPED_PERCENT = re.compile(r"(?<!\\)%")


def crontab_shell(text):
    shell = "sh"
    for line in text.splitlines():
        m = re.match(r"^\s*SHELL\s*=\s*(\S+)", line)
        if m:
            shell = m.group(1)
    return shell


def default_tz(text):
    for line in text.splitlines():
        m = re.match(r"^\s*CRON_TZ\s*=\s*(\S+)", line)
        if m:
            return m.group(1)
    if os.environ.get("TZ"):
        return os.environ["TZ"]
    try:
        with open("/etc/timezone") as f:
            return f.read().strip() or "UTC"
    except OSError:
        return "UTC"


def job_name(command):
    first = command.strip().split()[0] if command.strip() else "job"
    base = re.sub(r"[^a-z0-9_-]", "-", os.path.basename(first.strip("'\"")).lower()).strip("-")[:40] or "job"
    return f"{base}-{hashlib.sha1(command.encode()).hexdigest()[:6]}"


def sh_quote(s):
    return "'" + s.replace("'", "'\"'\"'") + "'"


def api(base, key, method, path, body=None):
    req = urllib.request.Request(base + path, method=method, data=json.dumps(body).encode() if body else None,
                                 headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json",
                                          "User-Agent": "cronpup-sync/1.0"})
    try:
        with urllib.request.urlopen(req, timeout=20) as r:
            return json.loads(r.read())
    except urllib.error.HTTPError as e:
        try:
            msg = json.loads(e.read()).get("error", "")
        except Exception:  # noqa: BLE001
            msg = ""
        raise SystemExit(f"API error {e.code}: {msg}") from None


def main():
    ap = argparse.ArgumentParser(description="Add CronPup monitoring to crontab jobs")
    ap.add_argument("--apply", action="store_true")
    ap.add_argument("--tz")
    ap.add_argument("--grace", type=int, default=10)
    ap.add_argument("--only")
    ap.add_argument("--file")
    ap.add_argument("--write")
    ap.add_argument("--base", default=os.environ.get("CRONPUP_URL", os.environ.get("TARDYBELL_URL", "https://cronpup.com")))
    ap.add_argument("--wrapper", default="cronpup-run")
    ap.add_argument("--system", action="store_true", help="file has a user column (/etc/crontab, /etc/cron.d/*)")
    a = ap.parse_args()
    base = a.base.rstrip("/")

    if a.file:
        with open(a.file) as f:
            text = f.read()
    else:
        r = subprocess.run(["crontab", "-l"], capture_output=True, text=True)
        if r.returncode != 0:
            raise SystemExit("could not read crontab (crontab -l): " + r.stderr.strip())
        text = r.stdout
    tz = a.tz or default_tz(text)
    only = re.compile(a.only) if a.only else None

    lines = text.splitlines()
    plan = []
    for i, line in enumerate(lines):
        parsed = parse_line(line, a.system)
        if not parsed:
            continue
        sched, user, cmd = parsed
        if "cronpup" in cmd or "/p/" in cmd:
            print(f"skip (already monitored): {line}")
            continue
        if UNESCAPED_PERCENT.search(cmd):
            print(f"skip (contains %, which cron turns into stdin; wrap this one by hand): {line}")
            continue
        if only and not only.search(cmd):
            continue
        plan.append((i, sched, user, cmd, job_name(cmd)))

    if not plan:
        print("No jobs to add.")
        return 0
    print(f"Timezone for schedules: {tz}   grace: {a.grace} min\n")
    for _i, sched, user, cmd, name in plan:
        print(f"  {name:28} {sched:15} {user}{cmd}")
    if not a.apply:
        print(f"\nDry run. {len(plan)} job(s) would be monitored. Re-run with --apply to create the monitors and update the crontab.")
        return 0

    key = os.environ.get("CRONPUP_API_KEY") or os.environ.get("TARDYBELL_API_KEY")
    if not key:
        raise SystemExit("set CRONPUP_API_KEY (Settings -> Create API key)")
    if a.wrapper == "cronpup-run" and not shutil.which("cronpup-run"):
        raise SystemExit("cronpup-run not found on PATH. Install it first:\n"
                         f"  curl -fsSL {base}/static/cronpup-run.sh -o /usr/local/bin/cronpup-run "
                         "&& chmod +x /usr/local/bin/cronpup-run")

    shell = crontab_shell(text)  # run commands with the same shell cron would have used
    existing = {m.get("slug"): m for m in api(base, key, "GET", "/api/v1/monitors")["monitors"] if m.get("slug")}
    new_lines = list(lines)
    for i, sched, user, cmd, name in plan:
        if name in existing:  # re-run after a partial failure: reuse, don't duplicate
            m = existing[name]
            print(f"reusing {name}: {m['ping_url']}")
        else:
            body = {"name": name, "slug": name, "grace": a.grace * 60, "cron": sched, "tz": tz}
            m = api(base, key, "POST", "/api/v1/monitors", body)
            print(f"created {name}: {m['ping_url']}")
        new_lines[i] = f"{sched} {user}{a.wrapper} {m['ping_url']} {shell} -c {sh_quote(cmd)}"

    new_text = "\n".join(new_lines) + "\n"
    if a.write or a.file:
        out = a.write or a.file
        if not a.write:
            shutil.copyfile(a.file, f"{a.file}.bak.{int(time.time())}")
        with open(out, "w") as f:
            f.write(new_text)
        print(f"wrote {out}")
    else:
        backup = os.path.expanduser(f"~/crontab.backup.{int(time.time())}")
        with open(backup, "w") as f:
            f.write(text if text.endswith("\n") else text + "\n")
        r = subprocess.run(["crontab", "-"], input=new_text, text=True, capture_output=True)
        if r.returncode != 0:
            raise SystemExit(f"crontab install failed: {r.stderr.strip()}  (your original is in {backup})")
        print(f"crontab updated. Original saved to {backup} (restore with: crontab {backup})")
    print("Done. New monitors show 'waiting' until each job's first run.")
    return 0


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