"""Release handlers: validate_release (#107), release_project (#106).

Both tools operate on the project_dir they're invoked against. The
repo-role detector (`_detect_repo_role`) decides which checklist to
run — skill-repo vs mcp-repo — so the same tools serve both release
surfaces per the #102 coordinated-minor protocol.
"""
from __future__ import annotations

import re
import subprocess
import tempfile
from pathlib import Path

from mcp.types import TextContent

from ...context import ServerContext
from ...github import releases as releases_mod
from ...parsers import parse_changelog, parse_owner_repo, parse_skill_version
# Late binding — see helpers.py docstring.
from active_claude_github_mcp import server as _s
from ..helpers import ok, resolve_local_skill_path


_BAZEL_TIMEOUT_SECONDS = 600
_MCP_PACKAGE_NAME = "active-claude-github-mcp"

# [project].version = "X.Y.Z"  — PEP 621 declaration in pyproject.toml
_PYPROJECT_VERSION_RE = re.compile(
    r"^version\s*=\s*[\"']([0-9]+(?:\.[0-9]+){1,2})[\"']\s*$", re.MULTILINE
)

_INIT_VERSION_RE = re.compile(
    r"^__version__\s*=\s*[\"']([0-9]+(?:\.[0-9]+){1,2})[\"']\s*$", re.MULTILINE
)


def _check(name: str, ok_: bool, severity: str, detail: str) -> dict:
    return {"name": name, "ok": ok_, "severity": severity, "detail": detail}


def _detect_repo_role(project_dir: Path) -> str:
    """Return "skill" or "mcp" based on what artefacts the repo owns.

    Raises ValueError if the directory is neither — `validate_release`
    is a no-op on arbitrary repos.
    """
    if resolve_local_skill_path(project_dir) is not None:
        return "skill"

    pyproject = project_dir / "pyproject.toml"
    if pyproject.exists():
        try:
            text = pyproject.read_text(encoding="utf-8")
        except OSError as exc:
            raise ValueError(f"Cannot read {pyproject}: {exc}") from exc
        if _MCP_PACKAGE_NAME in text:
            return "mcp"

    raise ValueError(
        f"project_dir {project_dir} is neither a skill repo (no "
        "active-claude-github.md) nor the mcp repo (no pyproject.toml "
        f"with package {_MCP_PACKAGE_NAME!r}). validate_release can "
        "only run in a known release target."
    )


def _parse_pyproject_version(pyproject_text: str) -> str | None:
    m = _PYPROJECT_VERSION_RE.search(pyproject_text)
    return m.group(1) if m else None


def _parse_init_version(init_text: str) -> str | None:
    m = _INIT_VERSION_RE.search(init_text)
    return m.group(1) if m else None


# ----- Individual checks ---------------------------------------------------


def _check_milestone_closed(
    token: str, owner: str, repo: str, version: str
) -> dict:
    milestone = _s.get_milestone_by_title(token, owner, repo, version)
    if milestone is None:
        return _check(
            "milestone_closed",
            False,
            "fail",
            f"No milestone titled {version!r} exists on {owner}/{repo}.",
        )
    open_issues = milestone["open_issues"]
    if open_issues:
        summary = ", ".join(f"#{i['number']}" for i in open_issues[:10])
        suffix = "" if len(open_issues) <= 10 else f" (+{len(open_issues) - 10} more)"
        return _check(
            "milestone_closed",
            False,
            "fail",
            f"Milestone {version} has {len(open_issues)} open "
            f"Issue(s): {summary}{suffix}.",
        )
    if milestone["state"] != "CLOSED":
        return _check(
            "milestone_closed",
            True,
            "fail",
            f"All Issues closed; milestone #{milestone['number']} "
            "itself still OPEN (cosmetic — will close when release tag is cut).",
        )
    return _check(
        "milestone_closed",
        True,
        "fail",
        f"Milestone {version} closed with no open Issues.",
    )


def _check_working_tree_clean(project_dir: Path) -> dict:
    try:
        status = subprocess.run(
            ["git", "status", "--porcelain"],
            cwd=project_dir,
            capture_output=True,
            text=True,
            timeout=30,
            check=False,
        )
    except (FileNotFoundError, subprocess.TimeoutExpired) as exc:
        return _check(
            "working_tree_clean",
            False,
            "fail",
            f"git invocation failed: {exc}",
        )

    if status.returncode != 0:
        return _check(
            "working_tree_clean",
            False,
            "fail",
            f"git status exited {status.returncode}: {status.stderr.strip()}",
        )
    if status.stdout.strip():
        lines = status.stdout.strip().splitlines()
        return _check(
            "working_tree_clean",
            False,
            "fail",
            f"{len(lines)} uncommitted change(s): {lines[0]}"
            + ("" if len(lines) == 1 else f" (+{len(lines) - 1} more)"),
        )

    branch = subprocess.run(
        ["git", "rev-parse", "--abbrev-ref", "HEAD"],
        cwd=project_dir,
        capture_output=True,
        text=True,
        timeout=10,
        check=False,
    )
    if branch.stdout.strip() != "main":
        return _check(
            "working_tree_clean",
            False,
            "fail",
            f"HEAD is on {branch.stdout.strip()!r}; release must cut from main.",
        )

    local_head = subprocess.run(
        ["git", "rev-parse", "HEAD"],
        cwd=project_dir,
        capture_output=True,
        text=True,
        timeout=10,
        check=False,
    )
    remote_head = subprocess.run(
        ["git", "rev-parse", "origin/main"],
        cwd=project_dir,
        capture_output=True,
        text=True,
        timeout=10,
        check=False,
    )
    if remote_head.returncode != 0:
        return _check(
            "working_tree_clean",
            False,
            "fail",
            "origin/main not found locally — run `git fetch origin`.",
        )
    if local_head.stdout.strip() != remote_head.stdout.strip():
        return _check(
            "working_tree_clean",
            False,
            "fail",
            f"HEAD {local_head.stdout.strip()[:8]} != "
            f"origin/main {remote_head.stdout.strip()[:8]}; push or pull first.",
        )

    return _check(
        "working_tree_clean",
        True,
        "fail",
        f"Clean working tree on main at {local_head.stdout.strip()[:8]}.",
    )


def _has_bazel_workspace(project_dir: Path) -> bool:
    return any(
        (project_dir / fname).is_file()
        for fname in ("MODULE.bazel", "WORKSPACE", "WORKSPACE.bazel")
    )


def _check_bazel_tests_green(project_dir: Path, skip: bool) -> dict:
    if skip:
        return _check(
            "bazel_tests_green",
            True,
            "warn",
            "Skipped (`skip_bazel=True`). Run `bazel test //...` manually "
            "before tagging.",
        )
    if not _has_bazel_workspace(project_dir):
        return _check(
            "bazel_tests_green",
            True,
            "warn",
            "No Bazel workspace at project_dir (no MODULE.bazel/WORKSPACE); "
            "check N/A for this repo role.",
        )
    try:
        result = subprocess.run(
            ["bazel", "test", "//..."],
            cwd=project_dir,
            capture_output=True,
            text=True,
            timeout=_BAZEL_TIMEOUT_SECONDS,
            check=False,
        )
    except FileNotFoundError:
        return _check(
            "bazel_tests_green",
            False,
            "fail",
            "bazel not on PATH. Install bazelisk or pass skip_bazel=True.",
        )
    except subprocess.TimeoutExpired:
        return _check(
            "bazel_tests_green",
            False,
            "fail",
            f"`bazel test //...` exceeded {_BAZEL_TIMEOUT_SECONDS}s timeout.",
        )
    if result.returncode != 0:
        tail = "\n".join(result.stderr.splitlines()[-10:])
        return _check(
            "bazel_tests_green",
            False,
            "fail",
            f"`bazel test //...` exited {result.returncode}. stderr tail:\n{tail}",
        )
    return _check(
        "bazel_tests_green", True, "fail", "`bazel test //...` green."
    )


def _check_pending_buffer_empty(project_dir: Path) -> dict:
    buffer = project_dir / ".claude" / "pending-posts.md"
    if not buffer.exists():
        return _check(
            "pending_buffer_empty",
            True,
            "fail",
            ".claude/pending-posts.md absent (no buffered ops).",
        )
    try:
        content = buffer.read_text(encoding="utf-8").strip()
    except OSError as exc:
        return _check(
            "pending_buffer_empty",
            False,
            "fail",
            f"Cannot read {buffer}: {exc}",
        )
    if content:
        return _check(
            "pending_buffer_empty",
            False,
            "fail",
            "pending-posts.md non-empty; run replay_pending first.",
        )
    return _check(
        "pending_buffer_empty", True, "fail", "pending-posts.md empty."
    )


def _check_skill_version(project_dir: Path, target: str) -> dict:
    skill_path = resolve_local_skill_path(project_dir)
    if skill_path is None:
        return _check(
            "skill_version_matches",
            False,
            "fail",
            "No active-claude-github.md found.",
        )
    try:
        text = skill_path.read_text(encoding="utf-8")
    except OSError as exc:
        return _check(
            "skill_version_matches",
            False,
            "fail",
            f"Cannot read {skill_path}: {exc}",
        )
    installed = parse_skill_version(text)
    if installed == target:
        return _check(
            "skill_version_matches",
            True,
            "fail",
            f"Skill **Version:** header == {target}.",
        )
    return _check(
        "skill_version_matches",
        False,
        "fail",
        f"Skill header is {installed!r}; expected {target!r}.",
    )


def _check_skill_changelog_entry(project_dir: Path, target: str) -> dict:
    skill_path = resolve_local_skill_path(project_dir)
    if skill_path is None:
        return _check(
            "skill_changelog_entry_exists",
            False,
            "fail",
            "No active-claude-github.md found.",
        )
    try:
        text = skill_path.read_text(encoding="utf-8")
    except OSError as exc:
        return _check(
            "skill_changelog_entry_exists",
            False,
            "fail",
            f"Cannot read {skill_path}: {exc}",
        )
    entries = parse_changelog(text)
    for e in entries:
        if e["version"] == target:
            return _check(
                "skill_changelog_entry_exists",
                True,
                "fail",
                f"Changelog entry for {target}: {e['summary'][:60]}",
            )
    return _check(
        "skill_changelog_entry_exists",
        False,
        "fail",
        f"No changelog entry for {target} in active-claude-github.md.",
    )


def _check_pyproject_version(project_dir: Path, target: str) -> dict:
    pyproject = project_dir / "pyproject.toml"
    if not pyproject.exists():
        return _check(
            "pyproject_version_matches",
            False,
            "fail",
            "No pyproject.toml found.",
        )
    try:
        text = pyproject.read_text(encoding="utf-8")
    except OSError as exc:
        return _check(
            "pyproject_version_matches",
            False,
            "fail",
            f"Cannot read {pyproject}: {exc}",
        )
    found = _parse_pyproject_version(text)
    if found == target:
        return _check(
            "pyproject_version_matches",
            True,
            "fail",
            f"pyproject.toml version == {target}.",
        )
    return _check(
        "pyproject_version_matches",
        False,
        "fail",
        f"pyproject.toml version is {found!r}; expected {target!r}.",
    )


def _check_package_version(project_dir: Path, target: str) -> dict:
    candidates = (
        project_dir / "active_claude_github_mcp" / "__init__.py",
        project_dir / "src" / "active_claude_github_mcp" / "__init__.py",
    )
    for init_path in candidates:
        if init_path.exists():
            try:
                text = init_path.read_text(encoding="utf-8")
            except OSError as exc:
                return _check(
                    "package_version_matches",
                    False,
                    "fail",
                    f"Cannot read {init_path}: {exc}",
                )
            found = _parse_init_version(text)
            if found == target:
                return _check(
                    "package_version_matches",
                    True,
                    "fail",
                    f"__version__ == {target}.",
                )
            return _check(
                "package_version_matches",
                False,
                "fail",
                f"__version__ is {found!r}; expected {target!r}.",
            )
    return _check(
        "package_version_matches",
        False,
        "fail",
        "active_claude_github_mcp/__init__.py not found.",
    )


def _check_changelog_regenerated(project_dir: Path, target: str) -> dict:
    """CHANGELOG.md's newest version header equals `target` (warn-only until #108 ships)."""
    changelog = project_dir / "CHANGELOG.md"
    if not changelog.exists():
        return _check(
            "changelog_regenerated",
            True,
            "warn",
            "CHANGELOG.md not present (#108 generator has not shipped yet).",
        )
    try:
        text = changelog.read_text(encoding="utf-8")
    except OSError as exc:
        return _check(
            "changelog_regenerated",
            False,
            "warn",
            f"Cannot read {changelog}: {exc}",
        )
    # Look for first ## <version> or ## vX.Y.Z header
    m = re.search(
        r"^##\s+v?([0-9]+(?:\.[0-9]+){1,2})\b", text, re.MULTILINE
    )
    found = m.group(1) if m else None
    if found == target:
        return _check(
            "changelog_regenerated",
            True,
            "warn",
            f"CHANGELOG.md newest entry == {target}.",
        )
    return _check(
        "changelog_regenerated",
        False,
        "warn",
        f"CHANGELOG.md newest entry is {found!r}; expected {target!r}. "
        "Re-run the generator (#108).",
    )


# ----- Public handler ------------------------------------------------------


async def validate_release(
    ctx: ServerContext,
    version: str,
    skip_bazel: bool = False,
) -> list[TextContent]:
    """#107 — read-only pre-flight check runner for a target release version."""
    if not version.strip():
        raise ValueError("version must not be empty.")

    repo_role = _detect_repo_role(ctx.project_dir)

    # Milestone lookup always targets the authoritative dev repo (#43).
    owner, dev_repo = parse_owner_repo(ctx.config.repos.private)

    checks: list[dict] = []
    checks.append(_check_milestone_closed(ctx.github_token, owner, dev_repo, version))
    checks.append(_check_working_tree_clean(ctx.project_dir))
    checks.append(_check_bazel_tests_green(ctx.project_dir, skip_bazel))
    checks.append(_check_pending_buffer_empty(ctx.project_dir))

    if repo_role == "skill":
        checks.append(_check_skill_version(ctx.project_dir, version))
        checks.append(_check_skill_changelog_entry(ctx.project_dir, version))
    else:  # mcp
        checks.append(_check_pyproject_version(ctx.project_dir, version))
        checks.append(_check_package_version(ctx.project_dir, version))

    checks.append(_check_changelog_regenerated(ctx.project_dir, version))

    ready = all(c["ok"] for c in checks if c["severity"] == "fail")

    return ok(
        {
            "version": version,
            "ready": ready,
            "repo_role": repo_role,
            "checks": checks,
        }
    )


# ====== release_project (#106) =============================================
#
# Orchestrator. Calls validate_release, then runs five steps in sequence.
# Hard-gated on validate_release; no force override. Defaults to dry-run.
# A `stop_after` knob lets the operator cut the dev tag without touching
# the public mirror — useful when the mirror isn't ready yet.

_VERSION_BUMP_CHECK_NAMES = frozenset({
    "skill_version_matches",
    "pyproject_version_matches",
    "package_version_matches",
})

# Curated-subset exclusions per #102 Q1 decision. Glob-matched against
# `git ls-files` output before staging the squashed mirror commit.
_SKILL_EXCLUDE_PATTERNS: tuple[str, ...] = (
    ".claude",
)
_MCP_EXCLUDE_PATTERNS: tuple[str, ...] = (
    ".claude",
    "tests/integration/live",
)

_VALID_STOP_AFTER = ("validate", "dev_tag", "mirror_sync", "release_notes")

_SKILL_VERSION_LINE_RE = re.compile(
    r"^(\*\*Version:\*\*\s*)([0-9]+(?:\.[0-9]+){1,2})(\s*)$",
    re.MULTILINE,
)


def _step(name: str, status: str, detail) -> dict:
    return {"name": name, "status": status, "detail": detail}


# ----- Version-bump propose / apply ----------------------------------------


def _propose_version_bump_edits(
    project_dir: Path, repo_role: str, target: str
) -> list[dict]:
    """Compute the file edits needed to bring version artefacts to `target`.

    Returns a list of ``{"path": str, "before": str, "after": str}`` entries
    for the files whose version is currently != target. Empty list means
    no edits required.
    """
    edits: list[dict] = []
    if repo_role == "skill":
        skill_path = resolve_local_skill_path(project_dir)
        if skill_path is None:
            return edits
        text = skill_path.read_text(encoding="utf-8")
        m = _SKILL_VERSION_LINE_RE.search(text)
        if m and m.group(2) != target:
            new_text = (
                text[: m.start(2)] + target + text[m.end(2) :]
            )
            edits.append(
                {
                    "path": str(skill_path.relative_to(project_dir)),
                    "before": m.group(2),
                    "after": target,
                    "new_content": new_text,
                }
            )
        return edits

    # mcp role
    pyproject = project_dir / "pyproject.toml"
    if pyproject.exists():
        text = pyproject.read_text(encoding="utf-8")
        m = _PYPROJECT_VERSION_RE.search(text)
        if m and m.group(1) != target:
            new_text = text[: m.start(1)] + target + text[m.end(1) :]
            edits.append(
                {
                    "path": "pyproject.toml",
                    "before": m.group(1),
                    "after": target,
                    "new_content": new_text,
                }
            )

    for init_rel in (
        Path("active_claude_github_mcp") / "__init__.py",
        Path("src") / "active_claude_github_mcp" / "__init__.py",
    ):
        init_path = project_dir / init_rel
        if init_path.exists() and not init_path.is_symlink():
            text = init_path.read_text(encoding="utf-8")
            m = _INIT_VERSION_RE.search(text)
            if m and m.group(1) != target:
                new_text = text[: m.start(1)] + target + text[m.end(1) :]
                edits.append(
                    {
                        "path": str(init_rel),
                        "before": m.group(1),
                        "after": target,
                        "new_content": new_text,
                    }
                )
            # Only one __init__ exists in either layout; stop at first hit
            break
    return edits


def _apply_version_bump_edits(project_dir: Path, edits: list[dict]) -> None:
    for edit in edits:
        path = project_dir / edit["path"]
        path.write_text(edit["new_content"], encoding="utf-8")


# ----- Release notes / CHANGELOG.md ----------------------------------------


def _release_notes_for_version(project_dir: Path, version: str) -> str | None:
    """Extract the changelog entry for `version` from the skill .md.

    Resolves the skill file via the standard candidate paths. For mcp-repo
    callers the operator can keep a copy at .claude/skills/active-claude-
    github.md (or symlink it); release_project does not require the skill
    file to live in the project being released — just to be reachable.
    """
    skill_path = resolve_local_skill_path(project_dir)
    if skill_path is None:
        return None
    try:
        text = skill_path.read_text(encoding="utf-8")
    except OSError:
        return None
    for entry in parse_changelog(text):
        if entry["version"] == version:
            return f"## v{version} ({entry['date']})\n\n{entry['summary']}\n"
    return None


def _generate_changelog_md(project_dir: Path) -> str | None:
    """Return CHANGELOG.md text generated from the skill changelog, or None."""
    skill_path = resolve_local_skill_path(project_dir)
    if skill_path is None:
        return None
    try:
        text = skill_path.read_text(encoding="utf-8")
    except OSError:
        return None
    entries = parse_changelog(text)
    if not entries:
        return None
    lines = ["# Changelog", ""]
    for entry in entries:
        lines.append(f"## v{entry['version']} ({entry['date']})")
        lines.append("")
        lines.append(entry["summary"])
        lines.append("")
    return "\n".join(lines).rstrip() + "\n"


async def generate_changelog(
    ctx: ServerContext,
    write: bool = False,
) -> list[TextContent]:
    """#108 — regenerate CHANGELOG.md from the skill changelog.

    Two modes:
    - `write=False` (default): preview only — return the generated text
      without touching disk. Safe to call any time.
    - `write=True`: write the generated text to ``CHANGELOG.md`` at
      the project root, replacing whatever was there.

    `release_project` also auto-populates `CHANGELOG.md` from the same
    helper when the file is absent (#106 decision 1). This standalone
    tool covers the mid-cycle case where the operator wants to refresh
    the file without going through validate.
    """
    generated = _generate_changelog_md(ctx.project_dir)
    if generated is None:
        return ok(
            {
                "ok": False,
                "wrote": False,
                "path": "CHANGELOG.md",
                "preview": None,
                "error": (
                    "No skill file or no changelog entries — generator "
                    "needs an active-claude-github.md with a populated "
                    "## Changelog section."
                ),
            }
        )
    target = ctx.project_dir / "CHANGELOG.md"
    if write:
        target.write_text(generated, encoding="utf-8")
    return ok(
        {
            "ok": True,
            "wrote": write,
            "path": "CHANGELOG.md",
            "preview": generated,
        }
    )


# ----- Curated subset / mirror sync ---------------------------------------


def _exclude_patterns_for(repo_role: str) -> tuple[str, ...]:
    return _SKILL_EXCLUDE_PATTERNS if repo_role == "skill" else _MCP_EXCLUDE_PATTERNS


def _is_excluded(rel_path: str, exclusions: tuple[str, ...]) -> bool:
    for pattern in exclusions:
        if rel_path == pattern or rel_path.startswith(pattern + "/"):
            return True
    return False


def _curated_file_list(project_dir: Path, repo_role: str) -> tuple[list[str], list[str]]:
    """Split tracked-file list into (included, excluded) paths."""
    exclusions = _exclude_patterns_for(repo_role)
    tracked = releases_mod.list_tracked_files(project_dir)
    included = [p for p in tracked if not _is_excluded(p, exclusions)]
    excluded = [p for p in tracked if _is_excluded(p, exclusions)]
    return included, excluded


# ----- Step runners --------------------------------------------------------


def _run_validate(
    ctx: ServerContext, version: str, skip_bazel: bool
) -> tuple[dict, list[dict], str]:
    """Run validate_release and return (step, full_validate_data, repo_role)."""
    repo_role = _detect_repo_role(ctx.project_dir)
    owner, dev_repo = parse_owner_repo(ctx.config.repos.private)

    checks: list[dict] = []
    checks.append(_check_milestone_closed(ctx.github_token, owner, dev_repo, version))
    checks.append(_check_working_tree_clean(ctx.project_dir))
    checks.append(_check_bazel_tests_green(ctx.project_dir, skip_bazel))
    checks.append(_check_pending_buffer_empty(ctx.project_dir))
    if repo_role == "skill":
        checks.append(_check_skill_version(ctx.project_dir, version))
        checks.append(_check_skill_changelog_entry(ctx.project_dir, version))
    else:
        checks.append(_check_pyproject_version(ctx.project_dir, version))
        checks.append(_check_package_version(ctx.project_dir, version))
    checks.append(_check_changelog_regenerated(ctx.project_dir, version))

    fail_checks = [c for c in checks if c["severity"] == "fail"]
    ready = all(c["ok"] for c in fail_checks)

    if ready:
        step = _step("validate", "ok", {"checks": checks})
    else:
        step = _step(
            "validate",
            "failed",
            {
                "failed_checks": [c for c in fail_checks if not c["ok"]],
                "all_checks": checks,
            },
        )
    return step, checks, repo_role


def _failures_are_only_version_bumps(checks: list[dict]) -> bool:
    """True iff every failing fail-severity check is a version-line check."""
    failing = [c for c in checks if c["severity"] == "fail" and not c["ok"]]
    if not failing:
        return False
    return all(c["name"] in _VERSION_BUMP_CHECK_NAMES for c in failing)


def _run_dev_tag(
    ctx: ServerContext, version: str, notes: str, dry_run: bool
) -> dict:
    tag_name = f"v{version}"
    sha = releases_mod.head_sha(ctx.project_dir)
    if dry_run:
        return _step(
            "dev_tag",
            "dry_run_preview",
            {
                "tag": tag_name,
                "sha": sha,
                "message_preview": notes.splitlines()[0] if notes else "",
                "would_push": "origin",
            },
        )
    try:
        releases_mod.create_annotated_tag(ctx.project_dir, tag_name, notes)
        releases_mod.push_tag(ctx.project_dir, "origin", tag_name)
    except releases_mod.ReleaseCommandError as exc:
        return _step("dev_tag", "failed", {"error": str(exc)})
    return _step("dev_tag", "ok", {"tag": tag_name, "sha": sha})


def _stage_curated_tree(project_dir: Path, repo_role: str, dest: Path) -> dict:
    """Copy the curated subset of `project_dir` into `dest`. Return a summary dict."""
    included, excluded = _curated_file_list(project_dir, repo_role)
    for rel in included:
        src = project_dir / rel
        if not src.exists():
            continue
        out = dest / rel
        out.parent.mkdir(parents=True, exist_ok=True)
        # follow_symlinks=False preserves the symlink-based src/ layout
        # inside the dev repo without dragging the whole src/ tree across
        # the mirror.
        try:
            with open(src, "rb") as f_in:
                content = f_in.read()
        except OSError:
            content = b""
        out.write_bytes(content)
    return {"files_included": len(included), "files_excluded": len(excluded)}


def _run_mirror_sync(
    ctx: ServerContext,
    version: str,
    notes: str,
    repo_role: str,
    dry_run: bool,
) -> tuple[dict, Path | None]:
    """Build the curated squashed commit and push to the public mirror.

    Returns (step, mirror_clone_path). The clone path is reused by the
    mirror_tag step so we don't reclone.
    """
    mirror_url = f"https://github.com/{ctx.config.repos.public}.git"
    included, excluded = _curated_file_list(ctx.project_dir, repo_role)
    if dry_run:
        return (
            _step(
                "mirror_sync",
                "dry_run_preview",
                {
                    "mirror_url": mirror_url,
                    "files_included": len(included),
                    "files_excluded": len(excluded),
                    "exclusions": list(_exclude_patterns_for(repo_role)),
                    "commit_message_preview": notes.splitlines()[0]
                    if notes
                    else f"v{version}",
                },
            ),
            None,
        )

    work_root = Path(tempfile.mkdtemp(prefix=f"acg-release-v{version}-"))
    mirror_clone = work_root / "mirror"
    staging = work_root / "staging"
    staging.mkdir(parents=True, exist_ok=True)
    try:
        # Force-push safety: capture remote main SHA before the operation;
        # report it in detail so the operator can audit. We use --force-
        # with-lease at push time so a concurrent change at the remote
        # aborts the push instead of silently overwriting.
        existing_remote_sha = releases_mod.remote_main_sha(
            ctx.project_dir, mirror_url
        )

        try:
            releases_mod.clone_repo(mirror_url, mirror_clone)
        except releases_mod.ReleaseCommandError as exc:
            return _step("mirror_sync", "failed", {"error": str(exc)}), None

        _stage_curated_tree(ctx.project_dir, repo_role, staging)

        try:
            releases_mod.replace_tree_contents(mirror_clone, staging)
            user = ctx.config.owner
            releases_mod.configure_user(
                mirror_clone, user, f"{user}@users.noreply.github.com"
            )
            squashed_sha = releases_mod.commit_all(
                mirror_clone,
                notes if notes else f"v{version}",
                allow_empty=True,
            )
            releases_mod.push_main(mirror_clone, "origin", force=True)
        except releases_mod.ReleaseCommandError as exc:
            return _step("mirror_sync", "failed", {"error": str(exc)}), mirror_clone

        return (
            _step(
                "mirror_sync",
                "ok",
                {
                    "mirror_url": mirror_url,
                    "previous_remote_sha": existing_remote_sha,
                    "new_squashed_sha": squashed_sha,
                    "files_included": len(included),
                    "files_excluded": len(excluded),
                },
            ),
            mirror_clone,
        )
    except Exception as exc:  # noqa: BLE001 — surface anything as step failure
        return _step("mirror_sync", "failed", {"error": str(exc)}), None


def _run_mirror_tag(
    mirror_clone: Path | None,
    version: str,
    notes: str,
    dry_run: bool,
) -> dict:
    tag_name = f"v{version}"
    if dry_run:
        return _step(
            "mirror_tag",
            "dry_run_preview",
            {"tag": tag_name, "would_push": "origin"},
        )
    if mirror_clone is None:
        return _step(
            "mirror_tag",
            "failed",
            {"error": "mirror_sync did not produce a clone — cannot tag."},
        )
    try:
        releases_mod.create_annotated_tag(mirror_clone, tag_name, notes)
        releases_mod.push_tag(mirror_clone, "origin", tag_name)
    except releases_mod.ReleaseCommandError as exc:
        return _step("mirror_tag", "failed", {"error": str(exc)})
    return _step("mirror_tag", "ok", {"tag": tag_name})


def _release_asset_path(
    mirror_clone: Path, version: str, repo_role: str
) -> Path | None:
    """Return the file path of the consumer asset to upload, or None.

    Skill repo: the skill file at the mirror's root.
    MCP repo: a `git archive` source tarball materialised next to the clone.
    """
    if repo_role == "skill":
        candidate = mirror_clone / "active-claude-github.md"
        return candidate if candidate.is_file() else None
    if repo_role == "mcp":
        prefix = f"active-claude-github-mcp-v{version}"
        out = mirror_clone.parent / f"{prefix}.tar.gz"
        try:
            releases_mod.git_archive_tarball(
                mirror_clone, f"v{version}", prefix, out
            )
        except releases_mod.ReleaseCommandError:
            return None
        return out if out.is_file() else None
    return None


def _run_release_notes(
    mirror_clone: Path | None,
    version: str,
    notes: str,
    repo_role: str,
    dry_run: bool,
) -> dict:
    tag_name = f"v{version}"
    if dry_run:
        if repo_role == "skill":
            asset_preview = "active-claude-github.md"
        elif repo_role == "mcp":
            asset_preview = f"active-claude-github-mcp-v{version}.tar.gz"
        else:
            asset_preview = None
        return _step(
            "release_notes",
            "dry_run_preview",
            {
                "tag": tag_name,
                "title": f"v{version}",
                "notes_preview": notes[:400],
                "asset_preview": asset_preview,
            },
        )
    if mirror_clone is None:
        return _step(
            "release_notes",
            "failed",
            {"error": "no mirror clone available — release_project must "
                      "run mirror_sync first (or stop_after=dev_tag)."},
        )
    notes_path = mirror_clone / ".release_notes.md"
    notes_path.write_text(notes, encoding="utf-8")
    try:
        url = releases_mod.create_github_release(
            mirror_clone, tag_name, f"v{version}", notes_path
        )
    except releases_mod.ReleaseCommandError as exc:
        return _step("release_notes", "failed", {"error": str(exc)})

    # Upload the consumer asset so README's `releases/download/<tag>/<file>`
    # quickstart resolves. Failure here doesn't unwind the release object —
    # it surfaces as a failed step so the operator can manually recover with
    # `gh release upload <tag> <asset>` (#131).
    asset_path = _release_asset_path(mirror_clone, version, repo_role)
    if asset_path is None:
        return _step(
            "release_notes",
            "ok",
            {
                "release_url": url,
                "asset": None,
                "asset_note": (
                    "no asset uploaded — repo_role does not produce a "
                    "consumer asset, or asset materialisation failed."
                ),
            },
        )
    try:
        releases_mod.upload_release_asset(mirror_clone, tag_name, asset_path)
    except releases_mod.ReleaseCommandError as exc:
        return _step(
            "release_notes",
            "failed",
            {
                "release_url": url,
                "asset_path": str(asset_path),
                "error": (
                    f"release created but asset upload failed: {exc}. "
                    f"Recover manually: `gh release upload {tag_name} "
                    f"{asset_path.name}`."
                ),
            },
        )
    return _step(
        "release_notes",
        "ok",
        {
            "release_url": url,
            "asset": asset_path.name,
        },
    )


# ----- Public handler ------------------------------------------------------


async def release_project(
    ctx: ServerContext,
    version: str,
    dry_run: bool = True,
    stop_after: str = "release_notes",
    approve_version_bump: bool = False,
    skip_bazel: bool = False,
) -> list[TextContent]:
    """#106 — orchestrator: validate → dev_tag → mirror_sync → mirror_tag → release_notes."""
    if not version.strip():
        raise ValueError("version must not be empty.")
    if stop_after not in _VALID_STOP_AFTER:
        raise ValueError(
            f"stop_after must be one of {_VALID_STOP_AFTER}; got {stop_after!r}."
        )

    steps: list[dict] = []

    # ----- Phase 1: validate (with optional version-bump propose-and-approve) ----
    validate_step, checks, repo_role = _run_validate(ctx, version, skip_bazel)
    steps.append(validate_step)

    if validate_step["status"] == "failed":
        # Special case: only the version-line checks are failing AND the
        # operator hasn't approved a bump yet → propose edits and stop.
        if (
            _failures_are_only_version_bumps(checks)
            and not approve_version_bump
        ):
            edits = _propose_version_bump_edits(
                ctx.project_dir, repo_role, version
            )
            return ok(
                {
                    "version": version,
                    "dry_run": dry_run,
                    "repo_role": repo_role,
                    "stopped_at": "awaiting_version_bump_approval",
                    "proposed_edits": [
                        {
                            "path": e["path"],
                            "before": e["before"],
                            "after": e["after"],
                        }
                        for e in edits
                    ],
                    "steps": steps,
                    "next_action": (
                        "Re-run release_project with approve_version_bump=True "
                        "to apply the edits and continue."
                    ),
                }
            )

        # Approval given — apply edits, commit, push, and re-validate.
        if (
            approve_version_bump
            and _failures_are_only_version_bumps(checks)
        ):
            edits = _propose_version_bump_edits(
                ctx.project_dir, repo_role, version
            )
            if not edits:
                # Propose returned empty — nothing to apply; bail with the
                # original validate failure intact.
                return ok(
                    {
                        "version": version,
                        "dry_run": dry_run,
                        "repo_role": repo_role,
                        "stopped_at": "validate",
                        "steps": steps,
                    }
                )
            if dry_run:
                steps.append(
                    _step(
                        "version_bump",
                        "dry_run_preview",
                        {"edits": edits},
                    )
                )
                return ok(
                    {
                        "version": version,
                        "dry_run": True,
                        "repo_role": repo_role,
                        "stopped_at": "version_bump",
                        "steps": steps,
                    }
                )
            _apply_version_bump_edits(ctx.project_dir, edits)
            try:
                bump_sha = releases_mod.commit_all(
                    ctx.project_dir,
                    f"chore: bump {repo_role} to v{version}",
                )
                releases_mod.push_main(ctx.project_dir, "origin")
            except releases_mod.ReleaseCommandError as exc:
                steps.append(
                    _step("version_bump", "failed", {"error": str(exc)})
                )
                return ok(
                    {
                        "version": version,
                        "dry_run": False,
                        "repo_role": repo_role,
                        "stopped_at": "version_bump",
                        "steps": steps,
                    }
                )
            steps.append(
                _step(
                    "version_bump",
                    "ok",
                    {"sha": bump_sha, "edits": [
                        {"path": e["path"], "before": e["before"], "after": e["after"]}
                        for e in edits
                    ]},
                )
            )
            # Re-validate after bump.
            validate_step, checks, repo_role = _run_validate(
                ctx, version, skip_bazel
            )
            steps.append(validate_step)
            if validate_step["status"] == "failed":
                return ok(
                    {
                        "version": version,
                        "dry_run": False,
                        "repo_role": repo_role,
                        "stopped_at": "validate",
                        "steps": steps,
                    }
                )
        else:
            return ok(
                {
                    "version": version,
                    "dry_run": dry_run,
                    "repo_role": repo_role,
                    "stopped_at": "validate",
                    "steps": steps,
                }
            )

    if stop_after == "validate":
        return ok(
            {
                "version": version,
                "dry_run": dry_run,
                "repo_role": repo_role,
                "stopped_at": "validate",
                "steps": steps,
            }
        )

    # ----- Auto-populate CHANGELOG.md if absent (per #106 decision 1) -----
    changelog_path = ctx.project_dir / "CHANGELOG.md"
    if not changelog_path.exists():
        generated = _generate_changelog_md(ctx.project_dir)
        if generated is not None:
            if dry_run:
                steps.append(
                    _step(
                        "changelog_populate",
                        "dry_run_preview",
                        {"path": "CHANGELOG.md", "preview": generated[:400]},
                    )
                )
            else:
                changelog_path.write_text(generated, encoding="utf-8")
                # Commit + push so the file lands in dev main and the
                # subsequent dev_tag step tags a tree that includes it.
                # Without this, CHANGELOG.md ends up orphan-untracked in
                # the operator's working tree and absent from both dev
                # tag tree and mirror snapshot (#130).
                try:
                    bump_sha = releases_mod.commit_all(
                        ctx.project_dir,
                        f"chore: regenerate CHANGELOG.md for v{version}",
                    )
                    releases_mod.push_main(ctx.project_dir, "origin")
                except releases_mod.ReleaseCommandError as exc:
                    steps.append(
                        _step(
                            "changelog_populate",
                            "failed",
                            {"path": "CHANGELOG.md", "error": str(exc)},
                        )
                    )
                    return ok(
                        _finalise(
                            version, dry_run, repo_role,
                            "changelog_populate", steps,
                        )
                    )
                steps.append(
                    _step(
                        "changelog_populate",
                        "ok",
                        {
                            "path": "CHANGELOG.md",
                            "bytes": len(generated),
                            "commit_sha": bump_sha,
                        },
                    )
                )

    notes = _release_notes_for_version(ctx.project_dir, version) or (
        f"## v{version}\n\nNo changelog entry found — manual fill-in required."
    )

    # ----- Phase 2: dev_tag ----------------------------------------------------
    steps.append(_run_dev_tag(ctx, version, notes, dry_run))
    if steps[-1]["status"] == "failed":
        return ok(_finalise(version, dry_run, repo_role, "dev_tag", steps))
    if stop_after == "dev_tag":
        return ok(_finalise(version, dry_run, repo_role, "dev_tag", steps))

    # ----- Phase 3: mirror_sync -----------------------------------------------
    mirror_step, mirror_clone = _run_mirror_sync(
        ctx, version, notes, repo_role, dry_run
    )
    steps.append(mirror_step)
    if mirror_step["status"] == "failed":
        return ok(_finalise(version, dry_run, repo_role, "mirror_sync", steps))
    if stop_after == "mirror_sync":
        return ok(_finalise(version, dry_run, repo_role, "mirror_sync", steps))

    # ----- Phase 4: mirror_tag (always paired with mirror_sync) ---------------
    steps.append(_run_mirror_tag(mirror_clone, version, notes, dry_run))

    # ----- Phase 5: release_notes (gh release create on the mirror) ----------
    steps.append(
        _run_release_notes(mirror_clone, version, notes, repo_role, dry_run)
    )

    return ok(
        _finalise(version, dry_run, repo_role, "release_notes", steps)
    )


def _finalise(
    version: str,
    dry_run: bool,
    repo_role: str,
    stopped_at: str,
    steps: list[dict],
) -> dict:
    return {
        "version": version,
        "dry_run": dry_run,
        "repo_role": repo_role,
        "stopped_at": stopped_at,
        "steps": steps,
    }
