#!/usr/bin/env python3
"""Update a packaged source tree per the "Updating" section of PACKAGING.md."""

import atexit
import os
import re
import shutil
import subprocess
import sys

import click
import git

DEV_BRANCHES = ("main", "master", "dev", "develop", "development")


def die(msg, code=1):
    """Print *msg* to stderr and exit."""
    print(f"{sys.argv[0]}: {msg}", file=sys.stderr)
    sys.exit(code)


_VERBOSE = False


def say(*args):
    """Emit a progress line to stderr iff --verbose is in effect."""
    if _VERBOSE:
        print(*args, file=sys.stderr, flush=True)


def run_debchange(repo_path, version):
    """Invoke ``debchange`` once to record the merge and release it.

    Combines the version/massage/release into a single call so no editor
    is spawned::

        debchange -v VERSION -m "Merge main" -D smurf --force-distribution
    """
    cmd = [
        "debchange",
        "-v", version,
        "-m", "Merge main",
        "-D", "smurf",
        "--force-distribution",
    ]
    res = subprocess.run(cmd, cwd=repo_path,
                        stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
    if res.returncode != 0:
        sys.stderr.write(res.stdout)
        die(f"debchange failed (exit {res.returncode})")
    # debchange prints a benign "Recognised distributions ..." warning on
    # stderr/stdout when handed a non-stock distribution like "smurf";
    # swallow it so it does not pollute the normal transcript.


def get_ref_commit(repo, refname):
    """Return the Commit for *refname*, or raise KeyError if absent."""
    try:
        return repo.refs[refname].commit
    except (IndexError, KeyError):
        raise KeyError(refname)


def ref_exists(repo, refname):
    """True iff *refname* (e.g. ``refs/heads/foo``) resolves to a commit.

    ``git rev-parse --verify --quiet`` exits non-zero for a missing ref and
    GitPython turns that into a raised GitCommandError (ignoring ``--quiet``),
    so we catch it rather than parse output.
    """
    try:
        repo.git.rev_parse("--verify", "--quiet", refname)
    except git.exc.GitCommandError:
        return False
    return True


def iter_committish_tags(repo):
    """Yield ``(name, Commit)`` for every tag whose dereferenced object is a
    commit. Both lightweight tags (``tag.tag is None``) and annotated tags
    are supported; ``tag.commit`` fully peels an annotated tag down to its
    commit. Tags ultimately pointing at blobs/trees are skipped silently."""
    for tag in repo.tags:
        try:
            commit = tag.commit
        except Exception:
            continue            # tag points at a non-commit object
        if isinstance(commit, git.Commit):
            yield tag.name, commit


def looks_like_dev_tag(name):
    """True for tags that smell like a development/prerelease marker and
    thus must not be selected as the target upstream version.

    Catches trailing dev/alpha/beta/rc qualifiers with any separator and
    optional trailing digits/dots/underscores, e.g. 1.2.3-rc1,
    v2.0.0-beta.2, 1.2.3.dev0, 1.2.3-alpha.1."""
    low = name.lower()
    if low.endswith(("dev", "alpha", "beta", "rc", "pre", "preview")):
        return True
    return bool(re.search(r'(?:^|[\d.-])(?:dev|alpha|beta|rc|pre|preview)(?:[\d._]*)$', low))


def strip_v_prefix(name):
    """Strip a leading ``v`` (case-insensitive) from a tag name."""
    return name[1:] if name and name[0] in "vV" else name


def discover_target_upstream(repo):
    """Pick the latest non-development upstream tag reachable from one of
    origin/{main,master,dev[el[opment]]}; error out otherwise.

    Selection rule mirrors the documented intent ("latest non-development
    tag"): among candidate tags descending from a qualifying dev branch,
    prefer ones shaped like X.Y.Z / vX.Y.Z, exclude development markers,
    and choose the highest such version numerically. Recency alone would
    misorder dotted versions, so we compare parsed tuples, falling back to
    chronological tie-break by tag-object date.
    """

    def verscore(name):
        """Parse a stripped tag name into a comparable tuple of ints;
        return None if it isn't plain dot-separated digits."""
        core = strip_v_prefix(name)
        parts = []
        for part in core.split("."):
            if part.isdigit():
                parts.append(int(part))
            else:
                return None
        return tuple(parts) if parts else None

    candidates = []          # (verscore, tag_date, name, commit)
    seen_commits = set()
    for br in DEV_BRANCHES:
        ref = f"origin/{br}"
        try:
            tip = get_ref_commit(repo, ref)
        except KeyError:
            continue
        for name, commit in iter_committish_tags(repo):
            if not repo.is_ancestor(commit.hexsha, tip.hexsha):
                continue
            if looks_like_dev_tag(name):
                continue
            score = verscore(name)
            if score is None:
                continue           # ignore non-X.Y.Z tags outright
            if commit.hexsha in seen_commits:
                continue
            seen_commits.add(commit.hexsha)
            tag_date = commit.committed_date
            # annotated tag dates aren't easily portable; commit date is a
            # stable enough secondary key for ties.
            candidates.append((score, tag_date, name, commit))

    if not candidates:
        die("none of origin/{main,master,dev[el[opment]]} descend from a "
            "suitable non-development upstream tag")

    candidates.sort(key=lambda c: (c[0], c[1]))
    _, _, name, commit = candidates[-1]
    return name, commit


def ensure_clean(repo):
    if repo.is_dirty(index=True, working_tree=True,
                     untracked_files=True, submodules=True):
        die("working tree is not clean")


def safe_fetch(repo, remote_name):
    """Fetch *remote_name*; die concisely if the remote is unknown or the
    fetch fails."""
    if remote_name not in [rm.name for rm in repo.remotes]:
        die(f"no '{remote_name}' remote configured")
    try:
        repo.remotes[remote_name].fetch(prune=True)
    except git.exc.GitError as exc:
        die(f"git fetch {remote_name} failed: {exc}")


def safe_checkout(repo, branch):
    try:
        repo.git.checkout(branch)
    except git.exc.GitError as exc:
        die(f"checkout '{branch}' failed: {exc}")


def safe_merge(repo, refspec, label=None, no_ff=True):
    """Merge *refspec* into the current branch.

    Returns True if HEAD actually moved (a real merge / fast-forward
    happened), False if it was a genuine no-op (``Already up to date``).
    Dies on conflicts or unexpected failures.

    No-op detection is done by comparing HEAD's SHA before and after:
    ``git merge --no-ff <ref>`` exits 0 and leaves HEAD untouched when the
    ref is already contained, whereas a successful merge always moves
    HEAD (either via a fresh merge commit or a fast-forward). String
    sniffing of git's prose is brittle across locales/versions.

    ``no_ff`` governs whether ``--no-ff`` is passed: it is mandatory for
    merges *into* ``deb`` so that a fast-forward cannot discard the deb
    branch's packaging divergence; merges into ``main`` may fast-forward
    for tidier history.
    """
    what = label or refspec
    before = repo.head.commit.hexsha
    # --no-verify keeps site-wide commit hooks (e.g. pre-commit) from
    # hijacking an unattended update; matches the documented -n on amend.
    args = ["--no-verify"] + (["--no-ff"] if no_ff else []) + [refspec]
    try:
        repo.git.merge(*args)
    except git.exc.GitError as exc:
        die(f"merge {what} failed: {strip_giterror(str(exc))}")
    return repo.head.commit.hexsha != before


def strip_giterror(msg):
    """Reduce noisy GitError strings to something concise for humans."""
    m = re.search(r"stderr:\s*'(.*)'", msg, re.S)
    return (m.group(1).replace("\\n", "\n").strip() if m else msg.strip())


# --- concurrency infrastructure -------------------------------------------
#
# The update mutates two long-lived branches (``main`` and ``deb``) and runs
# side-effectful build/upload steps (``debch``). Two simultaneous invocations
# would race on the shared refs and clobber each other's uploads. We guard
# against that by doing all the work inside a dedicated git worktree and
# serializing on a lockfile living in that worktree's administrative area.
#
# Lockfile path note: in a linked worktree ``.git`` is a regular *file*
# (the gitdir pointer), so ``.git/merge-in.lock`` literally lands in the
# worktree's private admin directory (``<common>/.git/worktrees/<name>``).
# That is distinct from the ``.git/merge-in.lock`` that ``git-merge-in``
# writes in the *original* checkout (whose ``.git`` is the common dir), so
# the child ``git-merge-in`` invocation cannot self-deadlock against us.

WORKTREE_SUFFIX = "-update-wt"
LOCK_RELPATH = ".git/merge-in.lock"

# Held lock descriptor, populated by acquire_lock(); released on exit via
# the atexit hook installed alongside it.
_LOCK = {"path": None}


def _release_lock():
    """Drop the worktree lock acquired by :func:`acquire_lock` (atexit)."""
    if _LOCK["path"]:
        try:
            os.unlink(_LOCK["path"])
        except OSError:
            pass
        _LOCK["path"] = None


def acquire_lock(admin_dir):
    """Take ``admin_dir/.git/merge-in.lock`` via ``shlock``.

    ``shlock`` records the holder's PID and refuses to hand the lock out if
    the recorded PID is still alive, so a crashed predecessor is recovered
    from automatically. We stash the path for an atexit cleanup so the lock
    is dropped even on an untidy exit (exception, SIGTERM, ...).
    """
    lock_path = os.path.join(admin_dir, *LOCK_RELPATH.split("/"))
    os.makedirs(os.path.dirname(lock_path), exist_ok=True)
    if shutil.which("shlock") is None:
        die("'shlock' not found on PATH (needed for locking)")
    res = subprocess.run(["shlock", "-p", str(os.getpid()), "-f", lock_path])
    if res.returncode != 0:
        try:
            holder = open(lock_path).read().strip()
        except OSError:
            holder = "?"
        die(f"already running (lock held by PID {holder})")
    _LOCK["path"] = lock_path
    atexit.register(_release_lock)


def ensure_worktree(orig_repo):
    """Create (or reuse) a sibling worktree for staging the update.

    The worktree lives at ``<orig_top>-update-wt`` (a sibling directory of
    the original checkout) and starts life detached at TARGET's eventual base;
    callers check out concrete branches inside it. Reusing an existing
    worktree lets a crashed run leave its lock behind for the next one to
    pick up, and avoids littering the filesystem with stale trees.

    Returns ``(wt_repo, wt_path)`` where ``wt_repo`` is a GitPython handle
    rooted at the worktree and ``wt_path`` its absolute top level.
    """
    orig_top = orig_repo.working_tree_dir.rstrip("/")
    wt_path = orig_top + WORKTREE_SUFFIX
    # Which worktrees are currently registered? (Porcelain lines beginning
    # with "worktree " introduce each entry.)
    registered = [
        line[len("worktree "):]
        for line in orig_repo.git.worktree("list", "--porcelain").splitlines()
        if line.startswith("worktree ")
    ]
    if wt_path not in registered:
        # Prune any stale metadata for a vanished directory, then add ours.
        orig_repo.git.worktree("prune")
        try:
            orig_repo.git.worktree("add", "--detach", wt_path)
        except git.exc.GitError as exc:
            die(f"could not create worktree {wt_path}: {strip_giterror(str(exc))}")
    if not os.path.isdir(wt_path):
        die(f"worktree {wt_path} is registered but its directory is gone")
    try:
        wt_repo = git.Repo(wt_path)
    except git.exc.InvalidGitRepositoryError:
        die(f"worktree {wt_path} is not a git repository")
    return wt_repo, wt_path


def current_branch(path):
    """Return the branch checked out in the checkout at *path*, or None.

    None covers "not a repo", "detached HEAD", and any lookup failure --
    callers treat all three identically ("don't disturb the original dir").
    Uses plumbing so it never prints to stderr on a detached head.
    """
    try:
        out = subprocess.run(
            ["git", "symbolic-ref", "--short", "-q", "HEAD"],
            cwd=path, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True,
        )
    except (OSError, subprocess.SubprocessError):
        return None
    if out.returncode != 0:
        return None
    name = out.stdout.strip()
    return name or None


def prepare_update_branch(wt_repo, target):
    """Materialize ``<target>-update`` in the worktree as a copy of *target*.

    If the branch does not yet exist, create it from *target* and check it
    out. If it already exists (leftover from a prior interrupted run), check
    it out and merge *target* into it so any new commits on *target* are
    folded in before we proceed. Either way the worktree ends up on
    ``<target>-update``.
    """
    upd = f"{target}-update"
    has_branch = ref_exists(wt_repo, f"refs/heads/{upd}")
    if not has_branch:
        say(f"creating {upd} from {target}")
        try:
            wt_repo.git.checkout("-b", upd, target)
        except git.exc.GitError as exc:
            die(f"checkout -b {upd} {target} failed: {strip_giterror(str(exc))}")
    else:
        say(f"reusing existing {upd}; merging {target}")
        try:
            wt_repo.git.checkout(upd)
        except git.exc.GitError as exc:
            die(f"checkout {upd} failed: {strip_giterror(str(exc))}")
        safe_merge(wt_repo, target, label=target, no_ff=False)


def finish_target(wt_repo, orig_top, target):
    """Publish ``<target>-update`` back onto *target* and retire the branch.

    Two cases, per the assignment:

    * the original checkout currently has *target* checked out -- run
      ``git-merge-in <target>-update`` *there*. ``git-merge-in`` does a
      fast-forward merge under its own ``.git/merge-in.lock``, so the move
      is atomic and serialized against anyone touching that checkout.
    * otherwise -- check out *target* in the worktree and fast-forward
      merge ``<target>-update`` into it ourselves, leaving the worktree
      on *target* (handy: a subsequent debch then runs on a real branch).

    On success the temporary ``<target>-update`` branch is deleted. Any
    failure dies loudly, leaving ``<target>-update`` intact for inspection.
    """
    upd = f"{target}-update"
    orig_here = current_branch(orig_top) == target
    if orig_here:
        say(f"running git-merge-in {upd} in {orig_top}")
        res = subprocess.run(["git-merge-in", upd], cwd=orig_top)
        if res.returncode != 0:
            die(f"git-merge-in {upd} failed (exit {res.returncode}); "
                f"{upd} left for inspection")
        # The worktree is still parked on {upd} (only the original dir
        # moved); git refuses to delete a branch checked out in any
        # worktree, so detach first.
        try:
            wt_repo.git.checkout("--detach")
        except git.exc.GitError:
            pass
    else:
        say(f"checking out {target} in worktree and fast-forwarding {upd}")
        # Safe precisely because orig_here is False: *target* is not pinned
        # by the original checkout, so the worktree may borrow it. After
        # the fast-forward we sit on *target* (not on upd), so the branch
        # deletion below is legal WITHOUT detaching -- and staying on
        # *target* lets a later debch run on a real branch, not detached.
        try:
            wt_repo.git.checkout(target)
        except git.exc.GitError as exc:
            die(f"checkout {target} in worktree failed: {strip_giterror(str(exc))}")
        try:
            wt_repo.git.merge("--ff-only", upd)
        except git.exc.GitError as exc:
            die(f"fast-forward merge of {upd} into {target} failed: "
                f"{strip_giterror(str(exc))}; {upd} left for inspection")
    say(f"deleting branch {upd}")
    try:
        wt_repo.git.branch("-D", upd)
    except git.exc.GitError as exc:
        die(f"could not delete {upd}: {strip_giterror(str(exc))}")


@click.command()
@click.option("-C", "--repo", "repo_path", metavar="PATH", default=None,
              help="repository to operate on (default: cwd's enclosing repo)")
@click.option("-v", "--verbose", is_flag=True,
              help="narrate each step as it runs.")
def main(repo_path, verbose):
    """Update a packaged source tree per the "Updating" section of PACKAGING.md.

    Operates on the git repository containing the current working directory
    (or the one given with -C/--repo). Uses the ``git`` Python package (never
    spawns git through subprocess directly), except for ``debchange``, which is
    an external program and therefore run via :mod:`subprocess`.

    Each step aborts with a concise error message if it fails in a way that
    cannot trivially be fixed.

    Concurrency safety: all mutation happens inside a dedicated worktree
    (``<top>-update-wt``) guarded by a PID-stamped lockfile, so two
    simultaneous invocations serialize instead of racing on the shared
    ``main``/``deb`` refs. See the module-level comment near
    :func:`acquire_lock` for the lock-path rationale.
    """
    global _VERBOSE
    _VERBOSE = verbose
    try:
        repo = git.Repo(repo_path, search_parent_directories=repo_path is None)
    except git.exc.NoSuchPathError:
        die(f"no such path: {repo_path}")
    except git.exc.InvalidGitRepositoryError:
        die("not inside a git repository")
    orig_top = repo.working_tree_dir.rstrip("/")
    # Short-SHA width follows the repo's configured abbreviation rather
    # than assuming git's default of 7 (core.abbrev / auto-growth).
    short_len = len(repo.git.rev_parse("--short", "HEAD"))

    # Step 1: ensure the ORIGINAL tree is clean ----------------------------
    # Only the original checkout matters for cleanliness; the worktree is
    # scratch space we fully control. We re-check it before publishing too.
    say("step 1: ensuring working tree is clean")
    ensure_clean(repo)

    # Spin up (or reattach to) the staging worktree, then take the lock
    # that serializes all concurrent update-this runs on this repository.
    say("setting up update worktree")
    wt_repo, wt_path = ensure_worktree(repo)
    say(f"worktree at {wt_path}")
    acquire_lock(wt_repo.git_dir)
    say(f"locked {os.path.relpath(_LOCK['path'], wt_path)} (pid {os.getpid()})")

    # Steps 2 & 3: fetch remotes -------------------------------------------
    say("step 2-3: fetching remotes (origin, intern)")
    safe_fetch(repo, "origin")
    safe_fetch(repo, "intern")

    # Step 4: discover the target upstream version -------------------------
    tag_name, target_commit = discover_target_upstream(repo)
    say(f"step 4: target upstream tag {tag_name} "
        f"@ {target_commit.hexsha[:short_len]}")

    # Step 5: early-exit if deb already descends from target AND equals
    # intern/deb ------------------------------------------------------------
    say("step 5: checking whether deb is already up to date")
    try:
        deb_head = get_ref_commit(repo, "deb")
    except KeyError:
        die("'deb' branch does not exist")
    try:
        intern_deb = get_ref_commit(repo, "intern/deb")
    except KeyError:
        die("'intern/deb' does not exist (did you fetch?)")
    if repo.is_ancestor(target_commit.hexsha, deb_head.hexsha) \
            and deb_head.hexsha == intern_deb.hexsha:
        say("nothing to do")
        print("nothing to do")
        return

    # Steps 6-8: advance main ----------------------------------------------
    say("steps 6-8: advancing main")
    prepare_update_branch(wt_repo, "main")
    say("step 7: merging intern/main")
    safe_merge(wt_repo, "intern/main", label="intern/main", no_ff=False)
    say(f"step 8: merging target {tag_name}")
    safe_merge(wt_repo, tag_name, label=f"target {tag_name}", no_ff=False)
    finish_target(wt_repo, orig_top, "main")

    # Steps 9-13: advance deb -----------------------------------------------
    say("steps 9-13: advancing deb")
    prepare_update_branch(wt_repo, "deb")
    say("step 10: merging intern/deb")
    safe_merge(wt_repo, "intern/deb", label="intern/deb", no_ff=True)
    say("step 11: merging main")
    merged_main = safe_merge(wt_repo, "main", label="main")
    if not merged_main:
        # Nothing new flowed from main; roll back the speculative branch
        # and report, mirroring the original early-exit semantics. Detach
        # first so we are free to delete the branch we stand on.
        say("nothing to do")
        try:
            wt_repo.git.checkout("--detach")
            wt_repo.git.branch("-D", "deb-update")
        except git.exc.GitError:
            pass
        print("nothing to do")
        return

    # Step 12: update debian/changelog --------------------------------------
    # Compute VNR = <upstream version> "." <subversion>, with the
    # subversion N reset to 0 on an upstream change. The upstream version
    # is taken straight from the target tag (leading v/V stripped),
    # PRESERVING ITS NATURAL COMPONENT COUNT -- the doc's "A.B.C.N" is the
    # common 3-component case, but it also blesses date schemes as
    # "YYYY.MM.0" (2-component) and this very repo uses 2-component
    # upstream tags (1.524 -> 1.524.1). Padding everyone to 3 components
    # would corrupt those, so we simply append ".0" to the tagged version.
    core = strip_v_prefix(tag_name)
    parts = core.split(".")
    if all(p.isdigit() for p in parts):
        vnr = core + ".0"
    else:
        # Non-numeric / date-ish tag: derive the upstream portion from the
        # current changelog version by dropping its trailing subversion
        # component, then reset N to 0 (e.g. 1.524.1 -> 1.524.0).
        cur = subprocess.run(["dpkg-parsechangelog", "-S", "Version"],
                             cwd=wt_path, stdout=subprocess.PIPE, text=True)
        if cur.returncode != 0:
            die("cannot read current changelog version for VNR")
        m = re.match(r'^(.*)\.[0-9]+$', cur.stdout.strip())
        if not m:
            die(f"cannot derive VNR from non-numeric tag {tag_name!r}")
        vnr = m.group(1) + ".0"

    say(f"step 12: updating debian/changelog (VNR={vnr})")
    run_debchange(wt_path, vnr)

    # Fold the changelog edit into the merge commit (matches the documented
    # ``git commit --amend --no-edit -n debian/changelog``).
    try:
        wt_repo.git.commit("--amend", "--no-edit", "-n", "--no-verify",
                           "debian/changelog")
    except git.exc.GitError as exc:
        die(f"commit --amend failed: {strip_giterror(str(exc))}")

    # Publish deb before the destructive build step, so a debch crash
    # leaves the integration commit safely on the deb branch.
    finish_target(wt_repo, orig_top, "deb")

    # Step 13 (last actionable step): run debch -----------------------------
    # NOTE: per the assignment, the trailing "Analyze output, fix errors"
    # human step is intentionally omitted here. debch builds/uploads the
    # branch it is invoked on, so it must run in a checkout that currently
    # has *deb* checked out. After finish_target("deb") that is either the
    # original dir (if it started on deb, advanced in place by git-merge-in)
    # or the worktree (where we fast-forwarded deb ourselves).
    say("step 13: running debch")
    deb_cwd = orig_top if current_branch(orig_top) == "deb" else wt_path
    res = subprocess.run(["debch"], cwd=deb_cwd)
    if res.returncode != 0:
        die(f"debch failed (exit {res.returncode})")

    say("done")
    print("OK")


if __name__ == "__main__":
    main()
