#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Mutual Solutions, Inc.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
# documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
# persons to whom the Software is furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
# Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
# WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
# COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
# (Full text: https://zap.mutual.solutions/downloads/LICENSE.txt. Using the Zap! service itself is governed by
# https://zap.mutual.solutions/terms.)
"""zap.py: command-line client for Zap! by mutual (https://zap.mutual.solutions). Python 3.8+, standard library only.

The API key is read from the environment variable ZAP_API_KEY, or from the file named by ZAP_API_KEY_FILE, or from
~/.config/zap/key (in that order). It is never accepted on the command line, so it stays out of shell history and
process lists. Keep the key file readable only by you (chmod 600). Without a key, zap.py uses the sign-in saved by
`zap.py login` (where the service offers OAuth sign-in).

  python3 zap.py run BOARD.kicad_pcb [-o OUT] [--currents C.json] [--router stable|next] [--keep-existing | --strip-existing]
        [--fab-floor jlc|board] [--effort quick|normal|thorough] [--minutes N] [--consent-training] [--keep]
        submit, wait, download. Picks up BOARD.kicad_pro and BOARD.kicad_dru next to the board automatically.
        Writes BOARD.routed.kicad_pcb unless -o is given, and the full report (positions of every finding) to
        BOARD.routed.zap-report.json; then deletes the job's files on the server (--keep: leave them for 7 days).
        --keep-existing: the board is partly routed; keep its tracks, vias and zones and route only what is missing.
        --strip-existing: the board has old tracks or vias; discard them and route everything.
        --minutes N: let Zap! keep trying for up to N minutes (1 to 60) and return the best result; default is one
        round of a few minutes.
  python3 zap.py submit BOARD.kicad_pcb [--project P.kicad_pro] [--rules R.kicad_dru] [--currents C.json]
        [--router stable|next] [--keep-existing] [--fab-floor jlc|board] [--consent-training]
  python3 zap.py demo        downloads mutual's sample board (zap-sample.kicad_pcb, 42 pads) here and runs it: the quickest
        way to see a result (uses one of your daily jobs)
  python3 zap.py check BOARD.kicad_pcb [--currents C.json] [--router stable|next] [--keep-existing] [--fab-floor jlc|board]
        pre-flight check, no routing and no job quota: what would stop or weaken the job (run does this first;
        --no-check skips it). Picks up BOARD.kicad_pro and BOARD.kicad_dru like run.
  python3 zap.py status JOB_ID
  python3 zap.py wait JOB_ID
  python3 zap.py download JOB_ID [-o OUT]
  python3 zap.py report JOB_ID [-o REPORT.json]   every finding with position and what to do (+ one .svg per copper layer)
  python3 zap.py delete JOB_ID     (or cancel JOB_ID): cancels a queued or running job; deletes a finished job's files
  python3 zap.py list
  python3 zap.py version      service version and the routers offered (no key needed)
  python3 zap.py feedback "TEXT" [--job JOB_ID] [--kind praise|problem|idea|other] [--contact-ok] [--snapshot ID]... [--yes]
        send your opinion about Zap! or a result to the Zap! team. Shows the exact text and asks y/N first;
        --yes only after the user agreed to this exact text (needed when not run on a terminal, e.g. by an agent).
        --contact-ok: the Zap! team may reply to your account's email address. --snapshot: attach a picture made with
        zap.py snapshot (up to 3; the user saw it and agreed).
  python3 zap.py snapshot JOB_ID --at X,Y [--size MM] [--layers F.Cu,B.Cu] [--no-issues] [-o FILE]
        a picture of one area (at most 40 mm square, centred on X,Y in mm as KiCad shows them) of a finished job's
        routed board, with the report's issues marked; saved as FILE (default zap-snapshot-<id>.png) to show the user.
        Deleted on the server after 24 hours unless attached to feedback.
  python3 zap.py snapshot-delete ID   delete a picture the user does not want, on the server, at once
  python3 zap.py login        sign in with Google, GitHub or Microsoft in a browser (any device): prints an address and a
        short code to type there, waits for you to approve, prints the account that approved, then saves the sign-in to
        ~/.config/zap/oauth.json (mode 600; Windows: %APPDATA%/zap/oauth.json). 1-hour access, renewed automatically
        until you log out or revoke it on /account.
  python3 zap.py logout       ends that sign-in on the server and deletes the file

Environment: ZAP_URL (default https://zap.mutual.solutions; https only). Redirects are never followed, so the key
only goes to that address. Exit status: 0 success, 1 job failed or API error, 2 usage or local error; on any error the
last line on stderr is JSON: {"error": code, "message": ..., "exit": status, plus the API's fields such as option, fix}. Every routed
board must be reviewed by an engineer before fabrication."""
import argparse, gzip, json, os, stat, sys, time, urllib.error, urllib.request, uuid

VERSION = "1.14"
URL = os.environ.get("ZAP_URL", "https://zap.mutual.solutions").rstrip("/")


class _NoRedirect(urllib.request.HTTPRedirectHandler):
    """A redirect becomes an error: the key is never sent on to another address."""
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        return None


OPENER = urllib.request.build_opener(_NoRedirect)
METRICS = [
    ("unconnected", "connections still missing (finish these by hand in KiCad)"),
    ("critical_unconnected", "of those, on supply, ground, clock, reset, boot or debug nets"),
    ("drc_errors", "KiCad design-rule errors under your rules, including ones caused by the placement"),
]


def die(msg, code=2, err=None):
    """Exit non-zero: a sentence for people, then one JSON line for software (error code, message, exit status and the
    API's extra fields such as option and fix)."""
    print("zap: " + msg, file=sys.stderr)
    info = {"error": (err or {}).get("error") or ("usage_or_local" if code == 2 else "request_failed"), "message": msg, "exit": code}
    info.update({k: v for k, v in (err or {}).items() if k not in ("error", "message") and isinstance(v, (str, int, float, bool, list, dict))})
    print(json.dumps(info), file=sys.stderr)
    sys.exit(code)


def load_key():
    k = os.environ.get("ZAP_API_KEY", "").strip()
    if k:
        return k
    path = os.environ.get("ZAP_API_KEY_FILE") or os.path.expanduser("~/.config/zap/key")
    if not os.environ.get("ZAP_API_KEY_FILE") and not os.path.exists(path):
        tok = oauth_access_token()  # no key anywhere: the sign-in saved by zap.py login, if any
        if tok:
            return tok
        die("no API key and not signed in: run zap.py login, or set ZAP_API_KEY_FILE to your key file")
    try:
        st = os.stat(path)
        if os.name == "posix" and st.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
            print("zap: warning: %s is readable by other users; run: chmod 600 %s" % (path, path), file=sys.stderr)
        with open(path) as f:
            k = f.read().strip().split()[0]
    except (OSError, IndexError):
        die("no API key: set ZAP_API_KEY_FILE to your key file (or ZAP_API_KEY, or save it in ~/.config/zap/key)")
    return k


def call(method, path, body=None, ctype=None, raw=False, auth=True):
    if not URL.lower().startswith("https://"):
        die("ZAP_URL must be an https:// address (the key is never sent over plain http)")
    headers = {"User-Agent": "zap.py/" + VERSION}
    if auth:
        headers["Authorization"] = "Bearer " + load_key()
    if ctype:
        headers["Content-Type"] = ctype
    for attempt in range(5):
        req = urllib.request.Request(URL + path, data=body, method=method, headers=headers)
        try:
            with OPENER.open(req, timeout=400) as r:  # a pre-flight check may wait 60 s for a slot and run 300 s
                data = r.read()
                return data if raw else json.loads(data or b"{}")
        except urllib.error.HTTPError as e:
            try:
                err = json.loads(e.read())
            except Exception:
                err = {}
            if 300 <= e.code < 400:
                die("%s answered with a redirect (HTTP %d), which zap.py does not follow so your key stays with Zap!; "
                    "check ZAP_URL" % (URL, e.code), 1)
            if e.code == 429 and err.get("error") == "rate_limited" and attempt < 4:
                time.sleep(min(60, int(e.headers.get("Retry-After", "5"))))
                continue
            if e.code in (502, 503, 504) and not err:  # the edge answers this way during a short maintenance
                if attempt < 4 and method == "GET":
                    time.sleep(5 + 10 * attempt)
                    continue
                die("Zap! is briefly unavailable (HTTP %d from the network edge, usually a short maintenance): "
                    "try again in a minute" % e.code, 1)
            if e.code == 403 and "cf-mitigated" in {k.lower() for k in e.headers.keys()}:
                die("blocked by the network firewall in front of Zap! (HTTP 403 challenge); tell hello@mutual.solutions "
                    "your public IP address", 1)
            msg = err.get("message") or ("HTTP %d" % e.code)
            for f in (err.get("footprints") or [])[:20]:  # footprints_outside_board: which ones, and where
                at = f.get("at_mm") or [None, None]
                print("  %s at x %s, y %s mm (%s side)" % (f.get("ref"), at[0], at[1], f.get("side")), file=sys.stderr)
            if (err.get("footprints_total") or 0) > 20:
                print("  ... and %d more" % (err["footprints_total"] - 20), file=sys.stderr)
            die("%s (%s)" % (msg, err.get("error", e.code)), 1, err if isinstance(err, dict) else None)
        except (urllib.error.URLError, OSError) as e:
            if attempt < 4 and method == "GET":
                time.sleep(2 + 3 * attempt)
                continue
            die("could not reach %s (%s)" % (URL, getattr(e, "reason", e)), 1)


# ---------- OAuth sign-in (zap.py login): device authorization grant, RFC 8628 ----------
CLI_CLIENT_ID = "zap-cli"
DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"


def token_path():
    base = os.environ.get("APPDATA") if os.name == "nt" else os.path.expanduser("~/.config")
    return os.path.join(base or os.path.expanduser("~"), "zap", "oauth.json")


def form_post(path, fields):
    """POST application/x-www-form-urlencoded to the OAuth endpoints -> (HTTP status, JSON). Same rules as call():
    https only, no redirects."""
    if not URL.lower().startswith("https://"):
        die("ZAP_URL must be an https:// address (tokens are never sent over plain http)")
    import urllib.parse
    req = urllib.request.Request(URL + path, data=urllib.parse.urlencode(fields).encode(), method="POST", headers={
        "User-Agent": "zap.py/" + VERSION, "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"})
    try:
        with OPENER.open(req, timeout=60) as r:
            return r.status, json.loads(r.read() or b"{}")
    except urllib.error.HTTPError as e:
        if 300 <= e.code < 400:
            die("%s answered with a redirect (HTTP %d), which zap.py does not follow; check ZAP_URL" % (URL, e.code), 1)
        try:
            return e.code, json.loads(e.read() or b"{}")
        except ValueError:
            return e.code, {}
    except (urllib.error.URLError, OSError) as e:
        die("could not reach %s (%s)" % (URL, getattr(e, "reason", e)), 1)


class _Lock:
    """One process at a time renews the saved sign-in (a refresh token works once; a second use ends the sign-in).
    An operating-system lock on the lock file (flock; msvcrt.locking on Windows): it is held for as long as the renewal
    takes, however slow the network, and it ends with the process, so a crashed zap.py never leaves it behind. The file
    itself stays (removing it would let two processes lock two different files)."""
    WAIT = 300  # seconds to wait for another zap.py (its renewal is bounded by the HTTP timeouts)

    def __init__(self, path):
        self.path = path + ".lock"
        self.f = None

    def _try(self):
        try:
            if os.name == "nt":
                import msvcrt
                self.f.seek(0)
                msvcrt.locking(self.f.fileno(), msvcrt.LK_NBLCK, 1)
            else:
                import fcntl
                fcntl.flock(self.f.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
            return True
        except OSError:
            return False

    def __enter__(self):
        os.makedirs(os.path.dirname(self.path), mode=0o700, exist_ok=True)  # first login: the folder may not exist yet
        self.f = os.fdopen(os.open(self.path, os.O_RDWR | os.O_CREAT, 0o600), "r+b")
        deadline = time.time() + self.WAIT
        while not self._try():
            if time.time() > deadline:
                self.f.close()
                die("another zap.py has been renewing the sign-in for %d s; try again" % self.WAIT)
            time.sleep(0.1)
        return self

    def __exit__(self, *a):
        try:
            if os.name == "nt":
                import msvcrt
                self.f.seek(0)
                msvcrt.locking(self.f.fileno(), msvcrt.LK_UNLCK, 1)
        except OSError:
            pass
        self.f.close()  # closing the file releases flock


def save_tokens(t):
    path = token_path()
    os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True)
    data = {"url": URL, "access_token": t["access_token"], "refresh_token": t.get("refresh_token"),
            "expires_at": int(time.time()) + int(t.get("expires_in", 3600))}
    tmp = "%s.%d.tmp" % (path, os.getpid())
    fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
    with os.fdopen(fd, "w") as f:
        json.dump(data, f)
    os.replace(tmp, path)
    return path


def read_tokens():
    path = token_path()
    try:
        st = os.stat(path)
        if os.name == "posix" and st.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
            print("zap: warning: %s is readable by other users; run: chmod 600 %s" % (path, path), file=sys.stderr)
        with open(path) as f:
            t = json.load(f)
        return t if isinstance(t, dict) and t.get("url") == URL else None  # a sign-in belongs to one server
    except (OSError, ValueError):
        return None


def oauth_access_token():
    if read_tokens() is None:
        return None
    with _Lock(token_path()):
        t = read_tokens()  # again, inside the lock: another process may have renewed it
        if t is None:
            return None
        if t.get("expires_at", 0) > time.time() + 60:
            return t["access_token"]
        st, body = form_post("/oauth/token", {"grant_type": "refresh_token", "refresh_token": t.get("refresh_token") or "",
                                              "client_id": CLI_CLIENT_ID})
        if st != 200:
            if body.get("error") in ("invalid_grant", "invalid_client"):
                os.remove(token_path())
                die("your sign-in has ended (expired or revoked): run zap.py login again", 1)
            die("could not renew the sign-in (%s); try again" % (body.get("error") or "HTTP %d" % st), 1)
        save_tokens(body)
        return body["access_token"]


def login():
    st, d = form_post("/oauth/device_authorization", {"client_id": CLI_CLIENT_ID, "scope": "zap"})
    if st in (404, 413):  # a server without OAuth sign-in answers like any unknown address
        die("%s does not offer sign-in: use an API key (ZAP_API_KEY_FILE)" % URL, 1)
    if st != 200:
        die("sign-in is not available right now (%s); try again later or use an API key" % (d.get("error") or "HTTP %d" % st), 1)
    # the address and the code separately, never one pre-filled link: a link is easy to forward, and whoever approves
    # it signs this terminal in to their account
    print("To sign in, open this address in a browser (this or any device you are using):\n\n    %s\n\n"
          "sign in, and type this code:\n\n    %s\n\n"
          "Approve only a code that this terminal shows. Waiting up to %d minutes..." % (
              d["verification_uri"], d["user_code"], max(1, d["expires_in"] // 60)), file=sys.stderr)
    interval, deadline = int(d.get("interval", 5)), time.time() + int(d["expires_in"])
    while time.time() < deadline:
        time.sleep(interval)
        st, t = form_post("/oauth/token", {"grant_type": DEVICE_GRANT, "device_code": d["device_code"],
                                           "client_id": CLI_CLIENT_ID})
        if st == 200:
            with _Lock(token_path()):
                path = save_tokens(t)
            who = t.get("account_email")
            print("signed in%s; saved to %s (readable only by you)" % (" as " + who if who else "", path), file=sys.stderr)
            if who:
                print("If %s is not your Zap! account, run zap.py logout now: your boards would go to that account."
                      % who, file=sys.stderr)
            return
        e = t.get("error")
        if e == "authorization_pending":
            continue
        if e == "slow_down":
            interval += 5
            continue
        die({"access_denied": "the sign-in was denied in the browser",
             "expired_token": "the code expired; run zap.py login again"}.get(e, "sign-in failed (%s)" % e), 1)
    die("the code expired; run zap.py login again", 1)


def logout():
    t = read_tokens()
    if t is None:
        print("not signed in", file=sys.stderr)
        return
    form_post("/oauth/revoke", {"token": t.get("refresh_token") or t["access_token"], "client_id": CLI_CLIENT_ID})
    os.remove(token_path())
    print("signed out; the sign-in was ended on the server and %s deleted" % token_path(), file=sys.stderr)


def multipart(fields):
    b = uuid.uuid4().hex
    parts = []
    for name, (fname, data) in fields.items():
        head = '--%s\r\nContent-Disposition: form-data; name="%s"' % (b, name)
        if fname is not None:
            head += '; filename="%s"\r\nContent-Type: application/octet-stream' % os.path.basename(fname).replace('"', "")
        parts.append(head.encode() + b"\r\n\r\n" + data + b"\r\n")
    return b"".join(parts) + ("--%s--\r\n" % b).encode(), "multipart/form-data; boundary=" + b


def read(path):
    try:
        with open(path, "rb") as f:
            return f.read()
    except OSError as e:
        die("cannot read %s (%s)" % (path, e.strerror))


OPTS = {}  # routing options from the command line: upload field -> value
OPTION_FLAGS = (("signal_width_mm", None, "signal track width in mm (default: the board's net class, else 0.2)"),
                ("via_diameter_mm", None, "via diameter in mm, with --via-drill-mm (default: net class, else 0.6)"),
                ("via_drill_mm", None, "via drill in mm, with --via-diameter-mm (default: net class, else 0.3)"),
                ("vias_in_pads", ("forbid", "allow"), "vias inside SMD pads (default forbid; allow needs filled and capped vias, an extra fab option)"),
                ("ground_pour", ("on", "off"), "ground copper pours (default on)"),
                ("size_rails_from_current", ("on", "off"), "supply track width from current where room allows (default on; off: net-class width, may overheat)"),
                ("effort", ("quick", "normal", "thorough"), "routing effort (default normal). thorough: about twice the routing work and keeps the best intermediate result; slower on large boards, usually fewer unrouted connections; quick: about one eighth of the work"),
                ("minutes", None, "let Zap! keep trying for up to N minutes (1 to 60) and return the best result; default is one round of a few minutes"))


def upload_fields(board, project=None, rules=None, consent=False, currents=None, router=None, keep_existing=False, fab_floor=None):
    if not board.endswith(".kicad_pcb"):
        die("the board must be a .kicad_pcb file")
    # the board goes gzip-compressed (the service unpacks it; large boards then fit the 20 MiB upload limit)
    fields = {"board": (board + ".gz", gzip.compress(read(board), compresslevel=6, mtime=0))}
    if project:
        fields["project"] = (project, read(project))
    if rules:
        fields["rules"] = (rules, read(rules))
    if currents:
        fields["currents"] = (currents, read(currents))
    fields["consent_training"] = (None, b"true" if consent else b"false")
    if router:
        fields["router"] = (None, router.encode())
    if keep_existing:
        fields["keep_existing"] = (None, b"true")
    if fab_floor:
        fields["fab_floor"] = (None, fab_floor.encode())
    for k, v in OPTS.items():  # routing options given on the command line (only those that were set)
        fields[k] = (None, str(v).encode())
    size = sum(len(v[1]) for v in fields.values())
    if size > 20 * 1024 * 1024:
        die("upload is %.1f MB; the limit is 20 MB in total" % (size / 1048576))
    return fields


def check(board, project=None, rules=None, currents=None, router=None, keep_existing=False, fab_floor=None):
    """Pre-flight check (no routing, no job quota): print what would stop or weaken the job. -> True if ready."""
    body, ctype = multipart(upload_fields(board, project, rules, False, currents, router, keep_existing, fab_floor))
    r = call("POST", "/v1/check", body, ctype)
    b = r.get("board") or {}
    print("check: %s (%s pads, %s footprints, %s copper layers, KiCad %s file)" % (
        r.get("summary"), b.get("pads"), b.get("footprints"), b.get("copper_layers"), b.get("kicad_version")), file=sys.stderr)
    ex = b.get("existing_copper")
    if ex:
        print("  keeping your %s tracks, %s vias and %s zones; only the missing connections are routed" % (
            ex.get("tracks"), ex.get("vias"), ex.get("zones")), file=sys.stderr)
    for kind in ("blockers", "warnings"):
        for f in r.get(kind) or []:
            print("  %s %s: %s" % ("PROBLEM" if kind == "blockers" else "warning", f.get("code"), f.get("message")), file=sys.stderr)
            items = f.get("footprints") or f.get("items") or []
            for x in items[:10]:
                where = x.get("at_mm")
                what = x.get("ref") or ", ".join(x.get("refs") or [])
                print("      %s at x %s, y %s mm" % (what, where[0], where[1]) if where else "      %s" % what, file=sys.stderr)
            if len(items) > 10:
                print("      ... and %d more" % (len(items) - 10), file=sys.stderr)
            for k in ("unknown_nets", "net_classes"):
                if f.get(k):
                    print("      " + ", ".join(f[k][:20]), file=sys.stderr)
            if f.get("rule"):
                print("      rule %r%s" % (f["rule"], (" uses " + f["function"]) if f.get("function") else ""), file=sys.stderr)
    sys.stderr.flush()
    return bool(r.get("ok"))


def submit(board, project=None, rules=None, consent=False, currents=None, router=None, keep_existing=False, fab_floor=None):
    fields = upload_fields(board, project, rules, consent, currents, router, keep_existing, fab_floor)
    body, ctype = multipart(fields)
    job = call("POST", "/v1/jobs", body, ctype)
    sent = ", ".join(os.path.basename(v[0]) for k, v in fields.items() if v[0])
    print("submitted %s -> job %s" % (sent, job["id"]), file=sys.stderr)
    return job["id"]


def wait(jid):
    t0, last, note = time.time(), None, None
    while True:
        st = call("GET", "/v1/jobs/" + jid)
        s = st.get("status")
        if s in ("succeeded", "failed", "cancelled"):
            if sys.stderr.isatty():
                print(file=sys.stderr)
            return st
        line = "%s, %d s" % (s, time.time() - t0)
        if s == "queued" and st.get("queue_position"):
            line += ", position %d in queue" % st["queue_position"]
        if st.get("queue_note") and st["queue_note"] != note:  # e.g. waiting for another long job (--minutes over 10)
            note = st["queue_note"]
            print(("\n" if sys.stderr.isatty() else "") + note, file=sys.stderr)
        if sys.stderr.isatty():
            print("\r" + line.ljust(50), end="", file=sys.stderr, flush=True)
        elif s != last:
            print(line, file=sys.stderr)
        last = s
        time.sleep(5 if time.time() - t0 < 60 else 10)


def report(st):
    if st["status"] == "cancelled":
        print("job %s was cancelled; nothing was kept on the server" % st["id"])
        sys.stdout.flush()
        return 1
    if st["status"] == "failed":
        e = st.get("error") or {}
        print("job %s failed: %s (%s)" % (st["id"], e.get("message", ""), e.get("code", "")))
        sys.stdout.flush()
        print(json.dumps({"error": e.get("code"), "message": e.get("message"), "exit": 1, "job_id": st["id"],
                          "report_json": st.get("report_json")}), file=sys.stderr)
        return 1
    m = st.get("metrics") or {}
    print("job %s succeeded in %s s. Counts below: lower is better, 0 is best." % (st["id"], m.get("wall_time_s")))
    for k, what in METRICS:
        print("  %-22s %4s  %s" % (k, m.get(k), what))
    if m.get("time_budget"):  # --minutes: how many attempts ran, which one was kept, the time used
        print("Time budget: " + (m["time_budget"].get("summary") or json.dumps(m["time_budget"])))
    ex = m.get("existing_copper")
    if ex:
        print("Your own copper: kept %s tracks, %s vias and %s zones%s; the router added %s tracks and %s vias." % (
            ex.get("tracks_kept"), ex.get("vias_kept"), ex.get("zones_kept"),
            "" if not any(ex.get(k) for k in ("tracks_removed", "vias_removed", "zones_removed")) else
            " (but %s tracks, %s vias and %s zones are missing: tell hello@mutual.solutions)" % (
                ex.get("tracks_removed"), ex.get("vias_removed"), ex.get("zones_removed")),
            ex.get("tracks_added"), ex.get("vias_added")))
    report_fab(m.get("fab"))
    print("Review the routed board and run KiCad's DRC (Inspect > Design Rules Checker) before ordering boards.")
    sys.stdout.flush()
    return 0


def report_fab(f):
    if not f:
        return
    if not f.get("computed"):
        print("Fab check: not computed (%s)." % f.get("reason"))
        return
    th = f.get("thermal") or {}
    if th.get("computed"):
        sc = th.get("stated_currents")
        print("Temperature rise. Currents: %s" % th.get("currents"))
        if sc:
            print("  you stated %s net currents; %s" % (sc.get("nets"), "this check used them" if sc.get("applied") else "this check did not use them"))
            if th.get("stated_not_checked"):
                print("  not checked (no source or load part found): " + ", ".join(th["stated_not_checked"]))
        hot = "off the chart (far beyond IPC-2152)" if th.get("off_chart") else "%s C above ambient" % th.get("worst_rise_c")
        if th.get("worst_temp_c") is not None:
            hot += ", %s C at your ambient of %s C" % (th.get("worst_temp_c"), th.get("ambient_c"))
        print("  heat: %s over the %s C rise limit (hottest %s)%s" % (
            th.get("sections_over_limit"), th.get("limit_c"), hot,
            (", on " + ", ".join(th.get("nets_over_limit") or [])) if th.get("nets_over_limit") else ""))
    else:
        print("Temperature rise: not computed (%s)." % th.get("reason"))
    by = f.get("jlc_violations_by_type") or {}
    pre = f.get("shorts_already_in_board")
    print("Fab: %s shorts%s, %s design-rule errors, %s JLC capability violations%s" % (
        f.get("shorts"), (" (%s already in your uploaded board, not made by the router)" % pre) if pre else "",
        f.get("drc_errors"), f.get("jlc_violations"),
        (" (" + ", ".join("%s %s" % (k, v) for k, v in sorted(by.items())) + ")") if by else ""))
    for x in (f.get("jlc_items") or [])[:5]:
        print("  %s at x %s, y %s mm%s%s" % (x.get("type"), x["at_mm"][0], x["at_mm"][1],
                                          (" on " + x["layer"]) if x.get("layer") else "", (", net " + x["net"]) if x.get("net") else ""))
    if len(f.get("jlc_items") or []) > 5:
        print("  ... %d more in %s" % (len(f["jlc_items"]) - 5, REPORT_HINT[0]))


REPORT_HINT = ["the job status (metrics.fab.jlc_items)"]


def copy_project(base, out):
    """KiCad opens a board with the project of the same name: give the routed board the input's rules too."""
    obase = out[: -len(".kicad_pcb")] if out.endswith(".kicad_pcb") else out
    for ext in (".kicad_pro", ".kicad_dru"):
        if os.path.exists(base + ext) and not os.path.exists(obase + ext):
            with open(base + ext, "rb") as f, open(obase + ext, "wb") as g:
                g.write(f.read())
            print("copied %s to %s (so KiCad checks the routed board with your rules)" % (base + ext, obase + ext), file=sys.stderr)


def save_report(jid, rpath, ipath):
    """GET /v1/jobs/{id}/report.json (and its copper picture) -> files next to the routed board; -> the report."""
    try:
        rep = call("GET", "/v1/jobs/%s/report.json" % jid)
    except SystemExit:  # an older server without reports: keep the job status instead
        rep = call("GET", "/v1/jobs/" + jid)
    with open(rpath, "w") as f:
        json.dump(rep, f, indent=1)
    saved = []
    for lay in (rep.get("image") or {}).get("layers") or []:  # one picture per copper layer
        p = "%s.%s.svg" % (ipath, lay["name"].replace(".", "_"))
        with open(p, "wb") as f:
            f.write(call("GET", lay["url"], raw=True))
        saved.append(p)
    rep["_saved_pictures"] = saved
    return rep


def download(jid, out):
    data = call("GET", "/v1/jobs/%s/result" % jid, raw=True)
    with open(out, "wb") as f:
        f.write(data)
    print("saved %s (%d bytes)" % (out, len(data)), file=sys.stderr)


def snapshot(jid, at, size=None, layers=None, issues=True, out=None):
    """POST /v1/jobs/{id}/snapshot, then save the PNG to show the user."""
    try:
        x, y = (float(v) for v in at.split(","))
    except ValueError:
        die("--at needs X,Y in mm, for example --at 93.5,69.5")
    body = {"x_mm": x, "y_mm": y, "show_issues": issues}
    if size is not None:
        body["size_mm"] = size
    if layers:
        body["layers"] = [n.strip() for n in layers.split(",") if n.strip()]
    r = call("POST", "/v1/jobs/%s/snapshot" % jid, json.dumps(body).encode(), "application/json")
    png = call("GET", r["png_url"], raw=True)
    out = out or "zap-snapshot-%s.png" % r["snapshot_id"]
    with open(out, "wb") as f:
        f.write(png)
    r["saved_to"] = os.path.abspath(out)
    print(json.dumps(r))
    print("saved %s; show it to the user. Attach it with: zap.py feedback \"...\" --job %s --snapshot %s (only if they "
          "agree); otherwise it is deleted at %s" % (out, jid, r["snapshot_id"], r.get("expires_at")), file=sys.stderr)


def feedback(text, job=None, kind=None, contact_ok=False, yes=False, snapshots=None):
    """POST /v1/feedback, only after the user confirmed this exact text (y/N on a terminal, or --yes)."""
    text = (text or "").strip()
    if not text:
        die("no feedback text given")
    if len(snapshots or []) > 3:
        die("at most 3 pictures (--snapshot) per feedback")
    print("This text will be sent to the Zap! team%s%s:\n\n%s\n" % (
        " about job %s" % job if job else "", (" with picture%s %s" % ("s" if len(snapshots) > 1 else "", ", ".join(snapshots)))
        if snapshots else "", text), file=sys.stderr)
    if not yes:
        if not sys.stdin.isatty():
            die("not sent: confirm on a terminal, or add --yes once the user agreed to send this exact text")
        print("Send it? [y/N] ", end="", file=sys.stderr, flush=True)
        if sys.stdin.readline().strip().lower() not in ("y", "yes"):
            print("not sent", file=sys.stderr)
            return
    body = {"message": text, "user_approved": True}
    if job:
        body["job_id"] = job
    if kind:
        body["kind"] = kind
    if contact_ok:
        body["contact_ok"] = True
    if snapshots:
        body["snapshot_ids"] = snapshots
    r = call("POST", "/v1/feedback", json.dumps(body).encode(), "application/json")
    print(json.dumps(r))
    print("sent; thank you", file=sys.stderr)


def main():
    p = argparse.ArgumentParser(prog="zap.py", description=__doc__.split("\n\n")[0],
                                formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__.split("\n\n", 1)[1])
    sub = p.add_subparsers(dest="cmd")
    r = sub.add_parser("run"); r.add_argument("board"); r.add_argument("-o", "--out"); r.add_argument("--consent-training", action="store_true")
    r.add_argument("--keep", action="store_true", help="leave the routed board on the server (deleted after 7 days)")
    s = sub.add_parser("submit"); s.add_argument("board"); s.add_argument("--project"); s.add_argument("--rules")
    s.add_argument("--consent-training", action="store_true")
    c = sub.add_parser("check"); c.add_argument("board"); c.add_argument("--project"); c.add_argument("--rules")
    for x in (r, s, c):
        x.add_argument("--currents", metavar="FILE", help="JSON with the currents you know: {\"nets\": {\"<net>\": {\"amps\": A}}}")
        x.add_argument("--router", choices=("stable", "next"), help="router to use (default stable; see: zap.py version)")
        x.add_argument("--keep-existing", action="store_true",
                       help="partly routed board: keep its tracks, vias and zones and route only the missing connections")
        x.add_argument("--strip-existing", action="store_true",
                       help="discard the board's existing tracks and vias and route everything (a board with old routing)")
        for name, choices, helptext in OPTION_FLAGS:
            x.add_argument("--" + name.replace("_", "-"), choices=choices, help=helptext)
        x.add_argument("--fab-floor", choices=("jlc", "board"), help="jlc (default): never go below JLC's capability; nets "
                       "that need finer copper are left unrouted and listed with the reason. board: follow your board's "
                       "own minimums, for fabs finer than JLC; JLC findings are still reported")
    r.add_argument("--no-check", action="store_true", help="skip the pre-flight check before submitting")
    for n in ("status", "wait", "delete", "cancel"):
        sub.add_parser(n).add_argument("job_id")
    d = sub.add_parser("download"); d.add_argument("job_id"); d.add_argument("-o", "--out", default="routed.kicad_pcb")
    rp = sub.add_parser("report"); rp.add_argument("job_id"); rp.add_argument("-o", "--out", default="zap-report.json")
    sub.add_parser("list"); sub.add_parser("version"); sub.add_parser("login"); sub.add_parser("logout")
    fb = sub.add_parser("feedback", help="send your opinion to the Zap! team (asks first)")
    fb.add_argument("text"); fb.add_argument("--job", metavar="JOB_ID", help="the job it is about")
    fb.add_argument("--kind", choices=("praise", "problem", "idea", "other"))
    fb.add_argument("--contact-ok", action="store_true", help="the Zap! team may reply to your account's email address")
    fb.add_argument("--yes", action="store_true", help="send without asking: only after the user agreed to this exact text")
    fb.add_argument("--snapshot", action="append", metavar="ID", help="attach a picture made with zap.py snapshot (up to 3)")
    sn = sub.add_parser("snapshot", help="a picture of one area of a routed board, to show the user")
    sn.add_argument("job_id"); sn.add_argument("--at", required=True, metavar="X,Y", help="centre in mm, as KiCad shows positions")
    sn.add_argument("--size", type=float, metavar="MM", help="side of the square, 1 to 40 mm (default 20)")
    sn.add_argument("--layers", metavar="F.Cu,B.Cu", help="copper layers to draw (default all)")
    sn.add_argument("--no-issues", action="store_true", help="do not mark the report's issues")
    sn.add_argument("-o", "--out", metavar="FILE", help="where to save the PNG")
    sub.add_parser("snapshot-delete", help="delete a picture the user does not want, at once").add_argument("snapshot_id")
    dm = sub.add_parser("demo"); dm.add_argument("--keep", action="store_true", help="leave the routed sample on the server")
    a = p.parse_args()
    OPTS.update({n: getattr(a, n) for n, _, _ in OPTION_FLAGS if getattr(a, n, None) is not None})
    if getattr(a, "strip_existing", False):
        OPTS["strip_existing"] = "true"
    if a.cmd == "demo":  # fetch the sample next to you (kept if already there), then the same as: zap.py run zap-sample.kicad_pcb
        for n in ("zap-sample.kicad_pcb", "zap-sample.kicad_pro"):
            if not os.path.exists(n):
                with open(n, "wb") as f:
                    f.write(call("GET", "/downloads/" + n, raw=True, auth=False))
                print("downloaded %s" % n, file=sys.stderr)
        a.cmd, a.board, a.out, a.consent_training, a.currents, a.router, a.no_check = "run", "zap-sample.kicad_pcb", None, False, None, None, False
        a.keep_existing = False
        a.fab_floor = None
    if a.cmd == "run":
        base = a.board[: -len(".kicad_pcb")] if a.board.endswith(".kicad_pcb") else a.board
        pro = base + ".kicad_pro" if os.path.exists(base + ".kicad_pro") else None
        dru = base + ".kicad_dru" if os.path.exists(base + ".kicad_dru") else None
        out = a.out or base + ".routed.kicad_pcb"
        if os.path.exists(out) and os.path.samefile(out, a.board):
            die("the output would overwrite your input board; choose another -o")
        if not a.no_check and not check(a.board, pro, dru, a.currents, a.router, a.keep_existing, a.fab_floor):
            die("not submitted: fix the problems above first (the check used no job quota)", 1)
        jid = submit(a.board, pro, dru, a.consent_training, a.currents, a.router, a.keep_existing, a.fab_floor)
        st = wait(jid)
        obase = out[: -len(".kicad_pcb")] if out.endswith(".kicad_pcb") else out
        rpath, ipath = obase + ".zap-report.json", obase
        if st.get("status") == "succeeded":
            rep = save_report(jid, rpath, ipath)
            REPORT_HINT[0] = rpath
        rc = report(st)
        if rc == 0:
            download(jid, out)
            copy_project(base, out)
            po = rep.get("pours") if isinstance(rep.get("pours"), dict) else {}
            if po.get("replaced") or po.get("made"):  # the board's pours that Zap! replaced, and the ones it made
                pt = lambda xs: "; ".join("%s on %s" % (x.get("net") or "no net", ", ".join(x.get("layers") or [])) for x in xs) or "none"
                print("Copper pours: yours replaced: %s. Zap! made: %s." % (pt(po.get("replaced") or []), pt(po.get("made") or [])))
            pics = rep.get("_saved_pictures") or []
            print("saved the full report (every finding with its position and what to do) to %s%s" % (
                rpath, (" and pictures of the copper layers: %s" % ", ".join(pics)) if pics else ""), file=sys.stderr)
            print("the same report as a web page (needs your key): %s" % st.get("report_url", ""), file=sys.stderr)
            if a.keep:
                print("the routed board stays on the server for 7 days (zap.py delete %s removes it)" % jid, file=sys.stderr)
            else:
                call("DELETE", "/v1/jobs/" + jid)
                print("deleted the job's files on the server (--keep leaves them for 7 days)", file=sys.stderr)
        sys.exit(rc)
    elif a.cmd == "check":
        base = a.board[: -len(".kicad_pcb")] if a.board.endswith(".kicad_pcb") else a.board
        pro = a.project or (base + ".kicad_pro" if os.path.exists(base + ".kicad_pro") else None)
        dru = a.rules or (base + ".kicad_dru" if os.path.exists(base + ".kicad_dru") else None)
        sys.exit(0 if check(a.board, pro, dru, a.currents, a.router, a.keep_existing, a.fab_floor) else 1)
    elif a.cmd == "submit":
        print(submit(a.board, a.project, a.rules, a.consent_training, a.currents, a.router, a.keep_existing, a.fab_floor))
    elif a.cmd == "status":
        print(json.dumps(call("GET", "/v1/jobs/" + a.job_id), indent=2))
    elif a.cmd == "wait":
        sys.exit(report(wait(a.job_id)))
    elif a.cmd == "download":
        download(a.job_id, a.out)
    elif a.cmd == "report":
        ipath = a.out[: -len(".json")] if a.out.endswith(".json") else a.out
        rep = save_report(a.job_id, a.out, ipath)
        for s in rep.get("sections") or []:
            print("%-48s %5s" % (s.get("title"), s.get("total")))
        print("saved %s%s" % (a.out, "".join(", " + p for p in rep.get("_saved_pictures") or [])), file=sys.stderr)
    elif a.cmd in ("delete", "cancel"):
        print(json.dumps(call("DELETE", "/v1/jobs/" + a.job_id)))
    elif a.cmd == "list":
        for j in call("GET", "/v1/jobs").get("jobs", []):
            print("%s  %-9s  %s" % (j["id"], j["status"], j["created_at"]))
    elif a.cmd == "version":
        print(json.dumps(call("GET", "/v1/version", auth=False)))  # no key needed
    elif a.cmd == "feedback":
        feedback(a.text, a.job, a.kind, a.contact_ok, a.yes, a.snapshot)
    elif a.cmd == "snapshot":
        snapshot(a.job_id, a.at, a.size, a.layers, not a.no_issues, a.out)
    elif a.cmd == "snapshot-delete":
        print(json.dumps(call("DELETE", "/v1/snapshots/%s.png" % a.snapshot_id)))
    elif a.cmd == "login":
        login()
    elif a.cmd == "logout":
        logout()
    else:
        p.print_help()
        sys.exit(2)


if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        sys.exit(130)
