#!/usr/bin/env python3
"""Increment the patch component of plugin.json's version as part of the commit.

Runs as a pre-commit hook, which is the only commit hook whose `git add` is
picked up by the commit currently being created:

  * pre-commit          -> staged changes land in THIS commit   (correct)
  * prepare-commit-msg  -> staged changes land in the NEXT commit
  * commit-msg          -> staged changes are ignored entirely

The commit message is read from the invoking `git` process' argv (-m/-F/-C and
long forms). `.git/COMMIT_EDITMSG` is deliberately NOT consulted: at pre-commit
time it still holds the PREVIOUS commit's message, so a stop word there would
silently suppress every later bump.

Bump is skipped when:
  * the commit message carries a stop word (see STOP_PATTERNS),
  * NO_BUMP / GIT_NO_BUMP is set in the environment,
  * plugin.json's version already differs from HEAD (manual bump),
  * a merge or rebase is in progress,
  * plugin.json is absent or its version is not x.y.z.

For editor-driven commits (`git commit` with no -m) the message does not exist
yet when this hook runs, so use `NO_BUMP=1 git commit` to skip the bump there.
"""

import json
import os
import re
import subprocess
import sys
from pathlib import Path

PLUGIN_FILE = "plugin.json"

STOP_PATTERNS = (
    r"\[\s*no[-_ ]?bump\s*\]",
    r"\[\s*skip[-_ ]?bump\s*\]",
    r"\[\s*no[-_ ]?version[-_ ]?bump\s*\]",
    r"\[\s*skip[-_ ]?version\s*\]",
)
STOP_RE = re.compile("|".join(STOP_PATTERNS), re.IGNORECASE)

VERSION_RE = re.compile(r"\A(\d+)\.(\d+)\.(\d+)(.*)\Z", re.DOTALL)

# options whose value IS the message text
TEXT_OPTS = ("-m", "--message")
TEXT_OPTS_EQ = ("--message=",)
# options whose value NAMES a file containing the message
FILE_OPTS = ("-F", "--file")
FILE_OPTS_EQ = ("--file=",)
# options whose value names a commit to take the message from
COMMIT_OPTS = ("-C", "--reuse-message", "-c", "--reedit-message")
COMMIT_OPTS_EQ = ("--reuse-message=", "--reedit-message=")


def repo_root() -> Path:
    out = subprocess.run(
        ["git", "rev-parse", "--show-toplevel"],
        capture_output=True,
        text=True,
    )
    if out.returncode != 0:
        raise RuntimeError(out.stderr.strip() or "not inside a git work tree")
    return Path(out.stdout.strip())


def git_dir() -> Path:
    out = subprocess.run(
        ["git", "rev-parse", "--absolute-git-dir"],
        capture_output=True,
        text=True,
    )
    if out.returncode != 0:
        raise RuntimeError(out.stderr.strip() or "cannot resolve git dir")
    return Path(out.stdout.strip())


def bump_patch(version: str) -> str:
    m = VERSION_RE.match(version.strip())
    if not m:
        raise ValueError(f"version {version!r} is not x.y.z")
    major, minor, patch, suffix = m.groups()
    return f"{major}.{minor}.{int(patch) + 1}{suffix}"


def head_version(root: Path) -> str | None:
    """Version recorded for plugin.json in HEAD, or None when unavailable."""
    if subprocess.run(
            ["git", "rev-parse", "--verify", "--quiet", "HEAD"],
            cwd=root,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
    ).returncode != 0:
        return None

    show = subprocess.run(
        ["git", "show", f"HEAD:{PLUGIN_FILE}"],
        cwd=root,
        capture_output=True,
        text=True,
    )
    if show.returncode != 0:
        return None
    try:
        return str(json.loads(show.stdout)["version"])
    except (json.JSONDecodeError, KeyError, TypeError):
        return None


def commit_message_text(root: Path) -> str:
    """Commit message text of the git process that spawned this hook.

    Covers `-m/--message` (inline), `-F/--file` (read the file) and
    `-C/-c/--reuse-message/--reedit-message` (read that commit's message).
    Returns "" for editor-driven commits, where no message exists yet.
    """
    try:
        argv = Path(f"/proc/{os.getppid()}/cmdline").read_bytes().decode("utf-8", "replace").split("\0")
    except OSError:
        return ""

    texts: list[str] = []
    pending: str | None = None  # "text" | "file" | "commit"

    def take(kind: str, value: str) -> None:
        if kind == "text":
            texts.append(value)
        elif kind == "file":
            try:
                texts.append(Path(value).read_text(encoding="utf-8", errors="replace"))
            except OSError:
                pass
        else:
            out = subprocess.run(
                ["git", "log", "-1", "--format=%B", value],
                cwd=root,
                capture_output=True,
                text=True,
            )
            if out.returncode == 0:
                texts.append(out.stdout)

    for arg in argv:
        if not arg:
            continue
        if pending:
            take(pending, arg)
            pending = None
            continue
        if arg in TEXT_OPTS:
            pending = "text"
        elif arg in FILE_OPTS:
            pending = "file"
        elif arg in COMMIT_OPTS:
            pending = "commit"
        elif arg.startswith(TEXT_OPTS_EQ):
            take("text", arg.split("=", 1)[1])
        elif arg.startswith(FILE_OPTS_EQ):
            take("file", arg.split("=", 1)[1])
        elif arg.startswith(COMMIT_OPTS_EQ):
            take("commit", arg.split("=", 1)[1])
        elif len(arg) > 1 and arg[0] == "-" and arg[1] != "-":
            # clustered short flags, e.g. -am "text" or -am"text"
            for kind, letter in (("text", "m"), ("file", "F"), ("commit", "C")):
                idx = arg.find(letter, 1)
                if idx == -1:
                    continue
                if idx + 1 < len(arg):
                    take(kind, arg[idx + 1:])
                else:
                    pending = kind
                break

    return "\n".join(texts)


def strip_comments(text: str) -> str:
    return " ".join(
        line for line in text.splitlines() if not line.lstrip().startswith("#")
    )


def stop_word_reason(root: Path) -> str | None:
    if os.environ.get("NO_BUMP") or os.environ.get("GIT_NO_BUMP"):
        return "NO_BUMP is set"
    match = STOP_RE.search(strip_comments(commit_message_text(root)))
    if match:
        return f"commit message contains {match.group(0)!r}"
    return None


def merge_or_rebase_in_progress(gitdir: Path) -> bool:
    return any(
        (gitdir / name).exists()
        for name in ("MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "rebase-merge", "rebase-apply")
    )


def main() -> int:
    try:
        root = repo_root()
        gitdir = git_dir()
    except RuntimeError as exc:
        print(f"pre-commit: {exc}", file=sys.stderr)
        return 0

    if merge_or_rebase_in_progress(gitdir):
        return 0

    reason = stop_word_reason(root)
    if reason:
        print(f"pre-commit: {reason}, keeping version unchanged")
        return 0

    path = root / PLUGIN_FILE
    if not path.exists():
        return 0

    raw = path.read_text(encoding="utf-8")
    try:
        data = json.loads(raw)
    except json.JSONDecodeError as exc:
        print(f"pre-commit: {PLUGIN_FILE} is not valid JSON: {exc}", file=sys.stderr)
        return 1

    current = str(data.get("version", ""))
    previous = head_version(root)
    if previous is not None and current != previous:
        print(
            f"pre-commit: version already changed ({previous} -> {current}), keeping it"
        )
        return 0

    try:
        new_version = bump_patch(current)
    except ValueError as exc:
        print(f"pre-commit: {exc}, keeping version unchanged", file=sys.stderr)
        return 0

    data["version"] = new_version
    trailing = "\n" if raw.endswith("\n") else ""
    path.write_text(json.dumps(data, indent=2) + trailing, encoding="utf-8")

    # Stage into the index git is actually building for this commit. For
    # `git commit -- <pathspec>` that is a temporary index named by
    # GIT_INDEX_FILE, so honouring the inherited environment is what makes the
    # bump part of THIS commit.
    add = subprocess.run(["git", "add", "--", PLUGIN_FILE], cwd=root)
    if add.returncode != 0:
        print(f"pre-commit: failed to stage {PLUGIN_FILE}", file=sys.stderr)
        return add.returncode

    # The real index may still hold the pre-bump blob after a pathspec commit;
    # scripts/post-commit refreshes it once git releases index.lock.

    print(f"pre-commit: version {current} -> {new_version}")
    return 0


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