#!/usr/bin/env python3
#
# gitctl: thin client for managing repos on a gitolite3 + cgit server.
# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""gitctl: thin client for managing repos on a gitolite3 + cgit server."""
import os, sys, re, shlex, tomllib, argparse, subprocess, time

DEFAULTS = {
    "admin_clone_path": "~/git/gitolite-admin",
    "helper_ssh_alias": "git_helper",
    "push_ssh_alias": "git_push",
    "default_owner": "by Your Name",
    "admin_branch": "master",
    "rw_user": "youruser",
    "remote_template": "{push_ssh_alias}:{name}",
}
CONFIG_PATH = os.path.expanduser("~/.config/gitctl/config.toml")


def load_config(path=CONFIG_PATH):
    cfg = dict(DEFAULTS)
    if os.path.exists(path):
        with open(path, "rb") as f:
            cfg.update(tomllib.load(f))
    cfg["admin_clone_path"] = os.path.expanduser(cfg["admin_clone_path"])
    if cfg["helper_ssh_alias"] == cfg["push_ssh_alias"]:
        sys.exit("error: helper_ssh_alias and push_ssh_alias must differ")
    return cfg


def remote_url(cfg, name):
    return cfg["remote_template"].format(push_ssh_alias=cfg["push_ssh_alias"], name=name)


def helper_command(args):
    return " ".join(shlex.quote(a) for a in args)


def _server_prefixed(text):
    """Prefix each non-empty line with 'server: ' so passthrough output from the
    helper (and the sync script it runs) reads as remote output, not interleaved
    local noise."""
    return "".join(("server: " + ln if ln.strip() else ln) + "\n"
                   for ln in text.splitlines())


def call_helper(cfg, args, check=True, quiet=False):
    """Run a helper verb. Returns CompletedProcess. ssh sends the verb string;
    the restricted key forces gitctl-helper, which reads SSH_ORIGINAL_COMMAND.
    quiet suppresses the stdout passthrough so the caller can format it."""
    cmd = ["ssh", cfg["helper_ssh_alias"], helper_command(args)]
    r = subprocess.run(cmd, text=True, capture_output=True)
    if r.stdout and not quiet:
        sys.stdout.write(_server_prefixed(r.stdout))
    if r.stderr:
        sys.stderr.write(_server_prefixed(r.stderr))
    if check and r.returncode != 0:
        sys.exit(f"error: helper '{args[0]}' failed (exit {r.returncode})")
    return r


def git(cfg, *args, check=True, capture=False):
    r = subprocess.run(["git", "-C", cfg["admin_clone_path"], *args],
                       text=True, capture_output=capture)
    if check and r.returncode != 0:
        sys.exit(f"error: git {' '.join(args)} failed")
    return r


def stanza_exists(conf, name):
    return re.search(rf"(?m)^repo[ \t]+{re.escape(name)}[ \t]*$", conf) is not None


def add_stanza(conf, name, rw_user):
    if stanza_exists(conf, name):
        return conf
    sep = "" if conf.endswith("\n") or conf == "" else "\n"
    return conf + sep + f"\nrepo {name}\n    RW+     =   {rw_user}\n"


def remove_stanza(conf, name):
    """Inverse of add_stanza: drop the `repo <name>` block (its repo line plus
    the indented rule lines under it) and the blank line that preceded it.
    Idempotent: if the stanza is absent, return conf unchanged."""
    lines = conf.splitlines(keepends=True)
    start = None
    for i, line in enumerate(lines):
        if re.match(rf"^repo[ \t]+{re.escape(name)}[ \t]*$", line.rstrip("\n")):
            start = i
            break
    if start is None:
        return conf
    # block body = the repo line + the indented rule lines under it, ending at
    # the first blank or non-indented line (the next stanza's leading blank, the
    # next `repo`, or EOF). Blank lines are NOT consumed, so the separator before
    # the following stanza stays put.
    end = start + 1
    while end < len(lines) and lines[end][:1] in (" ", "\t"):
        end += 1
    # drop the single blank line add_stanza put before this stanza.
    if start > 0 and lines[start - 1].strip() == "":
        start -= 1
    return "".join(lines[:start] + lines[end:])


def _self_test():
    cfg = load_config("/nonexistent")
    assert cfg["rw_user"] == "youruser"
    assert remote_url(cfg, "publisher") == "git_push:publisher"
    # helper_command builds a single shell-quoted string for SSH_ORIGINAL_COMMAND
    s = helper_command(["add-repo", "--url", "a b", "--section", "X Y"])
    assert s == "add-repo --url 'a b' --section 'X Y'"
    conf = "repo gitolite-admin\n    RW+     =   youruser\n"
    assert stanza_exists(conf, "publisher") is False
    new = add_stanza(conf, "publisher", "youruser")
    assert "repo publisher\n    RW+     =   youruser\n" in new
    assert stanza_exists(new, "publisher") is True
    # idempotent: adding again returns unchanged
    assert add_stanza(new, "publisher", "youruser") == new
    # word-boundary: 'pub' must not match 'publisher'
    assert stanza_exists(new, "pub") is False
    # empty conf: no leading separator junk, valid stanza
    assert add_stanza("", "x", "youruser") == "\nrepo x\n    RW+     =   youruser\n"
    # conf without trailing newline: separator inserted so stanza lands clean
    assert add_stanza("repo a\n    RW+     =   youruser", "x", "youruser") == \
        "repo a\n    RW+     =   youruser\n\nrepo x\n    RW+     =   youruser\n"
    # remove_stanza is the inverse of add_stanza: add then remove == original.
    base = "repo gitolite-admin\n    RW+     =   youruser\n"
    assert remove_stanza(add_stanza(base, "publisher", "youruser"), "publisher") == base
    # remove the MIDDLE of three stanzas: neighbours and their separators survive.
    three = ("repo a\n    RW+     =   u\n\n"
             "repo b\n    RW+     =   u\n\n"
             "repo c\n    RW+     =   u\n")
    assert remove_stanza(three, "b") == ("repo a\n    RW+     =   u\n\n"
                                         "repo c\n    RW+     =   u\n")
    assert stanza_exists(remove_stanza(three, "b"), "b") is False
    # missing stanza: unchanged, idempotent
    assert remove_stanza(three, "ghost") == three
    assert remove_stanza(remove_stanza(three, "b"), "b") == remove_stanza(three, "b")
    # repo table: columns padded to the widest cell (header included), desc not
    # padded, private trailing-blank cells stripped. Color off => no escapes.
    t = format_repo_table([
        ("cad", "PUB", "Generic", "CAD work"),
        ("secret", "PRIV", "", ""),
    ])
    assert t == (
        "NAME    VIS   SECTION  DESCRIPTION\n"
        "cad     PUB   Generic  CAD work\n"
        "secret  PRIV"
    ), repr(t)
    # color on: public row green, private dim, header uncolored.
    tc = format_repo_table([("cad", "PUB", "G", "d"), ("s", "PRIV", "", "")], color=True)
    assert GREEN in tc and DIM in tc and tc.startswith("NAME")
    # server passthrough: each non-empty line is prefixed, blanks left bare.
    assert _server_prefixed("No changes.\n") == "server: No changes.\n"
    assert _server_prefixed("a\n\nb") == "server: a\n\nserver: b\n"
    # global -y/--yes: defaults off, set by either spelling, lands on the namespace
    # regardless of subcommand (so cmd_create can read a.yes).
    pr = build_parser()
    assert pr.parse_args(["repo", "create", "r", "--section", "S"]).yes is False
    assert pr.parse_args(["-y", "repo", "create", "r", "--section", "S"]).yes is True
    assert pr.parse_args(["--yes", "sections", "list"]).yes is True
    # repo delete takes a name; -y parses but cmd_delete ignores it (always confirms)
    da = pr.parse_args(["-y", "repo", "delete", "doomed"])
    assert da.action == "delete" and da.name == "doomed" and da.yes is True
    print("self-test OK")


def cmd_add_remote(cfg, name, remote_name):
    # operates on the CWD's repo
    url = remote_url(cfg, name)
    r = subprocess.run(["git", "remote"], text=True, capture_output=True)
    if r.returncode != 0:
        sys.exit("error: not a git repository (run this inside the repo's working tree)")
    if remote_name in r.stdout.split():
        print(f"remote {remote_name} already exists")
        return 0
    if subprocess.run(["git", "remote", "add", remote_name, url]).returncode != 0:
        sys.exit(f"error: git remote add {remote_name} failed")
    print(f"added remote {remote_name} -> {url}")
    return 0


GREEN, DIM, RESET = "\033[32m", "\033[2m", "\033[0m"


def format_repo_table(rows, color=False):
    """rows: list of (name, vis, section, desc) from the list-repos helper.
    Returns the rendered table as a string. Public rows green, private dim
    when color is on. Pure function: no I/O, so the self-test can pin it."""
    header = ("NAME", "VIS", "SECTION", "DESCRIPTION")
    table = [header] + rows
    widths = [max(len(r[i]) for r in table) for i in range(3)]  # desc not padded
    out = []
    for i, r in enumerate(table):
        line = "  ".join([
            r[0].ljust(widths[0]), r[1].ljust(widths[1]),
            r[2].ljust(widths[2]), r[3],
        ]).rstrip()
        if color and i > 0:
            line = (GREEN if r[1] == "PUB" else DIM) + line + RESET
        out.append(line)
    return "\n".join(out)


def cmd_list(cfg):
    r = call_helper(cfg, ["list-repos"], quiet=True)
    rows = [tuple(l.split("\t")) for l in r.stdout.splitlines() if l]
    if not rows:
        print("no repos")
        return 0
    print(format_repo_table(rows, color=sys.stdout.isatty()))
    return 0


def _confirm(prompt):
    return input(prompt + " [y/N] ").strip().lower() in ("y", "yes")


def cmd_create(cfg, a):
    owner = a.owner or cfg["default_owner"]
    conf_path = os.path.join(cfg["admin_clone_path"], "conf", "gitolite.conf")

    # PHASE 1: local gitolite-admin edit
    try:
        with open(conf_path) as f:
            conf = f.read()
    except OSError as e:
        sys.exit(f"error: cannot read {conf_path}: {e.strerror}")
    if stanza_exists(conf, a.name):
        print(f"stanza for {a.name} already present, skipping conf edit")
    else:
        with open(conf_path, "w") as f:
            f.write(add_stanza(conf, a.name, cfg["rw_user"]))
        diff = git(cfg, "diff", "--", "conf/gitolite.conf", capture=True).stdout
        print(diff)  # always shown, even with -y, for visibility
        if a.yes:
            print("-y: auto-confirmed (the push may still prompt for the SSH key)")
        elif not _confirm("Commit and push this gitolite.conf change?"):
            git(cfg, "checkout", "--", "conf/gitolite.conf")
            sys.exit("aborted: reverted conf/gitolite.conf, no changes pushed")
        git(cfg, "add", "conf/gitolite.conf")
        git(cfg, "commit", "-m", f"gitctl: add repo {a.name}")
        git(cfg, "push", "origin", cfg["admin_branch"])

    # wait for gitolite to build the bare repo
    print(f"waiting for bare repo {a.name}.git to appear ...")
    for _ in range(15):
        if call_helper(cfg, ["repo-exists", "--url", a.name], check=False).returncode == 0:
            break
        time.sleep(1)
    else:
        sys.exit(f"error: timed out waiting for {a.name}.git on the server")

    # PHASE 2: server-side via helper
    sections = call_helper(cfg, ["list-sections"], check=False).stdout.split("\n")
    if a.section not in [s.strip() for s in sections if s.strip()]:
        call_helper(cfg, ["add-section", "--name", a.section])
    call_helper(cfg, ["add-repo", "--url", a.name, "--section", a.section, "--owner", owner])
    if a.desc:
        call_helper(cfg, ["set-desc", "--url", a.name, "--desc", a.desc])
    print(f"done: {a.name} created under {a.section}")
    return 0


def cmd_delete(cfg, a):
    conf_path = os.path.join(cfg["admin_clone_path"], "conf", "gitolite.conf")

    # ALWAYS confirm. Delete is destructive (the repo leaves the live tree), so
    # -y is deliberately NOT honored here; the move to the trash is the only
    # safety net. This block never reads a.yes.
    print(f"About to DELETE repo {a.name!r}:")
    print(f"  - remove its gitolite stanza (commit + push to gitolite-admin)")
    print(f"  - remove its cgit block")
    print(f"  - MOVE its bare repo to the server trash dir (reversible, not rm)")
    if not _confirm(f"Delete {a.name}? This cannot be undone from the client."):
        sys.exit("aborted: nothing changed")

    # PHASE 1: gitolite stanza (inverse of create) -- pushed FIRST so gitolite
    # stops serving the repo before its bytes move.
    try:
        with open(conf_path) as f:
            conf = f.read()
    except OSError as e:
        sys.exit(f"error: cannot read {conf_path}: {e.strerror}")
    new = remove_stanza(conf, a.name)
    if new == conf:
        print(f"no stanza for {a.name}, skipping conf edit")
    else:
        with open(conf_path, "w") as f:
            f.write(new)
        print(git(cfg, "diff", "--", "conf/gitolite.conf", capture=True).stdout)
        git(cfg, "add", "conf/gitolite.conf")
        git(cfg, "commit", "-m", f"gitctl: delete repo {a.name}")
        git(cfg, "push", "origin", cfg["admin_branch"])

    # PHASE 2: server-side via helper -- cgit block + bare-repo move, idempotent.
    # If phase 1 pushed but this fails, re-running finishes (stanza step is then
    # a no-op), the same resumable shape as create.
    call_helper(cfg, ["delete-repo", "--url", a.name])
    print(f"done: {a.name} deleted (bare repo moved to the server trash dir)")
    return 0


def build_parser():
    p = argparse.ArgumentParser(
        prog="gitctl",
        description="Thin client for managing repos on a personal gitolite3 + cgit "
                    "server. Edits the local gitolite-admin clone and pushes it, and "
                    "calls a server helper (run as the git user) over a restricted SSH key for the "
                    "privileged cgit edits. Reads ~/.config/gitctl/config.toml; pass "
                    "--self-test to run the built-in assertions.")
    # Global -y. Skips additive y/N confirmations (today: the repo-create
    # gitolite.conf push). It does NOT make creation unattended: the phase-1
    # push still needs the SSH key password / smartcard touch, which -y cannot
    # supply. NEVER honored by `repo delete`: a destructive move always confirms
    # on its own (cmd_delete does not read a.yes).
    p.add_argument("-y", "--yes", action="store_true",
                   help="skip y/N confirmation of additive changes (still prints the diff; "
                        "does NOT bypass SSH key auth, and is never honored by repo delete)")
    sub = p.add_subparsers(dest="group", required=True, help="command group")

    sections = sub.add_parser(
        "sections", help="list or add cgit sections",
        description="Manage the cgit sections that repos are grouped under.")
    sec = sections.add_subparsers(dest="action", required=True, help="sections action")
    sec.add_parser("list", help="print all cgit section names",
                   description="Print every section name currently in the server's cgitrc.")
    sa = sec.add_parser("add", help="create a cgit section if it does not exist",
                        description="Create a new cgit section. Idempotent: an existing "
                                    "section is left as-is.")
    sa.add_argument("name", help="section title, e.g. 'Generic Projects' (quote if it has spaces)")

    sub.add_parser("sync", help="re-sync cgit descriptions from the bare repos",
                   description="Run the server's description sync, copying each bare repo's "
                               "description file into cgit's repo.desc=.")

    repo = sub.add_parser(
        "repo", help="create a repo, set its description, or add a git remote",
        description="Operations on a single repo.")
    rep = repo.add_subparsers(dest="action", required=True, help="repo action")

    rc = rep.add_parser(
        "create", help="create a new repo end to end (gitolite + cgit)",
        description="Two phase create. Phase 1 adds a stanza to the local "
                    "gitolite-admin clone, shows the diff, asks before committing and "
                    "pushing (declining reverts the edit), then polls until gitolite "
                    "has built the bare repo. Phase 2 ensures the cgit section exists, "
                    "files the repo under it, and optionally sets its description. "
                    "Every step is idempotent and safe to re-run.")
    rc.add_argument("name", help="repo name; used for the gitolite stanza, the bare repo, and cgit")
    rc.add_argument("--section", required=True,
                    help="cgit section to file the repo under; created automatically if missing")
    rc.add_argument("--desc", help="optional initial cgit description for the repo")
    rc.add_argument("--owner",
                    help="cgit repo.owner string (default: default_owner from config)")

    rd = rep.add_parser(
        "desc", help="set a repo's cgit description",
        description="Write the repo's description and re-sync. Goes through the bare "
                    "repo's description file (the single writer of cgit repo.desc=); "
                    "never writes repo.desc= directly.")
    rd.add_argument("name", help="repo name")
    rd.add_argument("description", help="new description text (quote if it has spaces)")

    rep.add_parser(
        "list", help="list every repo on the server, public and private",
        description="List all bare repos on the server (the full set, not just the "
                    "cgit-exposed ones). A VIS column marks each public (in cgit) or "
                    "private; on a terminal public rows are green, private dim.")

    rdel = rep.add_parser(
        "delete", help="delete a repo (gitolite + cgit + move bare repo to trash)",
        description="Remove a repo from all three places it lives: its gitolite stanza "
                    "(committed and pushed), its cgit block, and the bare repo itself, "
                    "which is MOVED to the server's trash dir (reversible, not rm). "
                    "ALWAYS asks for confirmation; the global -y does NOT bypass this. "
                    "Idempotent: a piece already gone is skipped.")
    rdel.add_argument("name", help="repo name to delete")

    rr = rep.add_parser(
        "add-remote", help="add a git remote for the repo in the current directory",
        description="Add a git remote pointing at the repo on the server, in the "
                    "current working directory's clone. Idempotent: an existing remote "
                    "of the same name is reported, not duplicated.")
    rr.add_argument("name", help="repo name on the server (used to build the remote URL)")
    rr.add_argument("--remote-name", default="origin",
                    help="local remote name to create (default: %(default)s)")
    return p


def main():
    cfg = load_config()
    a = build_parser().parse_args()

    if a.group == "sections" and a.action == "list":
        call_helper(cfg, ["list-sections"]); return 0
    if a.group == "sections" and a.action == "add":
        call_helper(cfg, ["add-section", "--name", a.name]); return 0
    if a.group == "sync":
        call_helper(cfg, ["sync"]); return 0
    if a.group == "repo" and a.action == "desc":
        call_helper(cfg, ["set-desc", "--url", a.name, "--desc", a.description]); return 0
    if a.group == "repo" and a.action == "list":
        return cmd_list(cfg)
    if a.group == "repo" and a.action == "add-remote":
        return cmd_add_remote(cfg, a.name, a.remote_name)
    if a.group == "repo" and a.action == "create":
        return cmd_create(cfg, a)
    if a.group == "repo" and a.action == "delete":
        return cmd_delete(cfg, a)
    return 1


if __name__ == "__main__":
    if len(sys.argv) > 1 and sys.argv[1] == "--self-test":
        _self_test(); sys.exit(0)
    sys.exit(main())
