#!/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 call_helper(cfg, args, check=True):
    """Run a helper verb. Returns CompletedProcess. ssh sends the verb string;
    the restricted key forces gitctl-helper, which reads SSH_ORIGINAL_COMMAND."""
    cmd = ["ssh", cfg["helper_ssh_alias"], helper_command(args)]
    r = subprocess.run(cmd, text=True, capture_output=True)
    if r.stdout:
        sys.stdout.write(r.stdout)
    if r.stderr:
        sys.stderr.write(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 _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"
    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


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)
        if 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 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.")
    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)")

    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 == "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)
    return 1


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