"""Flag PRs that would create a release PR for a package whose only change is its lockfile.

Why this exists:
    release-please scopes a commit to a package by the file *paths* it touches,
    with no notion of "this file is just a lockfile." When a bump-worthy commit
    (e.g. a `feat:` in `libs/deepagents`) also regenerates the `uv.lock` of every
    dependent package, release-please attributes the bump-worthy commit to those
    dependents too — and opens a release PR for each, even though their only
    change is a lockfile. This is the lockfile-churn sibling of the empty-commit
    fan-out that `guard-empty-commit` (in release-please.yml) already blocks. See
    the "Empty commit fan-out" entry in `.github/RELEASING.md` for the related case.

What it detects:
    Given a PR's conventional-commit title type and its list of changed files,
    report every release-please-managed package whose changed files inside the
    package path are *exclusively* lockfiles, when the title type is one that
    release-please would cut a release for. The package that legitimately owns
    the change has source edits too, so its lockfile change is never flagged.

How it stays faithful to release-please:
    - Package path -> component map is read straight from `release-please-config.json`
        (the same source release-please scopes against) — no second list to drift.
    - The set of "bump-worthy" types is derived from the non-hidden entries in that
        config's `changelog-sections`. This is a deliberately conservative *superset*
        of release-please's actual bump triggers (`feat`/`fix` + breaking): `perf` and
        `revert` are visible sections that may not always bump. We accept the rare
        false positive on a `perf`/`revert`-only lockfile PR to avoid missing a real
        fan-out; such a PR can be cleared with the `allow-lockfile-release` label.

    This script only *reports* offenders (and always exits 0 on success). The
    blocking decision — failing the check — lives in the workflow that calls it
    (`release_please_scope_check.yml`), not here.

Limitations:
    - Breaking changes are detected via the `!` title shorthand only. A
        `BREAKING CHANGE:` footer with no `!` is not inspected (the workflow passes
        the title, not the body). The worst case is an unflagged fan-out a maintainer
        catches at release-please time, so acceptable.
"""

import json
import re
import sys
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_CONFIG = REPO_ROOT / "release-please-config.json"

# Lockfiles that are regenerated by dependency resolution rather than authored
# directly. A package whose changed files are *only* these is churn, not a change.
LOCKFILE_NAMES = frozenset({"uv.lock"})

# Conventional-commit title: lowercase type, optional (scope), optional `!`.
_TITLE_RE = re.compile(r"^([a-z]+)(?:\([^)]*\))?(!)?:\s")


def bump_worthy_types(config: dict) -> frozenset[str]:
    """Return the conventional-commit types that may trigger a release.

    Derived from the non-hidden entries in the config's `changelog-sections`
    so the check tracks the repo's own configuration instead of a hardcoded
    list. See module docstring for why this is a conservative superset.

    Args:
        config: Parsed `release-please-config.json`.

    Returns:
        Set of lowercase commit types considered bump-worthy.
    """
    sections = config.get("changelog-sections", [])
    return frozenset(
        s["type"] for s in sections if s.get("type") and not s.get("hidden", False)
    )


def parse_title(title: str) -> tuple[str | None, bool]:
    """Return `(type, is_breaking)` parsed from a conventional-commit title.

    Args:
        title: The PR title (e.g. `feat(sdk): add thing` or `fix!: oops`).

    Returns:
        Tuple of the lowercase type (or `None` if the title is not
        conventional-commit-shaped) and whether the `!` breaking marker is set.
    """
    match = _TITLE_RE.match(title)
    if not match:
        return None, False
    return match.group(1), bool(match.group(2))


def _package_files(changed: list[str], path: str) -> list[str]:
    """Return the subset of `changed` that lives inside package directory `path`."""
    prefix = f"{path}/"
    return [f for f in changed if f == path or f.startswith(prefix)]


def find_offenders(title: str, changed: list[str], config: dict) -> list[str]:
    """Return components whose only changed files are lockfiles, if the PR bumps.

    Args:
        title: The PR title.
        changed: Changed file paths, repo-root-relative (forward slashes).
        config: Parsed `release-please-config.json`.

    Returns:
        Sorted list of offending component names. Empty when the title type is
        not bump-worthy or no managed package is lockfile-only.
    """
    ttype, breaking = parse_title(title)
    if not breaking and ttype not in bump_worthy_types(config):
        return []

    offenders: list[str] = []
    for path, meta in config.get("packages", {}).items():
        pkg_files = _package_files(changed, path)
        if pkg_files and all(Path(f).name in LOCKFILE_NAMES for f in pkg_files):
            offenders.append(meta.get("component", path))
    return sorted(offenders)


def main(title: str, changed: list[str], config_path: Path = DEFAULT_CONFIG) -> int:
    """Print offending components as a JSON array to stdout.

    Errors are surfaced loudly (not swallowed) and exit non-zero so a broken
    config or unreadable input fails the CI step visibly rather than silently
    reporting "no offenders."

    Returns:
        `0` always on successful analysis — offenders are reported on stdout and
            the blocking decision is made by the calling workflow,
            not this script.
        `2` on an internal error (missing/invalid/empty config).
    """
    try:
        config = json.loads(config_path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as e:
        print(
            f"::error::Could not read release-please config {config_path}: {e}",
            file=sys.stderr,
        )
        return 2

    # Fail closed on config drift rather than silently degrading to a no-op gate
    # that passes everything: no packages means nothing is ever scoped, and no
    # non-hidden changelog-sections means no title is ever bump-worthy.
    if not isinstance(config.get("packages"), dict) or not config["packages"]:
        print(
            f"::error::release-please config {config_path} has no 'packages' map",
            file=sys.stderr,
        )
        return 2
    if not bump_worthy_types(config):
        print(
            f"::error::release-please config {config_path} has no non-hidden "
            "changelog-sections; cannot determine bump-worthy types",
            file=sys.stderr,
        )
        return 2

    offenders = find_offenders(title, changed, config)
    if offenders:
        print(
            f"Lockfile-only release scope for: {', '.join(offenders)}", file=sys.stderr
        )
    print(json.dumps(offenders))
    return 0


if __name__ == "__main__":
    if len(sys.argv) < 2:
        print(
            "usage: check_lockfile_release_scope.py <pr-title>  (changed files on stdin)",
            file=sys.stderr,
        )
        raise SystemExit(2)
    pr_title = sys.argv[1]
    changed_files = [line.strip() for line in sys.stdin if line.strip()]
    raise SystemExit(main(pr_title, changed_files))
