#!/usr/bin/env python3
"""claude-commit-msg -- hand suggested commit messages from Claude sessions to `git commit`.

Several Claude sessions work on one checkout at once, each staging its own round. The old
handoff was ONE file (`<git-dir>/CLAUDE_COMMIT_MSG`) that the prepare-commit-msg hook pasted
into the editor: the second session silently overwrote the first, and the hook could not
know which message described which files. Now every handoff is its own file under
`<git-dir>/CLAUDE_COMMIT_MSG.d/`, tagged with the paths it describes, and the hook prefills
only the message(s) whose paths are part of the commit being made:

    <id>.msg     the raw message
    <id>.paths   one repo-relative path per line (the staged set at save time)
    used/        consumed messages, kept for recovery

  save     [--paths P ...] [--slug S]   read the message on stdin; paths default to the index
  list                                  pending messages and how they match the current index
  show ID | rm ID
  prefill MSGFILE [SOURCE]              hook entry point (prepare-commit-msg)

Matching in `prefill`: a message whose paths are all in the commit is a FULL match (prefilled,
then consumed); overlapping only partly = PARTIAL (prefilled, kept -- the rest may come in a
later commit); no paths recorded = WILDCARD (prefilled, consumed), which is also how a legacy
single CLAUDE_COMMIT_MSG file is treated. Several matches are all prefilled, separated by a
comment line git strips, so you pick in the editor. Nothing matching = editor untouched.
Everything lives inside the git dir, so it never shows in `git status`; `--git-path` keeps it
per-worktree. Always exits 0 from `prefill`: a hook failure must never abort a commit.
"""
import os
import re
import shutil
import subprocess
import sys
import time


def git(*args, check=True):
    r = subprocess.run(["git", *args], capture_output=True, text=True)
    if check and r.returncode != 0:
        sys.exit(f"claude-commit-msg: git {' '.join(args)} failed: {r.stderr.strip()}")
    return r.stdout


def store():
    d = git("rev-parse", "--git-path", "CLAUDE_COMMIT_MSG.d").strip()
    d = os.path.abspath(d)
    os.makedirs(os.path.join(d, "used"), exist_ok=True)
    return d


def staged_paths():
    out = git("diff", "--cached", "--name-only", "-z", check=False)
    return [p for p in out.split("\0") if p]


def comment_char():
    c = git("config", "--get", "core.commentchar", check=False).strip()
    return "#" if not c or c == "auto" else c[0]


def pending(d):
    """[(id, message, paths|None)] oldest first. paths None = wildcard."""
    out = []
    for name in sorted(os.listdir(d)):
        if not name.endswith(".msg"):
            continue
        mid = name[:-4]
        try:
            with open(os.path.join(d, name)) as fh:
                msg = fh.read()
        except OSError:
            continue
        paths = None
        pf = os.path.join(d, mid + ".paths")
        if os.path.exists(pf):
            with open(pf) as fh:
                paths = [ln.rstrip("\n") for ln in fh if ln.strip()]
        out.append((mid, msg, paths))
    return out


def slugify(text):
    m = re.match(r"\s*\[([^\]]+)\]", text)  # this repo's `[Scope] summary` convention
    base = m.group(1) if m else " ".join(text.split()[:3])
    s = re.sub(r"[^a-z0-9]+", "-", base.lower()).strip("-")
    return s[:32] or "msg"


def cmd_save(argv):
    paths, slug = None, None
    i = 0
    while i < len(argv):
        if argv[i] == "--paths":
            paths = []
            i += 1
            while i < len(argv) and not argv[i].startswith("--"):
                paths.append(argv[i])
                i += 1
            continue
        if argv[i] == "--slug" and i + 1 < len(argv):
            slug = argv[i + 1]
            i += 2
            continue
        sys.exit(f"claude-commit-msg save: unknown argument {argv[i]!r}")
    msg = sys.stdin.read().rstrip() + "\n"
    if not msg.strip():
        sys.exit("claude-commit-msg save: empty message on stdin")
    if paths is None:
        paths = staged_paths()
    d = store()
    mid = time.strftime("%Y%m%d-%H%M%S") + "-" + (slug or slugify(msg))
    n = 1
    while os.path.exists(os.path.join(d, f"{mid}.msg")):
        n += 1
        mid = f"{mid}-{n}"
    with open(os.path.join(d, mid + ".msg"), "w") as fh:
        fh.write(msg)
    with open(os.path.join(d, mid + ".paths"), "w") as fh:
        fh.write("".join(p + "\n" for p in paths))
    print(f"saved {mid} ({len(paths)} path{'s' if len(paths) != 1 else ''}) -> {os.path.join(d, mid + '.msg')}")
    if not paths:
        print("warning: no paths recorded (nothing staged?) -- it will prefill on ANY commit", file=sys.stderr)
    warn_if_hook_is_old()


def warn_if_hook_is_old():
    """A clone still running the legacy hook (or none) reads only the single file this tool
    never writes -- the editor would come up empty with no error. Say so at save time."""
    hook = git("rev-parse", "--git-path", "hooks/prepare-commit-msg", check=False).strip()
    try:
        with open(hook, errors="ignore") as fh:
            ok = "claude-commit-msg" in fh.read()
    except OSError:
        ok = False
    if not ok:
        print(
            "warning: this repo's prepare-commit-msg hook is missing or predates claude-commit-msg, so this\n"
            "         message will NOT be prefilled here. Install the tracked hook once per repo:\n"
            "         ln -sf ~/.config/git/hooks/prepare-commit-msg (git rev-parse --git-path hooks)/prepare-commit-msg",
            file=sys.stderr,
        )


def classify(paths, staged):
    if paths is None or not paths:
        return "wildcard"
    ps = set(paths)
    if ps <= staged:
        return "full"
    if ps & staged:
        return "partial"
    return "none"


def cmd_list(_argv):
    d = store()
    staged = set(staged_paths())
    items = pending(d)
    legacy = git("rev-parse", "--git-path", "CLAUDE_COMMIT_MSG").strip()
    if not items and not os.path.exists(legacy):
        print("no pending messages")
        return
    for mid, msg, paths in items:
        n = len(paths) if paths else 0
        print(f"{mid}  [{classify(paths, staged):8}] {n} path{'s' if n != 1 else ''}  {msg.splitlines()[0][:70]}")
    if os.path.exists(legacy) and os.path.getsize(legacy) > 0:
        with open(legacy) as fh:
            first = fh.readline().rstrip()
        print(f"(legacy CLAUDE_COMMIT_MSG)  [wildcard]  {first[:70]}")


def cmd_show(argv):
    d = store()
    for mid in argv:
        with open(os.path.join(d, mid + ".msg")) as fh:
            sys.stdout.write(fh.read())


def cmd_rm(argv):
    d = store()
    for mid in argv:
        hit = False
        for ext in (".msg", ".paths"):
            p = os.path.join(d, mid + ext)
            if os.path.exists(p):
                os.remove(p)
                hit = True
        if hit:
            print(f"removed {mid}")
        elif os.path.exists(os.path.join(d, "used", mid + ".msg")):
            print(f"{mid} was already consumed by a commit (see used/)")
        else:
            print(f"{mid}: no such pending message")


def consume(d, mid):
    for ext in (".msg", ".paths"):
        p = os.path.join(d, mid + ext)
        if os.path.exists(p):
            shutil.move(p, os.path.join(d, "used", mid + ext))


def cmd_prefill(argv):
    if not argv:
        return
    msg_file = argv[0]
    source = argv[1] if len(argv) > 1 else ""
    if source not in ("", "template"):  # -m/-F, merge, squash, amend: leave alone
        return
    d = store()
    staged = set(staged_paths())
    order = {"full": 0, "partial": 1, "wildcard": 2}
    picked = []
    for mid, msg, paths in pending(d):
        kind = classify(paths, staged)
        if kind != "none":
            picked.append((order[kind], mid, msg, kind))
    legacy = git("rev-parse", "--git-path", "CLAUDE_COMMIT_MSG").strip()
    legacy_msg = None
    if os.path.isfile(legacy) and os.path.getsize(legacy) > 0:
        with open(legacy) as fh:
            legacy_msg = fh.read()
        picked.append((3, "legacy", legacy_msg, "wildcard"))
    if not picked:
        return
    picked.sort(key=lambda t: (t[0], t[1]))
    cc = comment_char()
    sep = f"\n{cc} ---- another pending Claude message matched this commit; keep the right one ----\n\n"
    body = sep.join(m.rstrip("\n") + "\n" for _, _, m, _ in picked)
    with open(msg_file) as fh:
        existing = fh.read()
    tmp = msg_file + ".claude-tmp"
    with open(tmp, "w") as fh:
        fh.write(body + "\n" + existing)
    os.replace(tmp, msg_file)
    for _, mid, _, kind in picked:
        if mid == "legacy":
            os.replace(legacy, legacy + ".last")
        elif kind in ("full", "wildcard"):
            consume(d, mid)


def main():
    cmds = {"save": cmd_save, "list": cmd_list, "show": cmd_show, "rm": cmd_rm, "prefill": cmd_prefill}
    if len(sys.argv) < 2 or sys.argv[1] not in cmds:
        sys.exit("usage: claude-commit-msg save [--paths P ...] [--slug S] < message | list | show ID | rm ID | prefill MSGFILE [SOURCE]")
    if sys.argv[1] == "prefill":
        try:
            cmd_prefill(sys.argv[2:])
        except Exception as e:  # never abort a commit because of the helper
            print(f"claude-commit-msg prefill: {e}", file=sys.stderr)
        return
    cmds[sys.argv[1]](sys.argv[2:])


if __name__ == "__main__":
    main()
