#!/usr/bin/env python3
"""Apply and verify branch protection on `main` -- Phase 2 of GITEA-SETUP.md.

    tools/gitea-protect --dry-run   print the exact rule it would send; no token
    tools/gitea-protect             create or update the rule (idempotent)
    tools/gitea-protect --verify    assert the live rule still holds; exit 1 if not

Six settings, of which two were missing from the first draft of the runbook and
both of those are the ones that close the gate. That is the shape of thing that
gets mis-clicked in a web form at 1am, so it goes through the API instead: what
was applied is reviewable in a diff, and `--verify` re-checks it every day
rather than once.

── Why each field is what it is ─────────────────────────────────────────────

Read out of Gitea's own models/git/protected_branch.go, not inferred:

  EnableMergeWhitelist=false      merging falls back on "whether the user has
                                  write permission" -- and both agents have
                                  Write. This is THE gate; without it every
                                  other row is decoration.
  EnableApprovalsWhitelist=false  "anyone with write access is considered
                                  official reviewer". Gitea refuses to let an
                                  author approve their OWN pull request and does
                                  nothing about sylph-decoder approving
                                  sylph-port's, so without this the two agents
                                  satisfy the human gate between themselves.
  enable_push=false               blocks PUSHES to main. It has no effect on
                                  merging whatsoever, which is the assumption
                                  that made the first version of this phase read
                                  as protection while being none.

🔴 block_admin_merge_override stays FALSE, deliberately. Turning it on locks the
human out of their own work: approvals are whitelisted to `fabi`, Gitea will not
let `fabi` approve a `fabi` PR, so a human-authored PR could never reach one
approval and -- with the override blocked -- could never be merged at all. The
admin override is what keeps that door open, and it is not a hole in the agent
gate because the agents are Write, not Admin. That is what "Write, not Admin" in
Phase 1.2 is buying, and this is where it gets spent.

── Where the token comes from ───────────────────────────────────────────────

Branch protection is a REPOSITORY-scope endpoint, so `~/.sylph-gitea-api-token`
(write:issue, read:repository) cannot do it -- that token exists precisely so the
issue work needs no repository rights.

The credential that CAN is one you already have: `~/.sylph-git-credentials`, on
the agent box, scoped write:repository. Reusing it means this needs no new
credential and no second machine holding push rights, which is the whole reason
to run this here rather than on the Pi.
"""

import argparse, json, os, sys, urllib.error, urllib.parse, urllib.request

HOST  = os.environ.get("SYLPH_GITEA_HOST", "git.mc02.dev")
REPO  = os.environ.get("SYLPH_GITEA_REPO", "fabi/Sylpheed")
HUMAN = os.environ.get("SYLPH_GITEA_HUMAN", "fabi")
BRANCH = os.environ.get("SYLPH_GITEA_BRANCH", "main")
AGENTS = os.environ.get("SYLPH_GITEA_AGENTS", "sylph-decoder,sylph-port").split(",")

RULE = {
    "rule_name":                    BRANCH,
    "enable_push":                  False,
    "required_approvals":           1,
    "dismiss_stale_approvals":      True,
    "block_on_rejected_reviews":    True,
    "enable_merge_whitelist":       True,
    "merge_whitelist_usernames":    [HUMAN],
    "enable_approvals_whitelist":   True,
    "approvals_whitelist_username": [HUMAN],
    "block_admin_merge_override":   False,   # see the module docstring
}

# What --verify asserts. Kept separate from RULE because a check that is written
# as "whatever we sent" cannot fail: it would re-derive the expectation from the
# thing under test. These are stated independently, on purpose.
EXPECTED = {
    "enable_push":                  (lambda v: v is False,        "pushes to the branch are blocked"),
    "required_approvals":           (lambda v: v >= 1,            "at least one approval required"),
    "dismiss_stale_approvals":      (lambda v: v is True,         "stale approvals dismissed"),
    "block_on_rejected_reviews":    (lambda v: v is True,         "rejected reviews block the merge"),
    "enable_merge_whitelist":       (lambda v: v is True,         "MERGE WHITELIST ON -- the gate"),
    "merge_whitelist_usernames":    (lambda v: v == [HUMAN],      f"only {HUMAN} may merge"),
    "enable_approvals_whitelist":   (lambda v: v is True,         "APPROVALS WHITELIST ON"),
    "approvals_whitelist_username": (lambda v: v == [HUMAN],      f"only {HUMAN}'s approval counts"),
}


def token():
    """The first credential that can plausibly do this, and a clear no otherwise."""
    explicit = os.environ.get("SYLPH_GITEA_ADMIN_TOKEN")
    if explicit and os.path.exists(explicit):
        return open(explicit).read().strip(), explicit

    cred = os.path.expanduser(os.environ.get("SYLPH_GIT_CREDENTIALS",
                                             "~/.sylph-git-credentials"))
    if os.path.exists(cred):
        for line in open(cred):
            line = line.strip()
            if HOST in line and "@" in line:
                parsed = urllib.parse.urlsplit(line)
                if parsed.password:
                    return urllib.parse.unquote(parsed.password), cred

    sys.exit(
        f"gitea-protect: no repository-scoped credential found.\n\n"
        f"  Looked in $SYLPH_GITEA_ADMIN_TOKEN and {cred}.\n\n"
        f"  NOT ~/.sylph-gitea-api-token: that one is write:issue + read:repository\n"
        f"  by design, and every branch-protection endpoint refuses it. Run this on\n"
        f"  the machine that already holds the push credential rather than issuing a\n"
        f"  repository-scoped token to a second box.\n"
    )


def api(method, path, tok, body=None):
    url = f"https://{HOST}/api/v1/repos/{REPO}{path}"
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(url, data=data, method=method, headers={
        "Authorization": f"token {tok}",
        "Content-Type": "application/json",
        "Accept": "application/json",
    })
    try:
        with urllib.request.urlopen(req, timeout=30) as r:
            raw = r.read()
            return r.status, (json.loads(raw) if raw else None)
    except urllib.error.HTTPError as e:
        raw = e.read().decode(errors="replace")
        if e.code in (401, 403) and "scope" in raw:
            sys.exit(f"🔴 that credential lacks repository scope:\n   {raw.strip()}")
        return e.code, raw
    except urllib.error.URLError as e:
        sys.exit(f"🔴 no response from {url} -- host or network: {e.reason}")


def apply_rule(tok):
    status, existing = api("GET", f"/branch_protections/{BRANCH}", tok)
    if status == 200:
        status, out = api("PATCH", f"/branch_protections/{BRANCH}", tok,
                          {k: v for k, v in RULE.items() if k != "rule_name"})
        verb = "updated"
    elif status == 404:
        status, out = api("POST", "/branch_protections", tok, RULE)
        verb = "created"
    else:
        sys.exit(f"🔴 unexpected {status} reading the existing rule: {existing}")

    if status not in (200, 201):
        sys.exit(f"🔴 {verb.rstrip('d')} failed ({status}): {out}")
    print(f"  {verb} the protection rule on {BRANCH}")
    return out


def verify(tok):
    """Assert, one line per property, and say which one failed rather than 'no'."""
    ok = True
    status, rule = api("GET", f"/branch_protections/{BRANCH}", tok)
    if status == 404:
        print(f"🔴 NO PROTECTION RULE on {BRANCH}. Anyone with Write can push to it.")
        return False
    if status != 200:
        sys.exit(f"🔴 could not read the rule ({status}): {rule}")

    for key, (pred, why) in EXPECTED.items():
        got = rule.get(key)
        good = pred(got)
        ok &= good
        print(f"  {'✅' if good else '🔴'} {why:<42} {key}={got!r}")

    # The other half of what a daily check is for: Phase 1.2's "Write, not
    # Admin". An agent promoted to Admin could edit the rule above and then
    # merge, so a green rule proves nothing on its own.
    for agent in AGENTS:
        status, perm = api("GET", f"/collaborators/{agent}/permission", tok)
        if status == 404:
            print(f"  ⚪ {agent:<42} not a collaborator (yet)")
            continue
        if status != 200:
            print(f"  🔴 {agent:<42} permission unreadable ({status})")
            ok = False
            continue
        role = perm.get("permission")
        good = role == "write"
        ok &= good
        print(f"  {'✅' if good else '🔴'} {agent + ' is Write, not Admin':<42} permission={role!r}")

    return ok


def main():
    p = argparse.ArgumentParser(add_help=True, description=__doc__.split("\n")[0])
    g = p.add_mutually_exclusive_group()
    g.add_argument("--dry-run", action="store_true",
                   help="print the rule that would be sent; needs no credential")
    g.add_argument("--verify", action="store_true",
                   help="check the live rule against what this file asserts")
    a = p.parse_args()

    print(f"repo   https://{HOST}/{REPO}")
    print(f"branch {BRANCH}\n")

    if a.dry_run:
        print(f"would PUT this rule (no credential read, nothing sent):\n")
        print(json.dumps(RULE, indent=2))
        print(f"\ndry run -- nothing was changed.")
        return 0

    tok, where = token()
    print(f"credential from {where}\n")

    if a.verify:
        ok = verify(tok)
        print()
        print("protection holds." if ok else
              "🔴 PROTECTION DOES NOT HOLD -- stop the agents until it does.")
        return 0 if ok else 1

    apply_rule(tok)
    print()
    ok = verify(tok)
    print()
    if ok:
        print("Now run the check that a settings page cannot give you, from")
        print("GITEA-SETUP.md Phase 2 -- especially step 4: approve the throwaway")
        print("PR yourself, then confirm sylph-port STILL has no merge button.")
        print("Steps 1-3 pass on an instance with no rule at all.")
    return 0 if ok else 1


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