#!/usr/bin/env python3
"""Publish spec-trace to PyPI.

Usage: bin/publish [--minor|--major] [--dry-run]

Requires UV_PUBLISH_TOKEN in the environment or in .env, and a non-empty
Unreleased section in CHANGELOG.md.

This is the whole release: running it is all a release needs. In order it
rebases onto origin/main, runs the gates, writes the new version, retitles the
changelog, commits, builds, publishes, tags, and pushes the commit and the tag.

The order matters. The rebase happens first so that the commit which gets
tagged is already in its final form -- tagging before a rebase strands the tag
on a commit the rebase then rewrites. The commit is written before the upload
so that a published version always has a commit behind it; if the upload
fails, the commit is rolled back and nothing has moved.
"""

from __future__ import annotations

import argparse
import os
import re
import shutil
import subprocess
import sys
from datetime import date
from pathlib import Path

PYPROJECT = Path("pyproject.toml")
INIT = Path("src/spec_trace/__init__.py")
LOCK = Path("uv.lock")
CHANGELOG = Path("CHANGELOG.md")

UNRELEASED_HEADING = "## [Unreleased]"


def load_token_from_env_file() -> None:
    env_file = Path(".env")
    if not env_file.exists():
        return
    for raw in env_file.read_text(encoding="utf-8").splitlines():
        line = raw.strip()
        if not line or line.startswith("#") or not line.startswith("UV_PUBLISH_TOKEN="):
            continue
        os.environ.setdefault("UV_PUBLISH_TOKEN", line.split("=", 1)[1].strip().strip("\"'"))


def current_version() -> str:
    match = re.search(r'^version = "(.+?)"', PYPROJECT.read_text(encoding="utf-8"), re.MULTILINE)
    if not match:
        sys.exit("Error: could not find version in pyproject.toml")
    return match.group(1)


def bump(version: str, part: str) -> str:
    major, minor, patch = (int(x) for x in version.split("."))
    if part == "major":
        return f"{major + 1}.0.0"
    if part == "minor":
        return f"{major}.{minor + 1}.0"
    return f"{major}.{minor}.{patch + 1}"


def write_version(old: str, new: str) -> None:
    """Write the new version to every file that records it.

    ``uv.lock`` records the project's own version too, and CI syncs with
    ``--locked``. Leaving the lock behind would turn main red on the release
    commit, so it is regenerated here rather than at the call sites: every
    path that writes a version -- the bump, the dry-run revert, and the
    failed-upload rollback -- has to keep all three files in step.
    """
    PYPROJECT.write_text(
        PYPROJECT.read_text(encoding="utf-8").replace(f'version = "{old}"', f'version = "{new}"', 1),
        encoding="utf-8",
    )
    INIT.write_text(
        INIT.read_text(encoding="utf-8").replace(f'__version__ = "{old}"', f'__version__ = "{new}"', 1),
        encoding="utf-8",
    )
    run("uv", "lock")


def unreleased_body() -> str:
    """Return the text under the Unreleased heading, up to the next release.

    A release with nothing written under Unreleased is the failure this
    guards: the tag carries no content of its own, so an empty section is the
    difference between a consumer being able to read what changed and not.
    """
    text = CHANGELOG.read_text(encoding="utf-8")
    if UNRELEASED_HEADING not in text:
        sys.exit(f"Error: {CHANGELOG} has no '{UNRELEASED_HEADING}' heading")
    after = text.split(UNRELEASED_HEADING, 1)[1]
    # The next "## " heading is the previous release; everything before it
    # belongs to Unreleased.
    body = re.split(r"^## ", after, maxsplit=1, flags=re.MULTILINE)[0]
    return body.strip()


def require_release_notes() -> None:
    if not CHANGELOG.exists():
        sys.exit(f"Error: {CHANGELOG} not found")
    if not unreleased_body():
        sys.exit(f"Error: the '{UNRELEASED_HEADING}' section of {CHANGELOG} is empty. Write the release notes first.")


def retitle_unreleased(new: str, released_on: str) -> None:
    """Rename Unreleased to the version being released, and open a fresh one."""
    text = CHANGELOG.read_text(encoding="utf-8")
    CHANGELOG.write_text(
        text.replace(UNRELEASED_HEADING, f"{UNRELEASED_HEADING}\n\n## [{new}] - {released_on}", 1),
        encoding="utf-8",
    )


def run(*command: str) -> None:
    try:
        subprocess.run(command, check=True)
    except FileNotFoundError:
        # A missing tool is a setup problem, and the traceback names it far
        # less clearly than this does.
        sys.exit(f"Error: {command[0]} is not on PATH")


def require_clean_worktree() -> None:
    """Refuse to release from a dirty tree.

    The release commit stages the two version files, and a failed upload rolls
    it back with ``reset --hard``. Both are only safe when nothing else is in
    flight.
    """
    result = subprocess.run(("git", "status", "--porcelain"), check=True, capture_output=True, text=True)
    if result.stdout.strip():
        sys.exit("Error: working tree is not clean. Commit or stash first.")


def main() -> int:
    parser = argparse.ArgumentParser(description="Publish spec-trace to PyPI")
    group = parser.add_mutually_exclusive_group()
    group.add_argument("--minor", action="store_true", help="bump the minor version")
    group.add_argument("--major", action="store_true", help="bump the major version")
    parser.add_argument("--dry-run", action="store_true", help="build only; do not publish or tag")
    args = parser.parse_args()

    if not PYPROJECT.exists():
        sys.exit("Error: run from the repository root")

    load_token_from_env_file()
    if not args.dry_run and not os.environ.get("UV_PUBLISH_TOKEN"):
        sys.exit("Error: UV_PUBLISH_TOKEN is not set (environment or .env)")

    require_clean_worktree()

    # Rebase first, so the commit this run tags is already in its final form.
    # A tag written before a rebase ends up on an orphaned commit.
    if not args.dry_run:
        run("mael", "sync", "--abort")

    # Before the gates, because it is the cheapest failure here and the most
    # annoying one to hit after a full gate run.
    require_release_notes()

    old = current_version()
    new = bump(old, "major" if args.major else "minor" if args.minor else "patch")
    print(f"Version {old} -> {new}")

    # Gates must pass before anything is published.
    run("bin/check")

    write_version(old, new)
    shutil.rmtree("dist", ignore_errors=True)
    run("uv", "build")

    if args.dry_run:
        print("Dry run: built but not published. Reverting the version bump.")
        write_version(new, old)
        return 0

    retitle_unreleased(new, date.today().isoformat())
    run("git", "add", str(PYPROJECT), str(INIT), str(LOCK), str(CHANGELOG))
    run("git", "commit", "-m", f"chore: release v{new}")

    # From here the commit exists but nothing is public yet. If the upload
    # fails, undo it so a re-run starts from the same place rather than
    # skipping a version number.
    try:
        run("uv", "publish")
    except subprocess.CalledProcessError:
        print(f"Publish failed. Rolling back the release commit for v{new}.", file=sys.stderr)
        try:
            run("git", "reset", "--hard", "HEAD~1")
        except subprocess.CalledProcessError:
            # Without this the reset failure stacks on the message above, and
            # the user cannot tell whether the release commit is still there.
            print(
                f"Rollback failed. The release commit for v{new} is still present.\n"
                "Remove it with: git reset --hard HEAD~1",
                file=sys.stderr,
            )
        return 1

    # Published, so the commit is now immutable in fact: tag it and push both
    # before anything can rewrite it.
    #
    # Rolling back is not an option past this point -- the upload succeeded, so
    # the version is spent. A push can still fail (origin/main moved during the
    # gate run, or auth expired), and the only useful thing left is to say what
    # state the release is in and how to finish it by hand.
    try:
        run("git", "tag", f"v{new}")
        run("git", "push")
        run("git", "push", "origin", f"v{new}")
    except subprocess.CalledProcessError:
        print(
            f"v{new} is published to PyPI, but the push failed. The release commit "
            f"and tag are local only.\nFinish with: git push && git push origin v{new}",
            file=sys.stderr,
        )
        return 1

    print(f"Published, tagged and pushed v{new}.")
    return 0


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