#!/usr/bin/env python3
"""Copy UptimeRobot monitors to OKStatus.

Reads your monitors with UptimeRobot's API (v2, read-only key is enough) and
creates them in OKStatus with its REST API (Full key, Max plan). Without
--apply it only prints what it would create. Your UptimeRobot account is
never changed. Python 3.9+, standard library only.

  export UPTIMEROBOT_API_KEY=ur...
  export OKSTATUS_REGIONS=fr-dc3,de-fra,nl-ams     # regions of your plan
  export OKSTATUS_MIN_INTERVAL=30                  # 300 Free, 60 Pro, 30 Max
  python3 uptimerobot_to_okstatus.py               # dry run
  export OKSTATUS_API_KEY=oks_live_...
  python3 uptimerobot_to_okstatus.py --apply

Guide: https://okstatus.eu/docs/tutorials/migrate-from-uptimerobot/
"""
import json
import os
import re
import sys
import urllib.error
import urllib.parse
import urllib.request

UR_API = os.environ.get("UPTIMEROBOT_API_URL", "https://api.uptimerobot.com/v2")
OK_API = os.environ.get("OKSTATUS_API_URL", "https://api.okstatus.eu/api/v1")
REGIONS = [r.strip() for r in os.environ.get("OKSTATUS_REGIONS", "fr-dc3,de-fra").split(",") if r.strip()]
MIN_INTERVAL = int(os.environ.get("OKSTATUS_MIN_INTERVAL", "60"))
APPLY = "--apply" in sys.argv

# UptimeRobot v2 codes (uptimerobot.com/api/legacy)
PORTS = {1: 80, 2: 443, 3: 21, 4: 25, 5: 110, 6: 143}  # port monitor sub_type
METHODS = {1: "HEAD", 2: "GET", 3: "POST", 4: "PUT", 5: "PATCH", 6: "DELETE", 7: "OPTIONS"}
PAUSED = 0  # monitor status


def uptimerobot_monitors():
    key = os.environ.get("UPTIMEROBOT_API_KEY")
    if not key:
        sys.exit("Set UPTIMEROBOT_API_KEY (Integrations → API → read-only key).")
    offset = 0
    while True:
        form = {"api_key": key, "format": "json", "offset": offset, "limit": 50,
                "http_request_details": "true"}
        req = urllib.request.Request(f"{UR_API}/getMonitors", urllib.parse.urlencode(form).encode())
        with urllib.request.urlopen(req, timeout=30) as r:
            page = json.load(r)
        if page.get("stat") != "ok":
            sys.exit(f"UptimeRobot: {page.get('error')}")
        yield from page.get("monitors", [])
        offset += 50
        if offset >= int(page.get("pagination", {}).get("total", 0)):
            return


def as_int(value, default=0):
    try:
        return int(value)
    except (TypeError, ValueError):
        return default


def convert(m):
    """(body of POST /api/v1/monitors, None) or (None, why it is skipped)."""
    kind = as_int(m.get("type"))
    body = {
        "name": str(m.get("friendly_name") or m.get("url") or "Monitor")[:100],
        "regions": REGIONS,
        # OKStatus accepts 30 s to 1 day, and never less than the plan's floor.
        "interval": min(max(as_int(m.get("interval"), 300), MIN_INTERVAL), 86400),
    }
    if kind in (1, 2):  # HTTP(s), Keyword
        body.update(type="http", url=m["url"])
        method = METHODS.get(as_int(m.get("http_method"), 2), "GET")
        if method != "GET":
            body["httpMethod"] = method
        if kind == 2:
            body["keyword"] = m.get("keyword_value") or ""
            body["keywordInvert"] = as_int(m.get("keyword_type")) == 2  # 2 = "not exists"
    elif kind == 3:  # Ping
        body.update(type="ping", url=m["url"])
    elif kind == 4:  # Port
        sub = as_int(m.get("sub_type"))
        port = as_int(m.get("port")) if sub == 99 else PORTS.get(sub)
        if not port:
            return None, "port monitor without a port"
        body.update(type="tcp", url=m["url"], port=port)
    elif kind == 5:  # Heartbeat
        if as_int(m.get("interval")) > 86400:
            # A shorter period would report the job down between two runs.
            return None, "heartbeat period over one day, the OKStatus maximum"
        slug = re.sub(r"[^a-z0-9]+", "-", body["name"].lower()).strip("-")
        body.update(type="push", url=slug or "heartbeat")
    else:
        return None, f"UptimeRobot type {m.get('type')} has no equivalent"
    return body, None


def okstatus(method, url, body):
    req = urllib.request.Request(url, json.dumps(body).encode(), method=method, headers={
        "Authorization": f"Bearer {os.environ['OKSTATUS_API_KEY']}",
        "Content-Type": "application/json",
    })
    with urllib.request.urlopen(req, timeout=30) as r:
        return json.load(r)["data"]


def main():
    if APPLY and not os.environ.get("OKSTATUS_API_KEY"):
        sys.exit("Set OKSTATUS_API_KEY (a Full key, Settings → API Keys) to use --apply.")
    created = skipped = failed = 0
    for m in uptimerobot_monitors():
        name = m.get("friendly_name", "?")
        body, why = convert(m)
        if body is None:
            print(f"SKIPPED  {name} ({why})")
            skipped += 1
            continue
        paused = as_int(m.get("status"), -1) == PAUSED
        if not APPLY:
            print(f"TO CREATE {body['type']:5} {body['name']} -> {body['url']}"
                  + (" (paused)" if paused else ""))
            continue
        try:
            new = okstatus("POST", f"{OK_API}/monitors", body)
            if paused:
                okstatus("PATCH", f"{OK_API}/monitors/{new['id']}", {"status": "paused"})
            extra = f" · new ping URL: {new['pushUrl']}" if body["type"] == "push" else ""
            print(f"CREATED  {body['name']}{extra}")
            created += 1
        except urllib.error.HTTPError as e:
            print(f"FAILED   {body['name']}: {e.code} {e.read().decode(errors='replace')[:200]}")
            failed += 1
    if APPLY:
        print(f"\n{created} created, {skipped} skipped, {failed} failed.")
        if failed:
            sys.exit(1)
    else:
        print("\nDry run: nothing created. Add --apply to create these monitors.")


if __name__ == "__main__":
    main()
