#!/usr/bin/env python3
"""Post, list and close Forgejo issues in the convention these projects use.

Exists because every one of the rules below was learned by getting it wrong
once. The script is the enforcement; the skill is the explanation.

  create   file an issue, refusing one that has no `Verify:` line or that
           duplicates an existing title
  batch    file several from a JSON file, skipping ones already there
  list     open issues, grouped by milestone
  close    close with an evidence comment — "Done" is not a close
  labels   / milestones — what exists, with the ids the API wants

Run any subcommand with --dry-run to see the payload and change nothing.
"""

from __future__ import annotations

import argparse
import json
import os
import re
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request

# Where the credentials live, when they are not already in the environment.
#
# Keep this file OUTSIDE the repository. A token in a file the repo can see is
# a token one `git add -A` away from being published — the same argument
# `release.sh` makes about its registry env.
ENV_FILE = os.environ.get("FORGEJO_ENV_FILE", os.path.expanduser("~/.forgejo.env"))

# Cloudflare fronts the Forgejo instance and 1010-blocks Python's default
# urllib User-Agent (browser_signature_banned). Every call fails with a
# Cloudflare HTML body that looks nothing like a Forgejo error. Do not remove.
USER_AGENT = "curl/8.5.0"

SEVERITY = ("P0", "P1", "P2", "release-blocker")


# ── plumbing ─────────────────────────────────────────────────────────────────


def die(msg: str, code: int = 1):
    print(f"error: {msg}", file=sys.stderr)
    sys.exit(code)


def load_env() -> tuple[str, str]:
    """Read host + token. Never print the token; it is not registry-scoped —
    it carries admin/push/pull over the whole API."""
    host = os.environ.get("FORGEJO_REGISTRY")
    token = os.environ.get("FORGEJO_REGISTRY_TOKEN")
    if not (host and token):
        try:
            with open(ENV_FILE, encoding="utf-8") as fh:
                for line in fh:
                    line = line.strip()
                    if not line or line.startswith("#") or "=" not in line:
                        continue
                    k, v = line.split("=", 1)
                    v = v.strip().strip("'\"")
                    if k.strip() == "FORGEJO_REGISTRY" and not host:
                        host = v
                    elif k.strip() == "FORGEJO_REGISTRY_TOKEN" and not token:
                        token = v
        except FileNotFoundError:
            pass
    if not (host and token):
        die(
            f"FORGEJO_REGISTRY / FORGEJO_REGISTRY_TOKEN not in the environment "
            f"or {ENV_FILE}.\n"
            f"Set FORGEJO_ENV_FILE to point somewhere else, or export both."
        )
    return host, token


def detect_repo() -> str | None:
    """owner/name from the git remote of the current directory."""
    try:
        url = subprocess.run(
            ["git", "remote", "get-url", "origin"],
            capture_output=True, text=True, check=True,
        ).stdout.strip()
    except (subprocess.CalledProcessError, FileNotFoundError):
        return None
    m = re.search(r"[:/]([^/:]+)/([^/]+?)(?:\.git)?$", url)
    return f"{m.group(1)}/{m.group(2)}" if m else None


class Api:
    def __init__(self, host: str, token: str, repo: str, dry_run: bool = False):
        self.base = f"https://{host}/api/v1"
        self.token = token
        self.repo = repo
        self.dry_run = dry_run

    def _call(self, method: str, path: str, body=None, params=None):
        url = f"{self.base}{path}"
        if params:
            url += "?" + urllib.parse.urlencode(params)
        data = json.dumps(body).encode() if body is not None else None
        req = urllib.request.Request(url, data=data, method=method)
        req.add_header("Authorization", f"token {self.token}")
        req.add_header("User-Agent", USER_AGENT)
        if data:
            req.add_header("Content-Type", "application/json")
        try:
            with urllib.request.urlopen(req, timeout=30) as resp:
                raw = resp.read().decode()
                return json.loads(raw) if raw.strip() else None
        except urllib.error.HTTPError as e:
            detail = e.read().decode()[:400]
            if "<html" in detail.lower():
                detail = ("Cloudflare returned HTML, not a Forgejo error — the "
                          "User-Agent was probably rejected.")
            die(f"{method} {path} → HTTP {e.code}: {detail}")
        except urllib.error.URLError as e:
            die(f"{method} {path} → {e.reason}")

    def get(self, path, params=None):
        return self._call("GET", path, params=params)

    def post(self, path, body):
        if self.dry_run:
            print(f"[dry-run] POST {path}\n{json.dumps(body, indent=2)}")
            return {"number": "?", "title": body.get("title", ""), "labels": [],
                    "milestone": None, "_dry": True}
        return self._call("POST", path, body=body)

    def patch(self, path, body):
        if self.dry_run:
            print(f"[dry-run] PATCH {path}\n{json.dumps(body, indent=2)}")
            return {"_dry": True}
        return self._call("PATCH", path, body=body)

    # ── repo helpers ─────────────────────────────────────────────────────────

    def issues(self, state="open", limit=50):
        """type=issues matters: without it Forgejo returns pull requests too,
        and every count drawn from the result is wrong.

        Stops on an EMPTY page, not a short one. The server caps a page at 50
        however large a `limit` you ask for, so the natural `len(batch) < limit`
        test with limit=100 is true on the very first page and the loop exits
        having read 50 of 72 open issues — silently, with a plausible-looking
        result. That truncation feeds duplicate detection, which is the one thing
        this list is for, so a short read re-files work that already exists.
        Costs one extra request; cannot truncate.
        """
        out, page = [], 1
        while True:
            batch = self.get(
                f"/repos/{self.repo}/issues",
                {"type": "issues", "state": state, "limit": limit, "page": page},
            ) or []
            if not batch:
                return out
            out.extend(batch)
            page += 1

    def labels(self) -> dict[str, int]:
        return {l["name"]: l["id"]
                for l in (self.get(f"/repos/{self.repo}/labels",
                                   {"limit": 100}) or [])}

    def milestones(self) -> dict[str, int]:
        out = {}
        for state in ("open", "closed"):
            for m in (self.get(f"/repos/{self.repo}/milestones",
                               {"state": state, "limit": 100}) or []):
                out[m["title"]] = m["id"]
        return out

    def milestones_full(self, state="all") -> list[dict]:
        states = ("open", "closed") if state == "all" else (state,)
        out = []
        for s in states:
            out.extend(self.get(f"/repos/{self.repo}/milestones",
                                {"state": s, "limit": 100}) or [])
        return out

    def current_milestone(self) -> dict | None:
        """The first OPEN milestone that still has open issues.

        'First' is the order Forgejo returns, which is creation order — NOT a
        numeric sort by title. A milestone created last therefore cannot become
        current while an earlier one still has open issues, which is the lever
        for filing work that must not disturb the card.
        """
        openms = self.milestones_full("open")
        for m in openms:
            if m["open_issues"] > 0:
                return m
        return openms[0] if openms else None


# ── convention checks ────────────────────────────────────────────────────────


def check_verify_line(body: str, title: str) -> str:
    """Every issue ends with a Verify: line stating the acceptance check.
    A deliverable nobody can re-test cannot be closed, so it must not be filed."""
    lines = [l for l in body.strip().splitlines() if l.strip()]
    if not any(l.strip().startswith("Verify:") for l in lines):
        die(f'"{title}" has no `Verify:` line. State the acceptance check — a\n'
            "       finding that cannot be re-tested cannot be closed.")
    if not lines[-1].strip().startswith("Verify:"):
        print(f'warning: "{title}" has a Verify: line but it is not last',
              file=sys.stderr)
    return body


def resolve_labels(names, available: dict[str, int]) -> list[int]:
    """Names → ids, failing loudly. A typo'd severity label is reported by the
    Command Center as *not adopted*, not as zero defects — silently dropping it
    would hide the whole repo's defect count."""
    ids = []
    for n in names:
        # A severity label that differs only in case is the dangerous one: it
        # looks right in the UI and is invisible to a query by exact name.
        if n not in available:
            near = [a for a in available if a.lower() == n.lower()]
            if near:
                die(f"label {n!r} does not exist, but {near[0]!r} does. "
                    "Names are matched exactly — use that one.")
            die(f"label {n!r} does not exist. "
                f"Available: {', '.join(sorted(available)) or '(none)'}")
        if n.upper() in {s.upper() for s in SEVERITY} and n not in SEVERITY:
            die(f"severity label must be spelled exactly one of {SEVERITY}, "
                f"got {n!r}")
        ids.append(available[n])
    return ids


def find_duplicate(title: str, existing: list) -> dict | None:
    t = title.strip().lower()
    for i in existing:
        if i["title"].strip().lower() == t:
            return i
    return None


def validate_milestone_title(title: str) -> None:
    """Refuse a comma. `milestones=` takes a comma-separated list of names, so a
    title containing one splits into names that do not exist, the filter DROPS,
    and the query returns the newest open issue in the WHOLE repository — which
    the card then presents as that milestone's next action. Percent-encoding does
    not save it; Forgejo decodes before splitting. Measured on null/fruit-fall,
    where `0.3.7 Logo, Icons & Branding` was renamed for exactly this reason."""
    if "," in title:
        die(f"milestone title contains a comma: {title!r}\n"
            "  That silently breaks the `milestones=` filter and makes the project\n"
            "  card show the wrong next action. Rename it without the comma.")
    first = title.strip().split()[0] if title.strip() else ""
    if first and first.replace("v", "", 1).replace(".", "").isdigit():
        rest = title.strip()[len(first):].strip()
        if rest:
            print(f"  note: the dashboard phase will show just {first!r} — a title "
                  f"starting with a\n        version token has the rest dropped. Put a "
                  f"word first to keep it whole.")


_warned_current: set[str] = set()


def _warn_if_current(api: Api, milestone: str) -> None:
    """The dashboard's next action is the NEWEST open issue in the current
    milestone — not the highest priority; priority labels have no influence at
    all. Filing a routine item into the milestone the team is working on
    therefore replaces what the card shows."""
    if milestone in _warned_current:
        return
    _warned_current.add(milestone)
    cur = api.current_milestone()
    if cur and cur["title"] == milestone:
        print(f"  warning: {milestone!r} is the CURRENT milestone. The next action on "
              "the project\n           card is the NEWEST open issue in it, ignoring "
              "priority — so this will\n           replace whatever is shown there now. "
              "To avoid that, file into a\n           milestone created later; order is "
              "creation order, not title order.")


# ── commands ─────────────────────────────────────────────────────────────────


def cmd_check(api: Api, args):
    """The probes that prove the card is not lying. Run before and after filing.

    Everything here exists because Forgejo's filters FAIL OPEN: given a value
    they cannot match they ignore the filter and return the unfiltered list, with
    no error. A query returning plausible results is not evidence it filtered.
    """
    print(f"tracker health — {api.repo}\n")
    problems = 0

    labels = api.labels()
    missing = [n for n in SEVERITY if n not in labels]
    if missing:
        problems += 1
        print(f"  FAIL  severity labels missing: {', '.join(missing)}")
        print("        a query for a label that does not exist matches EVERYTHING")
    else:
        print("  ok    all four severity labels defined")

    openi = api.issues(state="open")
    blockers = [i for i in openi
                if any(l["name"] == "release-blocker" for l in i["labels"])]
    if blockers:
        print(f"  WARN  {len(blockers)} open release-blocker — takes over the whole card:")
        for i in blockers[:5]:
            print(f"          #{i['number']} {i['title'][:58]}")
    else:
        print("  ok    no release-blocker hijacking the card")

    orphans = [i for i in openi if not i.get("milestone")]
    if orphans:
        problems += 1
        print(f"  FAIL  {len(orphans)} open issue(s) with no milestone — invisible on the card:")
        for i in orphans[:5]:
            print(f"          #{i['number']} {i['title'][:58]}")
    else:
        print(f"  ok    no orphan issues ({len(openi)} open)")

    openms = api.milestones_full("open")
    commas = [m["title"] for m in openms if "," in m["title"]]
    if commas:
        problems += 1
        print(f"  FAIL  comma in milestone title — breaks the filter: {commas}")
    else:
        print("  ok    no comma in any open milestone title")

    empty = [m["title"] for m in openms if m["open_issues"] == 0]
    if empty:
        print(f"  WARN  {len(empty)} open milestone(s) with nothing in them — the card")
        print(f"        will read 'Close milestone …': {empty[:3]}")
    else:
        print("  ok    no empty open milestones")

    cur = api.current_milestone()
    if cur:
        first = cur["title"].strip().split()[0]
        tok = first.replace("v", "", 1).replace(".", "")
        print(f"\n  current milestone : {cur['title']!r}")
        print(f"  phase shown       : {(first if tok.isdigit() else cur['title'].strip())!r}")
        nxt = api.get(f"/repos/{api.repo}/issues",
                      {"type": "issues", "state": "open", "limit": 1,
                       "milestones": cur["title"]}) or []
        if nxt:
            i = nxt[0]
            in_ms = (i.get("milestone") or {}).get("title")
            if in_ms != cur["title"]:
                problems += 1
                print(f"  next action       : {i['title']!r}")
                print("                      ^ FILTER DROPPED — that issue is in "
                      f"{in_ms!r}.\n                        The card is showing a "
                      "wrong next action.")
            else:
                print(f"  next action       : {i['title']!r}")
    else:
        print("\n  no open milestones — the phase would be 'release'")

    print(f"\n{'PROBLEMS: ' + str(problems) if problems else 'All checks passed.'}")
    return 1 if problems else 0


def cmd_milestone(api: Api, args):
    """Create a milestone. No due date is ever set — a due date means a
    commitment, and an invented one is worse than none."""
    title = args.title.strip()
    validate_milestone_title(title)
    if title in api.milestones():
        print(f"exists  {title!r} — not creating a second one")
        return
    desc = args.description or ""
    if args.description_file:
        desc = open(args.description_file, encoding="utf-8").read()
    if not desc.strip():
        print("  note: no description. It should say what the batch is for and how "
              "anybody\n        will know it landed.")
    m = api.post(f"/repos/{api.repo}/milestones",
                 {"title": title, "description": desc})
    print(f"created milestone {m.get('title', title)!r} (id {m.get('id', '?')})")


def create_one(api: Api, spec: dict, labels_map, ms_map, existing,
               allow_dup=False) -> dict | None:
    title = spec["title"].strip()
    body = check_verify_line(spec.get("body", ""), title)

    dup = find_duplicate(title, existing)
    if dup and not allow_dup:
        print(f"skip  #{dup['number']} already titled {title!r} "
              f"({dup['state']}) — creates are not idempotent, so this is a skip "
              f"not an error")
        return None

    payload = {"title": title, "body": body}
    if spec.get("labels"):
        payload["labels"] = resolve_labels(spec["labels"], labels_map)
        if "release-blocker" in spec["labels"]:
            print("  warning: release-blocker does NOT filter by milestone, so one "
                  "stray label\n           takes over the phase and next action for "
                  "the entire project. It means\n           *nothing else can "
                  "proceed* — it is not a synonym for important; that is P1.")
    if spec.get("milestone"):
        m = spec["milestone"]
        if m not in ms_map:
            die(f"milestone {m!r} does not exist. Available: "
                f"{', '.join(sorted(ms_map)) or '(none)'}")
        payload["milestone"] = ms_map[m]
        _warn_if_current(api, m)
    else:
        print(f"  warning: {title[:48]!r} has no milestone — it can never become the "
              "next action\n           and never appears anywhere on the project card.")

    d = api.post(f"/repos/{api.repo}/issues", payload)
    names = ",".join(l["name"] for l in d.get("labels", []))
    mile = (d.get("milestone") or {}).get("title", "—")
    print(f"filed #{d['number']}  [{names}]  {d['title']}"
          + (f"  → {mile}" if mile != "—" else ""))
    return d


def cmd_create(api: Api, args):
    labels_map, ms_map = api.labels(), api.milestones()
    existing = api.issues(state="all")
    body = args.body
    if args.body_file:
        body = open(args.body_file, encoding="utf-8").read()
    create_one(api, {"title": args.title, "body": body or "",
                     "labels": args.label, "milestone": args.milestone},
               labels_map, ms_map, existing, args.allow_duplicate)


def cmd_batch(api: Api, args):
    specs = json.load(open(args.file, encoding="utf-8"))
    if isinstance(specs, dict):
        specs = specs.get("issues", [])
    if not isinstance(specs, list):
        die("batch file must be a JSON list, or an object with an 'issues' list")
    labels_map, ms_map = api.labels(), api.milestones()
    existing = api.issues(state="all")
    filed = 0
    for spec in specs:
        d = create_one(api, spec, labels_map, ms_map, existing,
                       args.allow_duplicate)
        if d:
            filed += 1
            existing.append({"number": d["number"], "title": d["title"],
                             "state": "open"})
    print(f"\n{filed} filed, {len(specs) - filed} skipped")


def cmd_list(api: Api, args):
    issues = api.issues(state=args.state)
    groups: dict[str, list] = {}
    for i in issues:
        groups.setdefault((i.get("milestone") or {}).get("title",
                                                         "(no milestone)"),
                          []).append(i)
    for m in sorted(groups):
        print(f"\n### {m}")
        for i in sorted(groups[m], key=lambda x: x["number"]):
            names = ",".join(l["name"] for l in i["labels"])
            print(f"  #{i['number']:<4} [{names}] {i['title']}")
    print(f"\n{len(issues)} {args.state} issue(s) — pull requests excluded")


def cmd_close(api: Api, args):
    """Close with the evidence that proves it: a path, a symbol, a test name,
    or the command that shows it. 'Done' is not a close."""
    ev = args.evidence.strip()
    if len(ev) < 15:
        die("evidence too thin. Give a path, a symbol, a test name, or the "
            "command that proves it — 'Done' is not a close.")
    api.post(f"/repos/{api.repo}/issues/{args.number}/comments", {"body": ev})
    api.patch(f"/repos/{api.repo}/issues/{args.number}", {"state": "closed"})
    print(f"closed #{args.number} with evidence")
    print("note: prefer `closes #N` in the commit that does the work — the "
          "tracker then records who and when from the thing that happened.")


def cmd_labels(api: Api, args):
    labels = api.labels()
    print("severity (exact names — queried by name by the Command Center):")
    for s in SEVERITY:
        print(f"  {'✓' if s in labels else '✗ MISSING'}  {s}"
              + (f"  id={labels[s]}" if s in labels else ""))
    print("\nother:")
    for n, i in sorted(labels.items()):
        if n not in SEVERITY:
            print(f"  {n}  id={i}")


def cmd_milestones(api: Api, args):
    for state in ("open", "closed"):
        ms = api.get(f"/repos/{api.repo}/milestones",
                     {"state": state, "limit": 100}) or []
        if ms:
            print(f"\n{state}:")
            for m in ms:
                print(f"  [{m['id']}] {m['title']} — open {m['open_issues']} / "
                      f"closed {m['closed_issues']}")


def main():
    # --repo and --dry-run are accepted on BOTH sides of the subcommand. Putting
    # them only on the top-level parser means `… create "T" --dry-run` — the
    # natural way to type it, and the position that matters most — dies with an
    # argparse usage error instead of previewing. A safety flag that is easy to
    # put in the wrong place is a safety flag that gets left off.
    # default=SUPPRESS is load-bearing, not tidiness. With a normal default the
    # subparser re-defines the same dest and argparse writes its default over
    # whatever the top-level parser already parsed — so `--repo X create …`
    # silently became repo=None and `--dry-run create …` silently became False.
    # A --dry-run that quietly turns itself off is the worst possible bug in a
    # tool whose job is writing to a live tracker. SUPPRESS leaves the attribute
    # unset unless it was actually given, so neither position clobbers the other.
    common = argparse.ArgumentParser(add_help=False)
    common.add_argument("--repo", default=argparse.SUPPRESS,
                        help="owner/name (default: from git remote)")
    common.add_argument("--dry-run", action="store_true",
                        default=argparse.SUPPRESS,
                        help="print payloads, change nothing")

    p = argparse.ArgumentParser(
        parents=[common],
        description="File Forgejo issues in the tracker convention.",
        epilog="Every open issue is a denominator. Do not pad the tracker.")
    sub = p.add_subparsers(dest="cmd", required=True)

    c = sub.add_parser("create", parents=[common], help="file one issue")
    c.add_argument("title")
    c.add_argument("--body", help="issue body; must end with a Verify: line")
    c.add_argument("--body-file", help="read the body from a file")
    c.add_argument("--label", action="append", default=[],
                   help="label name, repeatable")
    c.add_argument("--milestone", help="milestone title")
    c.add_argument("--allow-duplicate", action="store_true")
    c.set_defaults(fn=cmd_create)

    b = sub.add_parser("batch", parents=[common], help="file several from a JSON file")
    b.add_argument("file")
    b.add_argument("--allow-duplicate", action="store_true")
    b.set_defaults(fn=cmd_batch)

    l = sub.add_parser("list", parents=[common], help="open issues by milestone")
    l.add_argument("--state", default="open",
                   choices=["open", "closed", "all"])
    l.set_defaults(fn=cmd_list)

    x = sub.add_parser("close", parents=[common], help="close with an evidence comment")
    x.add_argument("number", type=int)
    x.add_argument("evidence", help="what was checked and where")
    x.set_defaults(fn=cmd_close)

    m = sub.add_parser("milestone", parents=[common], help="create a milestone (batch)")
    m.add_argument("title")
    m.add_argument("--description")
    m.add_argument("--description-file")
    m.set_defaults(fn=cmd_milestone)

    sub.add_parser("check", parents=[common], help="health probes — run before AND after filing"
                   ).set_defaults(fn=cmd_check)
    sub.add_parser("labels", parents=[common], help="labels and their ids").set_defaults(
        fn=cmd_labels)
    sub.add_parser("milestones", parents=[common], help="milestones and their ids").set_defaults(
        fn=cmd_milestones)

    args = p.parse_args()
    # getattr, because SUPPRESS means the attribute may legitimately be absent.
    repo = getattr(args, "repo", None) or detect_repo()
    if not repo:
        die("could not detect owner/name from the git remote — pass --repo")
    host, token = load_env()
    rc = args.fn(Api(host, token, repo, getattr(args, "dry_run", False)), args)
    # `check` returns a count so it can gate a script; the rest return None.
    sys.exit(rc or 0)


if __name__ == "__main__":
    main()
