#!/usr/bin/env python3
"""Release helper for nanoflann.

The version number lives in several files, which must never disagree:

  * include/nanoflann.hpp : NANOFLANN_VERSION_STRING ("M.m.P")
  * include/nanoflann.hpp : NANOFLANN_VERSION        (0xMMmmPP)
  * package.xml           : <version>M.m.P</version>
  * CHANGELOG.rst         : topmost "M.m.P (YYYY-MM-DD)" entry
  * git tag               : "M.m.P"

CMakeLists.txt parses the header macro, while bloom/the ROS buildfarm read
package.xml, so a mismatch silently produces broken releases.

Usage:
  scripts/release.py check                 Verify all sources agree (safe, read only)
  scripts/release.py prepare --bump patch  Prepare a new release
  scripts/release.py prepare --version 1.12.0
  scripts/release.py notes                 Changelog entry as Markdown release notes

"prepare" drives the standard ROS flow (catkin_generate_changelog +
catkin_prepare_release) and folds the header macro bump into the very same
release commit and tag. Nothing is ever pushed: the final push and the
bloom-release calls are left to the user.
"""

import argparse
import datetime
import os
import re
import shutil
import subprocess
import sys

REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
HEADER_FILE = os.path.join(REPO_ROOT, "include", "nanoflann.hpp")
PACKAGE_XML = os.path.join(REPO_ROOT, "package.xml")
CHANGELOG = os.path.join(REPO_ROOT, "CHANGELOG.rst")

VERSION_STRING_RE = re.compile(
    r'(#define\s+NANOFLANN_VERSION_STRING\s+")(\d+\.\d+\.\d+)(")'
)
VERSION_HEX_RE = re.compile(r"(#define\s+NANOFLANN_VERSION\s+)(0x[0-9A-Fa-f]{6})")
PACKAGE_VERSION_RE = re.compile(r"(<version>)(\d+\.\d+\.\d+)(</version>)")
CHANGELOG_ENTRY_RE = re.compile(r"^(\d+\.\d+\.\d+) \((\d{4}-\d{2}-\d{2})\)$", re.M)
FORTHCOMING_RE = re.compile(r"^Forthcoming\n-+\n", re.M)
# reStructuredText external link, as generated by catkin_generate_changelog:
RST_LINK_RE = re.compile(r"`([^`<]+?)\s*<([^>]+)>`_")


class ReleaseError(Exception):
    pass


def read(path):
    with open(path, "r", encoding="utf-8") as f:
        return f.read()


def write(path, text):
    with open(path, "w", encoding="utf-8") as f:
        f.write(text)


def parse_version(text):
    parts = text.split(".")
    if len(parts) != 3:
        raise ReleaseError("Malformed version '%s', expected M.m.P" % text)
    try:
        version = tuple(int(p) for p in parts)
    except ValueError:
        raise ReleaseError("Malformed version '%s', expected M.m.P" % text)
    if any(field < 0 for field in version):
        raise ReleaseError("Version '%s' has negative fields" % text)
    return version


def format_version(version):
    return "%d.%d.%d" % version


def encode_hex(version):
    major, minor, patch = version
    for value, name in ((major, "major"), (minor, "minor"), (patch, "patch")):
        if value > 0xFF:
            raise ReleaseError(
                "Version field %s=%d does not fit in the 0xMMmmPP macro" % (name, value)
            )
    return "0x%02X%02X%02X" % (major, minor, patch)


def search(regex, text, path, what):
    match = regex.search(text)
    if not match:
        raise ReleaseError("Could not find %s in %s" % (what, path))
    return match


def current_versions():
    """Return a dict of {source name: version string} as found on disk."""
    header = read(HEADER_FILE)
    changelog = read(CHANGELOG)
    entry = CHANGELOG_ENTRY_RE.search(changelog)
    if not entry:
        raise ReleaseError("Could not find any dated entry in %s" % CHANGELOG)

    hex_text = search(
        VERSION_HEX_RE, header, HEADER_FILE, "NANOFLANN_VERSION"
    ).group(2)
    hex_value = int(hex_text, 16)
    hex_version = format_version(
        ((hex_value >> 16) & 0xFF, (hex_value >> 8) & 0xFF, hex_value & 0xFF)
    )

    return {
        "NANOFLANN_VERSION_STRING": search(
            VERSION_STRING_RE, header, HEADER_FILE, "NANOFLANN_VERSION_STRING"
        ).group(2),
        "NANOFLANN_VERSION (%s)" % hex_text: hex_version,
        "package.xml": search(
            PACKAGE_VERSION_RE, read(PACKAGE_XML), PACKAGE_XML, "<version>"
        ).group(2),
        "CHANGELOG.rst": entry.group(1),
    }


def check_consistency(verbose=True):
    """Verify every source of truth agrees. Returns the common version."""
    versions = current_versions()
    unique = set(versions.values())
    if len(unique) != 1:
        report = "\n".join(
            "  %-40s %s" % (source, value) for source, value in versions.items()
        )
        raise ReleaseError("Version mismatch between sources:\n" + report)
    version = unique.pop()
    if verbose:
        print("Version is consistent across all sources: %s" % version)
    return version


def changelog_notes(version):
    """Extract one CHANGELOG entry and render it as Markdown release notes."""
    changelog = read(CHANGELOG)
    entries = list(CHANGELOG_ENTRY_RE.finditer(changelog))
    for i, entry in enumerate(entries):
        if entry.group(1) != version:
            continue
        end = entries[i + 1].start() if i + 1 < len(entries) else len(changelog)
        # Skip the header itself and its "----" underline:
        body = changelog[entry.end() : end].split("\n", 2)[-1]
        body = RST_LINK_RE.sub(r"[\1](\2)", body)
        body = body.replace("\\_", "_")
        return body.strip("\n")
    raise ReleaseError("No entry for version %s found in %s" % (version, CHANGELOG))


def git(*args, capture=True):
    result = subprocess.run(
        ["git"] + list(args), cwd=REPO_ROOT, capture_output=capture, text=True
    )
    if result.returncode != 0:
        raise ReleaseError(
            "git %s failed:\n%s" % (" ".join(args), (result.stderr or "").strip())
        )
    return (result.stdout or "").strip()


def run(cmd, dry_run):
    """Run an external command, echoing it first."""
    print("+ %s" % " ".join(cmd))
    if dry_run:
        return
    result = subprocess.run(cmd, cwd=REPO_ROOT)
    if result.returncode != 0:
        raise ReleaseError("Command failed: %s" % " ".join(cmd))


def preflight(dry_run):
    for tool in ("catkin_generate_changelog", "catkin_prepare_release"):
        if shutil.which(tool) is None:
            raise ReleaseError(
                "'%s' not found. Install it with: pip install catkin_pkg" % tool
            )

    branch = git("rev-parse", "--abbrev-ref", "HEAD")
    if branch != "master":
        raise ReleaseError("Releases are made from 'master', but HEAD is '%s'" % branch)

    if git("status", "--porcelain"):
        raise ReleaseError("Working tree is not clean; commit or stash first")

    try:
        if not dry_run:
            # In dry-run mode nothing is touched, not even the remote refs, so
            # the comparison uses whatever origin/master is already known.
            git("fetch", "origin", "master")
        behind, ahead = git(
            "rev-list", "--left-right", "--count", "origin/master...HEAD"
        ).split()
    except ReleaseError as e:
        print("WARNING: could not compare with origin/master (%s)" % e)
        return
    if behind != "0" or ahead != "0":
        message = "Local master differs from origin/master (%s behind, %s ahead)" % (
            behind,
            ahead,
        )
        if not dry_run:
            raise ReleaseError(message + "; push or pull first")
        print("WARNING: " + message)


def bump(version, part):
    major, minor, patch = version
    if part == "major":
        return (major + 1, 0, 0)
    if part == "minor":
        return (major, minor + 1, 0)
    return (major, minor, patch + 1)


def update_header(new_version, dry_run):
    """Bump both version macros in nanoflann.hpp."""
    text = read(HEADER_FILE)
    text = VERSION_STRING_RE.sub(
        lambda m: m.group(1) + format_version(new_version) + m.group(3), text, count=1
    )
    text = VERSION_HEX_RE.sub(
        lambda m: m.group(1) + encode_hex(new_version), text, count=1
    )
    print(
        "Setting NANOFLANN_VERSION_STRING=\"%s\" and NANOFLANN_VERSION=%s"
        % (format_version(new_version), encode_hex(new_version))
    )
    if not dry_run:
        write(HEADER_FILE, text)


def has_forthcoming():
    return FORTHCOMING_RE.search(read(CHANGELOG)) is not None


def generate_changelog(dry_run, assume_yes):
    # Contributors are listed in every past entry, so keep them:
    run(["catkin_generate_changelog"], dry_run)
    if dry_run:
        return
    if not has_forthcoming():
        print("No new commits since the last release: CHANGELOG.rst is unchanged.")
    if not assume_yes:
        print("")
        print("Review and edit the 'Forthcoming' section of CHANGELOG.rst now.")
        try:
            answer = input("Press ENTER to continue, or Ctrl+C to abort: ")
        except (EOFError, KeyboardInterrupt):
            raise ReleaseError("Aborted by user")
        del answer
    if git("status", "--porcelain"):
        run(["git", "commit", "-a", "-m", "update changelog"], dry_run)


def prepare(args):
    preflight(args.dry_run)
    current = parse_version(check_consistency())

    if args.version:
        new_version = parse_version(args.version)
    else:
        new_version = bump(current, args.bump)
    if new_version <= current:
        raise ReleaseError(
            "New version %s is not newer than the current one %s"
            % (format_version(new_version), format_version(current))
        )
    new_version_str = format_version(new_version)
    print("Preparing release %s (from %s)" % (new_version_str, format_version(current)))

    generate_changelog(args.dry_run, args.yes)

    # catkin_prepare_release bumps package.xml, dates the changelog entry,
    # commits and tags. It requires a clean tree, hence the header macros are
    # folded into its commit afterwards, by amending it.
    run(
        [
            "catkin_prepare_release",
            "--version",
            new_version_str,
            "--no-push",
            "-y",
        ],
        args.dry_run,
    )

    update_header(new_version, args.dry_run)

    if args.dry_run:
        print("")
        print("--dry-run: no changes were made.")
        return

    run(["git", "add", os.path.relpath(HEADER_FILE, REPO_ROOT)], args.dry_run)
    run(["git", "commit", "--amend", "--no-edit"], args.dry_run)
    # Move the tag onto the amended commit (nothing was pushed yet):
    run(["git", "tag", "-f", new_version_str], args.dry_run)

    check_consistency()
    entry = CHANGELOG_ENTRY_RE.search(read(CHANGELOG))
    today = datetime.date.today().isoformat()
    if entry.group(2) != today:
        print("WARNING: changelog entry is dated %s, not today (%s)" % (entry.group(2), today))

    print("")
    print("Release %s prepared locally. Remaining manual steps:" % new_version_str)
    print("  1) Review it:  git show %s" % new_version_str)
    print("  2) Publish:    git push origin master && git push origin %s" % new_version_str)
    print("  3) Release to each ROS distro, e.g.:")
    print("       bloom-release nanoflann --rosdistro humble --track humble")
    print("")
    print("Pushing the tag triggers the workflow that creates the GitHub release.")


def main():
    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    subparsers = parser.add_subparsers(dest="command", required=True)

    subparsers.add_parser(
        "check", help="verify that all version sources agree (read only)"
    )

    notes_parser = subparsers.add_parser(
        "notes", help="print the changelog entry of a version as Markdown"
    )
    notes_parser.add_argument(
        "--version",
        help="version to extract; defaults to the current one",
    )

    prepare_parser = subparsers.add_parser("prepare", help="prepare a new release")
    group = prepare_parser.add_mutually_exclusive_group(required=True)
    group.add_argument("--bump", choices=("major", "minor", "patch"))
    group.add_argument("--version", help="explicit new version, as M.m.P")
    prepare_parser.add_argument(
        "--dry-run", action="store_true", help="show what would be done, change nothing"
    )
    prepare_parser.add_argument(
        "-y",
        "--yes",
        action="store_true",
        help="do not pause to hand-edit the changelog",
    )

    args = parser.parse_args()
    try:
        if args.command == "check":
            check_consistency()
        elif args.command == "notes":
            version = args.version or check_consistency(verbose=False)
            print(changelog_notes(version.lstrip("v")))
        else:
            prepare(args)
    except ReleaseError as e:
        print("ERROR: %s" % e, file=sys.stderr)
        return 1
    return 0


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