# Copyright Kevin Deldycke <kevin@deldycke.com> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.

from __future__ import annotations

import logging
import os
import re
import sys
import tempfile
from collections import Counter
from datetime import datetime
from pathlib import Path
from urllib.request import Request, urlopen

from boltons.iterutils import unique
from click_extra import (
    Choice,
    Context,
    EnumChoice,
    FloatRange,
    IntRange,
    argument,
    dir_path,
    echo,
    file_path,
    group,
    option,
    pass_context,
)
from click_extra.envvar import merge_envvar_ids
from extra_platforms import ALL_IDS, is_github_ci

from . import __version__, _dev_version
from .binary import (
    BINARY_ARCH_MAPPINGS,
    collect_and_rename_artifacts,
    format_github_output,
    verify_binary_arch,
)
from .broken_links import manage_combined_broken_links_issue
from .checksums import update_checksums
from .init_project import ALL_COMPONENTS, run_init
from .changelog import Changelog
from .deps_graph import (
    generate_dependency_graph,
    get_available_extras,
    get_available_groups,
)
from .git_ops import create_and_push_tag
from .github import format_multiline_output
from .lint_repo import run_repo_lint
from .mailmap import Mailmap
from .metadata import (
    Dialect,
    Metadata,
    get_project_name,
    is_version_bump_allowed,
    load_gha_utils_config,
)
from .github.pr_body import (
    _repo_url,
    build_pr_body,
    generate_pr_metadata_block,
    get_template_names,
    render_commit_message,
    render_template,
    render_title,
    template_args,
)
from .release_prep import ReleasePrep
from .renovate import (
    add_exclude_newer_to_file,
    calculate_target_date,
    collect_check_results,
    has_tool_uv_section,
    parse_exclude_newer_date,
    run_migration_checks,
    update_exclude_newer_in_file,
)
from .sponsor import (
    add_sponsor_label,
    get_default_author,
    get_default_number,
    get_default_owner,
    get_default_repo,
    is_pull_request,
    is_sponsor,
)
from .test_plan import DEFAULT_TEST_PLAN, SkippedTest, parse_test_plan
from .github.workflow_sync import (
    DEFAULT_REPO,
    DEFAULT_VERSION,
    WorkflowFormat,
    generate_workflows,
    run_workflow_lint,
)

TYPE_CHECKING = False
if TYPE_CHECKING:
    from collections.abc import Callable
    from typing import IO


def is_stdout(filepath: Path) -> bool:
    """Check if a file path is set to stdout.

    Prevents the creation of a ``-`` file in the current directory.
    """
    return str(filepath) == "-"


def prep_path(filepath: Path) -> IO:
    """Prepare the output file parameter for Click's echo function.

    Always returns a UTF-8 encoded file object, including for stdout. This avoids
    ``UnicodeEncodeError`` on Windows where the default stdout encoding is ``cp1252``.

    For non-stdout paths, parent directories are created automatically if they don't
    exist. This absorbs the ``mkdir -p`` step that workflows previously had to do.
    """
    if is_stdout(filepath):
        return open(sys.stdout.fileno(), "w", encoding="UTF-8", closefd=False)
    filepath.parent.mkdir(parents=True, exist_ok=True)
    return filepath.open("w", encoding="UTF-8")


def generate_header(ctx: Context) -> str:
    """Generate metadata to be left as comments to the top of a file generated by
    this CLI.
    """
    header = (
        f"# Generated by {ctx.command_path} v{__version__}"
        " - https://github.com/kdeldycke/workflows\n"
        f"# Timestamp: {datetime.now().isoformat()}\n"
    )
    logging.debug(f"Generated header:\n{header}")
    return header


def remove_header(content: str) -> str:
    """Return content without blank lines and header metadata from above."""
    logging.debug(f"Removing header from:\n{content}")
    lines = []
    still_in_header = True
    for line in content.splitlines():
        if still_in_header:
            # We are still in the header as long as we have blank lines or we have
            # comment lines matching the format produced by the method above.
            if not line.strip() or line.startswith((
                "# Generated by ",
                "# Timestamp: ",
            )):
                continue
            else:
                still_in_header = False
        # We are past the header, so keep all the lines: we have nothing left to remove.
        lines.append(line)

    headerless_content = "\n".join(lines)
    logging.debug(f"Result of header removal:\n{headerless_content}")
    return headerless_content


@group(version=_dev_version())
def gha_utils():
    pass


@gha_utils.command(
    name="init", short_help="Bootstrap a repository to use reusable workflows"
)
@argument(
    "components",
    nargs=-1,
    type=Choice(list(ALL_COMPONENTS.keys()), case_sensitive=False),
)
@option(
    "--version",
    "version_pin",
    default=None,
    help="Version pin for upstream workflows (e.g., v5.10.0). "
    "Defaults to the latest release derived from the package version.",
)
@option(
    "--repo",
    default=DEFAULT_REPO,
    help="Upstream repository containing reusable workflows.",
)
@option(
    "--output-dir",
    type=dir_path(resolve_path=True),
    default=".",
    help="Root directory of the target repository.",
)
@option(
    "--overwrite",
    is_flag=True,
    default=False,
    help="Overwrite existing files instead of skipping them.",
)
def init_project(
    components,
    version_pin,
    repo,
    output_dir,
    overwrite,
):
    """Bootstrap a repository to use reusable workflows from kdeldycke/workflows.

    With no arguments, generates thin-caller workflow files, exports
    configuration files (Renovate, labels, labeller rules), and creates a
    minimal changelog. Specify COMPONENTS to initialize only selected parts.

    \b
    Components:
        workflows    Thin-caller workflow files
        labels       Label config (labels.toml + labeller rules)
        renovate     Renovate config (renovate.json5)
        changelog    Minimal changelog.md
        ruff         Merge [tool.ruff] into pyproject.toml
        pytest       Merge [tool.pytest] into pyproject.toml
        mypy         Merge [tool.mypy] into pyproject.toml
        bumpversion  Merge [tool.bumpversion] into pyproject.toml

    \b
    Examples:
        # Full bootstrap (workflows + labels + renovate + changelog)
        gha-utils init

    \b
        # Pin to a specific version
        gha-utils init --version v5.9.1

    \b
        # Only merge ruff config into pyproject.toml
        gha-utils init ruff

    \b
        # Multiple components
        gha-utils init ruff bumpversion

    \b
        # Overwrite existing files
        gha-utils init --overwrite workflows
    """
    result = run_init(
        output_dir=output_dir,
        components=components,
        version=version_pin,
        repo=repo,
        overwrite=overwrite,
    )

    # Print summary.
    if result.created:
        echo(f"Created {len(result.created)} file(s):")
        for path in result.created:
            echo(f"  {path}")
    if result.skipped:
        echo(f"Skipped {len(result.skipped)} existing file(s).")
    if result.warnings:
        for warning in result.warnings:
            echo(f"Warning: {warning}")

    if result.created:
        echo("")
        echo("Next steps:")
        echo(
            "  1. Create a WORKFLOW_UPDATE_GITHUB_PAT secret"
            " in your repository settings."
        )
        echo("     See: https://github.com/kdeldycke/workflows#secrets")
        echo("  2. If using GitHub Pages for docs, enable it in repository settings.")
        echo("  3. Commit the generated files and push.")


@gha_utils.command(short_help="Output project metadata")
@option(
    "--format",
    type=EnumChoice(Dialect),
    default=Dialect.github,
    help="Rendering format of the metadata.",
)
@option(
    "--overwrite/--no-overwrite",
    "--force/--no-force",
    "--replace/--no-replace",
    default=True,
    help="Overwrite output file if it already exists.",
)
@option(
    "-o",
    "--output",
    type=file_path(writable=True, resolve_path=True, allow_dash=True),
    default="-",
    help="Output file path. Defaults to stdout.",
)
@pass_context
def metadata(ctx, format, overwrite, output):
    """Dump project metadata to a file.

    By default the metadata produced are displayed directly to the console output.
    To have the results written in a file on disk, specify the output file like so:
    `gha-utils metadata --output dump.txt`.

    For GitHub you want to output to the standard environment file pointed to by the
    `$GITHUB_OUTPUT` variable. I.e.:

        $ gha-utils metadata --output "$GITHUB_OUTPUT"
    """
    if is_stdout(output):
        if overwrite:
            logging.warning("Ignore the --overwrite/--force/--replace option.")
        logging.info(f"Print metadata to {sys.stdout.name}")
    else:
        logging.info(f"Dump all metadata to {output}")

        if output.exists():
            msg = "Target file exists and will be overwritten."
            if overwrite:
                logging.warning(msg)
            else:
                logging.critical(msg)
                ctx.exit(2)

    metadata = Metadata()

    # Output a warning in GitHub runners if metadata are not saved to $GITHUB_OUTPUT.
    if is_github_ci():
        env_file = os.getenv("GITHUB_OUTPUT")
        if env_file and Path(env_file) != output:
            logging.warning(
                "Output path is not the same as $GITHUB_OUTPUT environment variable,"
                " which is generally what we're looking to do in GitHub CI runners for"
                " other jobs to consume the produced metadata."
            )

    dialect = Dialect(format)
    content = metadata.dump(dialect=dialect)
    echo(content, file=prep_path(output))


@gha_utils.command(short_help="Maintain a Markdown-formatted changelog")
@option(
    "--source",
    type=file_path(exists=True, readable=True, resolve_path=True),
    default="changelog.md",
    help="Changelog source file in Markdown format.",
)
@argument(
    "changelog_path",
    type=file_path(writable=True, resolve_path=True, allow_dash=True),
    default="-",
)
@pass_context
def changelog(ctx, source, changelog_path):
    initial_content = None
    if source:
        logging.info(f"Read initial changelog from {source}")
        initial_content = source.read_text(encoding="UTF-8")

    changelog = Changelog(initial_content, Metadata.get_current_version())
    content = changelog.update()
    if content == initial_content:
        logging.warning("Changelog already up to date. Do nothing.")
        ctx.exit()

    if is_stdout(changelog_path):
        logging.info(f"Print updated results to {sys.stdout.name}")
    else:
        logging.info(f"Save updated results to {changelog_path}")
    echo(content, file=prep_path(changelog_path))


@gha_utils.command(short_help="Prepare files for a release")
@option(
    "--changelog",
    "changelog_path",
    type=file_path(exists=True, readable=True, writable=True, resolve_path=True),
    default="changelog.md",
    help="Path to the changelog file.",
)
@option(
    "--citation",
    "citation_path",
    type=file_path(readable=True, writable=True, resolve_path=True),
    default="citation.cff",
    help="Path to the citation file.",
)
@option(
    "--workflow-dir",
    default=".github/workflows",
    help="Path to the GitHub workflows directory.",
)
@option(
    "--default-branch",
    default="main",
    help="Name of the default branch for workflow URL updates.",
)
@option(
    "--update-workflows/--no-update-workflows",
    default=None,
    help="Update workflow URLs to use versioned tag instead of default branch."
    " Defaults to True when $GITHUB_REPOSITORY is the canonical workflows repo.",
)
@option(
    "--post-release",
    is_flag=True,
    default=False,
    help="Run post-release steps (retarget workflow URLs to default branch).",
)
@pass_context
def release_prep(
    ctx,
    changelog_path,
    citation_path,
    workflow_dir,
    default_branch,
    update_workflows,
    post_release,
):
    """Prepare files for a release or post-release version bump.

    This command consolidates all release preparation steps:

    \b
    - Set release date in changelog (replaces "(unreleased)" with today's date).
    - Set release date in citation.cff.
    - Update changelog comparison URL from "...main" to "...v{version}".
    - Remove the "[!IMPORTANT]" warning block from changelog.
    - Optionally update workflow URLs to use versioned tag.

    \b
    When running in GitHub Actions, --update-workflows is auto-detected:
    it defaults to True when $GITHUB_REPOSITORY matches the canonical
    workflows repository (kdeldycke/workflows).

    For post-release (after the release commit), use --post-release to retarget
    workflow URLs back to the default branch.

    Examples:

    \b
        # Prepare release (changelog + citation)
        gha-utils release-prep

    \b
        # In GitHub Actions on kdeldycke/workflows (auto-detects --update-workflows)
        gha-utils release-prep

    \b
        # Post-release: retarget workflows to main branch
        gha-utils release-prep --post-release
    """
    # Auto-detect --update-workflows from $GITHUB_REPOSITORY.
    if update_workflows is None:
        gh_repo = os.getenv("GITHUB_REPOSITORY", "")
        update_workflows = gh_repo == DEFAULT_REPO
        if update_workflows:
            logging.info(
                f"Auto-detected --update-workflows: $GITHUB_REPOSITORY={gh_repo!r}"
                f" matches canonical repo {DEFAULT_REPO!r}"
            )

    workflow_dir_path = Path(workflow_dir).resolve() if workflow_dir else None
    prep = ReleasePrep(
        changelog_path=changelog_path,
        citation_path=citation_path if citation_path.exists() else None,
        workflow_dir=workflow_dir_path,
        default_branch=default_branch,
    )

    if post_release:
        modified = prep.post_release(update_workflows=update_workflows)
        action = "Post-release"
    else:
        modified = prep.prepare_release(update_workflows=update_workflows)
        action = "Release preparation"

    if modified:
        logging.info(f"{action} complete. Modified {len(modified)} file(s):")
        for path in modified:
            echo(f"  {path}")
    else:
        logging.warning(f"{action}: no files were modified.")


@gha_utils.command(short_help="Check if a version bump is allowed")
@option(
    "--part",
    type=Choice(["minor", "major"], case_sensitive=False),
    required=True,
    help="The version part to check for bump eligibility.",
)
def version_check(part: str) -> None:
    """Check if a version bump is allowed for the specified part.

    This command prevents double version increments within a development cycle.
    It compares the current version from pyproject.toml against the latest Git tag
    to determine if a bump has already been applied but not released.

    \b
    Examples:
        # Check if minor version bump is allowed
        gha-utils version-check --part minor

        # Check if major version bump is allowed
        gha-utils version-check --part major

    \b
    Output:
        - Prints "true" if the bump is allowed
        - Prints "false" if a bump of this type was already applied

    \b
    Use in GitHub Actions:
        allowed=$( gha-utils version-check --part minor )
        if [ "$allowed" = "true" ]; then
            bump-my-version bump minor
        fi
    """
    allowed = is_version_bump_allowed(part)  # type: ignore[arg-type]
    echo("true" if allowed else "false")


GITIGNORE_BASE_CATEGORIES: tuple[str, ...] = (
    "certificates",
    "emacs",
    "git",
    "gpg",
    "linux",
    "macos",
    "node",
    "nohup",
    "python",
    "rust",
    "ssh",
    "vim",
    "virtualenv",
    "visualstudiocode",
    "windows",
)
"""Base gitignore.io template categories included in every generated ``.gitignore``.

These cover common development environments, operating systems, and tools.
Downstream projects can add more via ``gitignore-extra-categories`` in
``[tool.gha-utils]``.
"""

GITIGNORE_IO_URL = "https://www.toptal.com/developers/gitignore/api"
"""gitignore.io API endpoint for fetching ``.gitignore`` templates."""


@gha_utils.command(short_help="Generate .gitignore from gitignore.io templates")
@option(
    "--output",
    "output_path",
    type=file_path(writable=True, resolve_path=True, allow_dash=True),
    default=None,
    help=("Output path. Defaults to gitignore-location from [tool.gha-utils] config."),
)
def update_gitignore(output_path: Path | None) -> None:
    """Generate a ``.gitignore`` file from gitignore.io templates.

    Fetches templates for a base set of categories plus any extras from
    ``[tool.gha-utils]`` config, then appends ``gitignore-extra-content``.
    Writes to the path specified by ``gitignore-location`` (default
    ``./.gitignore``).

    \b
    Examples:
        # Generate .gitignore using config from pyproject.toml
        gha-utils update-gitignore

    \b
        # Write to custom location
        gha-utils update-gitignore --output ./custom/.gitignore

    \b
        # Preview on stdout
        gha-utils update-gitignore --output -
    """
    config = load_gha_utils_config()

    # Combine base and extra categories, preserving order and deduplicating.
    extra = config.get("gitignore-extra-categories", [])
    all_categories = list(dict.fromkeys((*GITIGNORE_BASE_CATEGORIES, *extra)))

    # Fetch from gitignore.io API.
    url = f"{GITIGNORE_IO_URL}/{','.join(all_categories)}"
    logging.info(f"Fetching {url}")
    request = Request(url, headers={"User-Agent": f"gha-utils/{__version__}"})  # noqa: S310
    with urlopen(request) as response:  # noqa: S310
        content = response.read().decode("UTF-8")

    # Append extra content.
    extra_content = config.get("gitignore-extra-content", "")
    if extra_content:
        content += "\n" + extra_content + "\n"

    # Resolve output path.
    if output_path is None:
        output_path = Path(config.get("gitignore-location", "./.gitignore"))

    if is_stdout(output_path):
        logging.info(f"Print to {sys.stdout.name}")
    else:
        logging.info(f"Write to {output_path}")

    echo(content.rstrip(), file=prep_path(output_path))


@gha_utils.group(short_help="Manage downstream workflow caller files")
def workflow():
    """Manage downstream workflow caller files.

    Generate, synchronize, and lint thin caller workflows that delegate to
    the canonical reusable workflows in ``kdeldycke/workflows``.

    \b
    Subcommands:
        create - Generate new workflow files (errors if file exists)
        sync   - Generate or overwrite workflow files
        lint   - Check existing workflow files for common issues
    """


@workflow.command(short_help="Generate new workflow caller files")
@option(
    "--format",
    "output_format",
    type=EnumChoice(WorkflowFormat),
    default=WorkflowFormat.THIN_CALLER,
    help="Output format for generated workflows.",
)
@option(
    "--version",
    default=DEFAULT_VERSION,
    help="Version reference for the upstream workflows (tag or branch).",
)
@option(
    "--repo",
    default=DEFAULT_REPO,
    help="Upstream repository containing reusable workflows.",
)
@option(
    "--output-dir",
    type=dir_path(resolve_path=True),
    default=".github/workflows",
    help="Directory to write workflow files to.",
)
@argument(
    "workflow_names",
    nargs=-1,
)
@pass_context
def create(ctx, output_format, version, repo, output_dir, workflow_names):
    """Generate new workflow caller files.

    Creates workflow files in the specified format. Errors if a target file
    already exists. Use ``sync`` to overwrite existing files.

    WORKFLOW_NAMES are optional filenames to generate. If omitted, generates
    all reusable workflows.

    \b
    Examples:
        # Generate all thin caller workflows
        gha-utils workflow create

    \b
        # Generate specific workflows
        gha-utils workflow create release.yaml lint.yaml

    \b
        # Generate with a pinned version
        gha-utils workflow create --version v5.8.0

    \b
        # Full copy mode
        gha-utils workflow create --format full-copy
    """
    exit_code = generate_workflows(
        names=workflow_names,
        output_format=WorkflowFormat(output_format),
        version=version,
        repo=repo,
        output_dir=output_dir,
        overwrite=False,
    )
    ctx.exit(exit_code)


@workflow.command(short_help="Sync workflow caller files (overwrites existing)")
@option(
    "--format",
    "output_format",
    type=EnumChoice(WorkflowFormat),
    default=WorkflowFormat.THIN_CALLER,
    help="Output format for generated workflows.",
)
@option(
    "--version",
    default=DEFAULT_VERSION,
    help="Version reference for the upstream workflows (tag or branch).",
)
@option(
    "--repo",
    default=DEFAULT_REPO,
    help="Upstream repository containing reusable workflows.",
)
@option(
    "--output-dir",
    type=dir_path(resolve_path=True),
    default=".github/workflows",
    help="Directory to write workflow files to.",
)
@argument(
    "workflow_names",
    nargs=-1,
)
@pass_context
def sync(ctx, output_format, version, repo, output_dir, workflow_names):
    """Sync workflow caller files, overwriting existing files.

    Same as ``create`` but overwrites existing files instead of erroring.

    \b
    Examples:
        # Update all thin caller workflows to latest version
        gha-utils workflow sync --version v5.9.0

    \b
        # Sync specific workflows
        gha-utils workflow sync release.yaml lint.yaml
    """
    exit_code = generate_workflows(
        names=workflow_names,
        output_format=WorkflowFormat(output_format),
        version=version,
        repo=repo,
        output_dir=output_dir,
        overwrite=True,
    )
    ctx.exit(exit_code)


@workflow.command(short_help="Lint workflow files for common issues")
@option(
    "--workflow-dir",
    type=dir_path(exists=True, resolve_path=True),
    default=".github/workflows",
    help="Directory containing workflow YAML files.",
)
@option(
    "--repo",
    default=DEFAULT_REPO,
    help="Upstream repository to match thin callers against.",
)
@option(
    "--fatal/--warning",
    default=False,
    help="Exit with code 1 if issues are found (default: warning only).",
)
@pass_context
def lint(ctx, workflow_dir, repo, fatal):
    """Lint workflow files for common issues.

    Checks all YAML files in the workflow directory for:

    \b
    - Missing ``workflow_dispatch`` trigger.
    - Thin callers using ``@main`` instead of a version tag.
    - Thin callers with mismatched triggers vs canonical workflows.
    - Thin callers missing ``secrets: inherit`` when required.

    \b
    Examples:
        # Lint workflows in default location
        gha-utils workflow lint

    \b
        # Lint with fatal mode (exit 1 on issues)
        gha-utils workflow lint --fatal

    \b
        # Lint a custom directory
        gha-utils workflow lint --workflow-dir ./my-workflows
    """
    exit_code = run_workflow_lint(
        workflow_dir=workflow_dir,
        repo=repo,
        fatal=fatal,
    )
    ctx.exit(exit_code)


@gha_utils.command(short_help="Update Git's .mailmap file with missing contributors")
@option(
    "--source",
    type=file_path(readable=True, resolve_path=True),
    default=".mailmap",
    help="Mailmap source file to use as reference for contributors identities that "
    "are already grouped.",
)
@option(
    "--create-if-missing/--skip-if-missing",
    is_flag=True,
    default=True,
    help="If not found, either create the missing destination mailmap file, or skip "
    "the update process entirely. This option is ignored if the destination is to print "
    f"the result to {sys.stdout.name}.",
)
@argument(
    "destination_mailmap",
    type=file_path(writable=True, resolve_path=True, allow_dash=True),
    default=None,
)
@pass_context
def mailmap_sync(ctx, source, create_if_missing, destination_mailmap):
    """Update a ``.mailmap`` file with all missing contributors found in Git commit
    history.

    By default the ``.mailmap`` at the root of the repository is read and its content
    is reused as reference, so identities already aliased in there are preserved and
    used as initial mapping. Only missing contributors not found in this initial mapping
    are added.

    The destination defaults to the source file path (in-place update). Pass ``-``
    explicitly to print to stdout instead.

    The updated results are sorted. But no attempts are made at regrouping new
    contributors. So you have to edit entries by hand to regroup them.
    """
    # Default destination to source path (in-place update).
    if destination_mailmap is None:
        destination_mailmap = source

    mailmap = Mailmap()

    if source.exists():
        logging.info(f"Read initial mapping from {source}")
        content = remove_header(source.read_text(encoding="UTF-8"))
        mailmap.parse(content)
    else:
        logging.debug(f"Mailmap source file {source} does not exist.")

    mailmap.update_from_git()
    new_content = mailmap.render()

    if is_stdout(destination_mailmap):
        logging.info(f"Print updated results to {sys.stdout.name}.")
        logging.debug(
            "Ignore the "
            + ("--create-if-missing" if create_if_missing else "--skip-if-missing")
            + " option."
        )
    else:
        logging.info(f"Save updated results to {destination_mailmap}")
        if not create_if_missing and not destination_mailmap.exists():
            logging.warning(
                f"{destination_mailmap} does not exist, stop the sync process."
            )
            ctx.exit()
        if content == new_content:
            logging.warning("Nothing to update, stop the sync process.")
            ctx.exit()

    echo(generate_header(ctx) + new_content, file=prep_path(destination_mailmap))


@gha_utils.command(short_help="Run a test plan from a file against a binary")
@option(
    "--command",
    "--binary",
    required=True,
    metavar="COMMAND",
    help="Path to the binary file to test, or a command line to be executed.",
)
@option(
    "-F",
    "--plan-file",
    type=file_path(exists=True, readable=True, resolve_path=True),
    multiple=True,
    metavar="FILE_PATH",
    help="Path to a test plan file in YAML. This option can be repeated to run "
    "multiple test plans in sequence. If not provided, a default test plan will be "
    "executed.",
)
@option(
    "-E",
    "--plan-envvar",
    multiple=True,
    metavar="ENVVAR_NAME",
    help="Name of an environment variable containing a test plan in YAML. This "
    "option can be repeated to collect multiple test plans.",
)
@option(
    "-t",
    "--select-test",
    type=IntRange(min=1),
    multiple=True,
    metavar="INTEGER",
    help="Only run the tests matching the provided test case numbers. This option can "
    "be repeated to run multiple test cases. If not provided, all test cases will be "
    "run.",
)
@option(
    "-s",
    "--skip-platform",
    type=Choice(sorted(ALL_IDS), case_sensitive=False),
    multiple=True,
    help="Skip tests for the specified platforms. This option can be repeated to "
    "skip multiple platforms.",
)
@option(
    "-x",
    "--exit-on-error",
    is_flag=True,
    default=False,
    help="Exit instantly on first failed test.",
)
@option(
    "-T",
    "--timeout",
    # Timeout passed to subprocess.run() is a float that is silently clamped to
    # 0.0 if negative values are provided, so we mimic this behavior here:
    # https://github.com/python/cpython/blob/5740b95076b57feb6293cda4f5504f706a7d622d/Lib/subprocess.py#L1596-L1597
    type=FloatRange(min=0, clamp=True),
    metavar="SECONDS",
    help="Set the default timeout for each CLI call, if not specified in the "
    "test plan.",
)
@option(
    "--show-trace-on-error/--hide-trace-on-error",
    default=True,
    help="Show execution trace of failed tests.",
)
@option(
    "--stats/--no-stats",
    is_flag=True,
    default=True,
    help="Print per-manager package statistics.",
)
def test_plan(
    command: str,
    plan_file: tuple[Path, ...] | None,
    plan_envvar: tuple[str, ...] | None,
    select_test: tuple[int, ...] | None,
    skip_platform: tuple[str, ...] | None,
    exit_on_error: bool,
    timeout: float | None,
    show_trace_on_error: bool,
    stats: bool,
) -> None:
    # Load [tool.gha-utils] config for fallback values.
    config = load_gha_utils_config()

    # Load test plan: CLI args > pyproject.toml config > DEFAULT_TEST_PLAN.
    test_list = []
    if plan_file or plan_envvar:
        # CLI-provided sources take precedence.
        for file in unique(plan_file):
            logging.info(f"Get test plan from {file} file")
            tests = list(parse_test_plan(file.read_text(encoding="UTF-8")))
            logging.info(f"{len(tests)} test cases found.")
            test_list.extend(tests)
        for envvar_id in merge_envvar_ids(plan_envvar):
            logging.info(f"Get test plan from {envvar_id!r} environment variable")
            tests = list(parse_test_plan(os.getenv(envvar_id)))
            logging.info(f"{len(tests)} test cases found.")
            test_list.extend(tests)

    else:
        # Fall back to [tool.gha-utils] config.
        config_test_plan = config.get("test-plan")
        if config_test_plan:
            logging.info("Get test plan from [tool.gha-utils] test-plan config.")
            tests = list(parse_test_plan(config_test_plan))
            logging.info(f"{len(tests)} test cases found.")
            test_list.extend(tests)

        config_plan_file = config.get("test-plan-file")
        if config_plan_file:
            plan_path = Path(config_plan_file)
            if plan_path.exists():
                logging.info(f"Get test plan from config path: {plan_path}")
                tests = list(parse_test_plan(plan_path.read_text(encoding="UTF-8")))
                logging.info(f"{len(tests)} test cases found.")
                test_list.extend(tests)

        if not test_list:
            logging.warning(
                "No test plan provided through CLI options or"
                " [tool.gha-utils] config: use default test plan."
            )
            test_list = DEFAULT_TEST_PLAN

    # Fall back to config timeout if not provided via CLI.
    if timeout is None:
        config_timeout = config.get("timeout")
        if config_timeout is not None:
            timeout = float(config_timeout)

    logging.debug(f"Test plan: {test_list}")

    counter = Counter(total=len(test_list), skipped=0, failed=0)

    for index, test_case in enumerate(test_list):
        test_number = index + 1
        test_name = f"#{test_number}"
        logging.info(f"Run test {test_name}...")

        if select_test and test_number not in select_test:
            logging.warning(f"Test {test_name} skipped by user request.")
            counter["skipped"] += 1
            continue

        try:
            logging.debug(f"Test case parameters: {test_case}")
            test_case.run_cli_test(
                command,
                additional_skip_platforms=skip_platform,
                default_timeout=timeout,
            )
        except SkippedTest as ex:
            counter["skipped"] += 1
            logging.warning(f"Test {test_name} skipped: {ex}")
        except Exception as ex:
            counter["failed"] += 1
            logging.error(f"Test {test_name} failed: {ex}")
            if show_trace_on_error and test_case.execution_trace:
                echo(test_case.execution_trace)
            if exit_on_error:
                logging.debug("Don't continue testing, a failed test was found.")
                sys.exit(1)

    if stats:
        echo(
            "Test plan results - "
            + ", ".join((f"{k.title()}: {v}" for k, v in counter.items()))
        )

    if counter["failed"]:
        sys.exit(1)


@gha_utils.command(short_help="Label issues/PRs from GitHub sponsors")
@option(
    "--owner",
    help="GitHub username or organization to check sponsorship for. "
    "Defaults to $GITHUB_REPOSITORY_OWNER.",
)
@option(
    "--author",
    help="GitHub username of the issue/PR author to check. "
    "Defaults to author from $GITHUB_EVENT_PATH.",
)
@option(
    "--repo",
    help="Repository in 'owner/repo' format. Defaults to $GITHUB_REPOSITORY.",
)
@option(
    "--number",
    type=int,
    help="Issue or PR number. Defaults to number from $GITHUB_EVENT_PATH.",
)
@option(
    "--label",
    default="💖 sponsors",
    help="Label to add if author is a sponsor.",
)
@option(
    "--pr/--issue",
    "is_pr",
    default=None,
    help="Specify issue or pull request. Auto-detected from $GITHUB_EVENT_PATH.",
)
@pass_context
def sponsor_label(
    ctx: Context,
    owner: str | None,
    author: str | None,
    repo: str | None,
    number: int | None,
    label: str,
    is_pr: bool | None,
) -> None:
    """Add a label to issues or PRs from GitHub sponsors.

    Checks if the author of an issue or PR is a sponsor of the repository owner.
    If they are, adds the specified label.

    This command requires the ``gh`` CLI to be installed and authenticated.

    When run in GitHub Actions, all parameters are auto-detected from environment
    variables ($GITHUB_REPOSITORY_OWNER, $GITHUB_REPOSITORY) and the event payload
    ($GITHUB_EVENT_PATH). You can override any auto-detected value by passing it
    explicitly.

    \b
    Examples:
        # In GitHub Actions (all defaults auto-detected)
        gha-utils sponsor-label

    \b
        # Override specific values
        gha-utils sponsor-label --label "sponsor"

    \b
        # Manual invocation with all values
        gha-utils sponsor-label --owner kdeldycke --author some-user \\
            --repo kdeldycke/workflows --number 123 --issue
    """
    # Apply defaults from GitHub Actions environment.
    if owner is None:
        owner = get_default_owner()
    if author is None:
        author = get_default_author()
    if repo is None:
        repo = get_default_repo()
    if number is None:
        number = get_default_number()
    if is_pr is None:
        is_pr = is_pull_request()

    # Validate required parameters.
    missing = []
    if not owner:
        missing.append("--owner")
    if not author:
        missing.append("--author")
    if not repo:
        missing.append("--repo")
    if not number:
        missing.append("--number")

    if missing:
        logging.error(
            f"Missing required parameters: {', '.join(missing)}. "
            "These could not be auto-detected from the environment."
        )
        ctx.exit(1)

    # Type narrowing for mypy.
    assert owner and author and repo and number

    if is_sponsor(owner, author):
        if add_sponsor_label(repo, number, label, is_pr=is_pr):
            echo(f"Added {label!r} label to {'PR' if is_pr else 'issue'} #{number}")
        else:
            logging.error("Failed to add sponsor label")
            ctx.exit(1)
    else:
        echo(f"Author {author!r} is not a sponsor of {owner!r}")


@gha_utils.command(short_help="Generate dependency graph from uv lockfile")
@option(
    "-p",
    "--package",
    help="Focus on a specific package's dependency tree.",
)
@option(
    "-g",
    "--group",
    "groups",
    multiple=True,
    help="Include dependencies from the specified group (e.g., test, typing). "
    "Can be repeated.",
)
@option(
    "--all-groups",
    is_flag=True,
    default=False,
    help="Include all dependency groups from pyproject.toml.",
)
@option(
    "-e",
    "--extra",
    "extras",
    multiple=True,
    help="Include dependencies from the specified extra (e.g., xml, json5). "
    "Can be repeated.",
)
@option(
    "--all-extras",
    is_flag=True,
    default=False,
    help="Include all optional extras from pyproject.toml.",
)
@option(
    "--frozen/--no-frozen",
    default=True,
    help="Use --frozen to skip lock file updates.",
)
@option(
    "-l",
    "--level",
    type=int,
    default=None,
    help="Maximum depth of the dependency graph. "
    "1 = primary deps only, 2 = primary + their deps, etc.",
)
@option(
    "-o",
    "--output",
    type=file_path(writable=True, resolve_path=True, allow_dash=True),
    default=None,
    help="Output file path. Defaults to [tool.gha-utils] config or stdout.",
)
def deps_graph(
    package: str | None,
    groups: tuple[str, ...],
    all_groups: bool,
    extras: tuple[str, ...],
    all_extras: bool,
    frozen: bool,
    level: int | None,
    output: Path | None,
) -> None:
    """Generate a Mermaid dependency graph from the project's uv lockfile.

    Parses the CycloneDX SBOM export from uv and renders it as a Mermaid
    flowchart for documentation. Version specifiers from uv.lock are shown
    as edge labels.

    \b
    Examples:
        # Generate Mermaid graph
        gha-utils deps-graph

    \b
        # Include test dependencies
        gha-utils deps-graph --group test

    \b
        # Include all groups and extras
        gha-utils deps-graph --all-groups --all-extras

    \b
        # Focus on a specific package
        gha-utils deps-graph --package click-extra

    \b
        # Limit graph depth to 2 levels
        gha-utils deps-graph --level 2

    \b
        # Save to file
        gha-utils deps-graph --output docs/dependency-graph.md
    """
    config = load_gha_utils_config()

    # Auto-detect package name from [project].name.
    if package is None:
        package = get_project_name()
        if package:
            logging.info(f"Auto-detected package from pyproject.toml: {package}")

    # Resolve output: CLI > config > stdout.
    if output is None:
        config_output = config.get("dependency-graph-output")
        if config_output:
            output = Path(config_output).resolve()
        else:
            output = Path("-")

    # Resolve --all-groups and --all-extras flags.
    resolved_groups: tuple[str, ...] | None = groups if groups else None
    if all_groups:
        resolved_groups = get_available_groups()
        logging.info(f"Discovered groups: {', '.join(resolved_groups)}")

    resolved_extras: tuple[str, ...] | None = extras if extras else None
    if all_extras:
        resolved_extras = get_available_extras()
        logging.info(f"Discovered extras: {', '.join(resolved_extras)}")

    graph = generate_dependency_graph(
        package=package,
        groups=resolved_groups,
        extras=resolved_extras,
        frozen=frozen,
        depth=level,
    )

    if is_stdout(output):
        logging.info(f"Print graph to {sys.stdout.name}")
    else:
        logging.info(f"Write graph to {output}")

    echo(graph, file=prep_path(output))


@gha_utils.command(short_help="Manage broken links issue lifecycle")
@option(
    "--lychee-exit-code",
    type=int,
    default=None,
    help="Exit code from lychee (0=no broken links, 2=broken links found).",
)
@option(
    "--body-file",
    type=file_path(exists=True, readable=True, resolve_path=True),
    default=None,
    help="Path to the issue body file (lychee output).",
)
@option(
    "--output-json",
    type=file_path(exists=True, readable=True, resolve_path=True),
    default=None,
    help="Path to Sphinx linkcheck output.json file.",
)
@option(
    "--source-url",
    default=None,
    help="Base URL for linking filenames and line numbers in the Sphinx report. "
    "Example: https://github.com/owner/repo/blob/<sha>/docs",
)
@option(
    "--repo-name",
    default=None,
    help="Repository name (for label selection)."
    " Defaults to $GITHUB_REPOSITORY name component.",
)
def broken_links(
    lychee_exit_code: int | None,
    body_file: Path | None,
    output_json: Path | None,
    source_url: str | None,
    repo_name: str | None,
) -> None:
    """Manage the broken links issue lifecycle.

    Combines Lychee and Sphinx linkcheck results into a single "Broken links"
    issue. Each tool's results appear under its own heading.

    \b
    In GitHub Actions, most options are auto-detected:
    - --repo-name defaults to $GITHUB_REPOSITORY name component.
    - --body-file defaults to ./lychee/out.md when --lychee-exit-code is set.
    - --output-json defaults to ./docs/linkcheck/output.json if the file exists.
    - --source-url is composed from $GITHUB_SERVER_URL, $GITHUB_REPOSITORY,
      and $GITHUB_SHA when --output-json is set.

    \b
    This command:
    1. Auto-detects missing options from environment and file paths.
    2. Validates inputs (lychee exit code must be 0 or 2 if provided).
    3. Parses Sphinx linkcheck output.json if provided.
    4. Builds a combined report with sections for each tool.
    5. Lists open issues by github-actions[bot].
    6. Triages matching "Broken links" issues (keep newest, close duplicates).
    7. Creates or updates the main issue.

    This command requires the ``gh`` CLI to be installed and authenticated.

    \b
    Examples:
        # In GitHub Actions (auto-detection)
        gha-utils broken-links --lychee-exit-code 2

    \b
        # Explicit options
        gha-utils broken-links \\
            --lychee-exit-code 2 \\
            --body-file ./lychee/out.md \\
            --repo-name "my-repo"

    \b
        # Both tools combined (explicit)
        gha-utils broken-links \\
            --lychee-exit-code 2 \\
            --body-file ./lychee/out.md \\
            --output-json ./docs/linkcheck/output.json \\
            --repo-name "my-repo" \\
            --source-url "https://github.com/owner/repo/blob/abc123/docs"
    """
    manage_combined_broken_links_issue(
        repo_name=repo_name,
        lychee_exit_code=lychee_exit_code,
        lychee_body_file=body_file,
        sphinx_output_json=output_json,
        sphinx_source_url=source_url,
    )


@gha_utils.command(short_help="Manage setup guide issue lifecycle")
@option(
    "--has-pat",
    is_flag=True,
    default=False,
    help="Whether WORKFLOW_UPDATE_GITHUB_PAT is configured.",
)
def setup_guide(has_pat: bool) -> None:
    """Manage the setup guide issue for WORKFLOW_UPDATE_GITHUB_PAT.

    Opens (or reopens) an issue with PAT setup instructions when the secret
    is missing. Closes the issue when the secret is detected.

    This command requires the ``gh`` CLI to be installed and authenticated.

    \b
    Examples:
        # Secret is missing — create or reopen the setup issue
        gha-utils setup-guide

    \b
        # Secret is configured — close the setup issue
        gha-utils setup-guide --has-pat
    """
    from .github.issue import manage_issue_lifecycle

    body = render_template("setup-guide")

    with tempfile.NamedTemporaryFile(
        mode="w",
        suffix=".md",
        delete=False,
        encoding="UTF-8",
    ) as tmp:
        tmp.write(body)
        body_file = Path(tmp.name)

    manage_issue_lifecycle(
        has_issues=not has_pat,
        body_file=body_file,
        labels=["🤖 ci"],
        title="Set up `WORKFLOW_UPDATE_GITHUB_PAT` to enable workflow auto-updates",
        no_issues_comment="PAT secret detected.",
    )


@gha_utils.command(short_help="Verify binary architecture using exiftool")
@option(
    "--target",
    type=Choice(sorted(BINARY_ARCH_MAPPINGS.keys()), case_sensitive=False),
    required=True,
    help="Target platform (e.g., linux-arm64, macos-x64, windows-x64).",
)
@option(
    "--binary",
    "binary_path",
    type=file_path(exists=True, readable=True, resolve_path=True),
    required=True,
    help="Path to the binary file to verify.",
)
def verify_binary(target: str, binary_path: Path) -> None:
    """Verify that a compiled binary matches the expected architecture.

    Uses exiftool to inspect the binary and validates that its architecture
    matches what is expected for the specified target platform.

    Requires exiftool to be installed and available in PATH.

    \b
    Examples:
        # Verify a Linux ARM64 binary
        gha-utils verify-binary --target linux-arm64 --binary ./mpm-linux-arm64.bin

    \b
        # Verify a Windows x64 binary
        gha-utils verify-binary --target windows-x64 --binary ./mpm-windows-x64.exe
    """
    verify_binary_arch(target, binary_path)
    echo(f"Binary architecture verified for {target}: {binary_path}")


@gha_utils.command(short_help="Collect and rename artifacts for release")
@option(
    "--download-folder",
    type=dir_path(exists=True, resolve_path=True),
    required=True,
    help="Folder containing downloaded artifacts.",
)
@option(
    "--short-sha",
    required=True,
    help="SHA suffix to strip from binary filenames.",
)
@option(
    "--nuitka-matrix",
    default=None,
    help="JSON string of the nuitka matrix (to identify binaries).",
)
@option(
    "-o",
    "--output",
    type=file_path(writable=True, resolve_path=True, allow_dash=True),
    default="-",
    help="Output file path for GITHUB_OUTPUT format. Defaults to stdout.",
)
def collect_artifacts(
    download_folder: Path,
    short_sha: str,
    nuitka_matrix: str | None,
    output: Path,
) -> None:
    """Collect artifacts and rename binaries for GitHub release.

    Processes all files in the download folder:
    - Binaries (identified via nuitka-matrix) are renamed to strip the SHA suffix.
    - Other artifacts are collected as-is.

    Outputs artifact paths in GITHUB_OUTPUT multiline format.

    \b
    Examples:
        # Collect artifacts and write to GITHUB_OUTPUT
        gha-utils collect-artifacts \\
            --download-folder ./release_artifact \\
            --short-sha 346ce66 \\
            --nuitka-matrix '{"include": [...]}' \\
            --output "$GITHUB_OUTPUT"

    \b
        # Preview artifact collection (output to stdout)
        gha-utils collect-artifacts \\
            --download-folder ./release_artifact \\
            --short-sha abc1234
    """
    artifacts = collect_and_rename_artifacts(
        download_folder=download_folder,
        short_sha=short_sha,
        nuitka_matrix_json=nuitka_matrix,
    )

    github_output = format_github_output(artifacts)

    if is_stdout(output):
        logging.info(f"Print to {sys.stdout.name}")
    else:
        logging.info(f"Write to {output}")

    echo(github_output, file=prep_path(output))


@gha_utils.command(short_help="Update exclude-newer date in pyproject.toml")
@option(
    "--pyproject",
    type=file_path(resolve_path=True),
    default="pyproject.toml",
    help="Path to pyproject.toml file.",
)
@option(
    "--days",
    type=IntRange(min=1),
    default=7,
    help="Number of days in the past for the target date.",
)
@option(
    "-o",
    "--output",
    type=file_path(writable=True, resolve_path=True, allow_dash=True),
    default=None,
    help="Output file for modified=true/false (e.g., $GITHUB_OUTPUT).",
)
def update_exclude_newer(
    pyproject: Path,
    days: int,
    output: Path | None,
) -> None:
    """Update or add exclude-newer date in pyproject.toml.

    If [tool.uv] exists but exclude-newer is missing, adds it with a default
    value. If exclude-newer exists and is stale, updates it.

    Uses a fixed date (not relative) to prevent uv.lock timestamp churn on
    every sync. This ensures Renovate's minimumReleaseAge and uv's
    exclude-newer stay synchronized.

    \b
    Examples:
        # Check and update pyproject.toml
        gha-utils update-exclude-newer

    \b
        # Output result for GitHub Actions
        gha-utils update-exclude-newer --output "$GITHUB_OUTPUT"

    \b
        # Use a different cooldown period
        gha-utils update-exclude-newer --days 14
    """
    if not pyproject.exists():
        logging.info(f"No {pyproject} found, skipping.")
        if output:
            echo("modified=false", file=prep_path(output))
        return

    target_date = calculate_target_date(days)
    current_date = parse_exclude_newer_date(pyproject)

    logging.info(f"Current exclude-newer date: {current_date}")
    logging.info(f"Target date ({days} days ago): {target_date}")

    modified = False

    if current_date is None:
        # No exclude-newer found. Add it if [tool.uv] section exists.
        if has_tool_uv_section(pyproject):
            logging.info("Adding missing exclude-newer to [tool.uv] section.")
            modified = add_exclude_newer_to_file(pyproject, target_date)
        else:
            logging.info("No [tool.uv] section found, skipping.")
    elif current_date < target_date:
        # exclude-newer exists but is stale.
        modified = update_exclude_newer_in_file(pyproject, target_date)

    if output:
        echo(f"modified={'true' if modified else 'false'}", file=prep_path(output))


@gha_utils.command(short_help="Check Renovate migration prerequisites")
@option(
    "--repo",
    default=None,
    help="Repository in 'owner/repo' format. Defaults to $GITHUB_REPOSITORY.",
)
@option(
    "--sha",
    default=None,
    help="Commit SHA for permission checks. Defaults to $GITHUB_SHA.",
)
@option(
    "--format",
    "output_format",
    type=Choice(["text", "json", "github"], case_sensitive=False),
    default="text",
    help="Output format: text (human-readable), json (structured), "
    "or github (for $GITHUB_OUTPUT).",
)
@option(
    "-o",
    "--output",
    type=file_path(writable=True, resolve_path=True, allow_dash=True),
    default="-",
    help="Output file path. Defaults to stdout.",
)
@pass_context
def check_renovate(
    ctx: Context,
    repo: str | None,
    sha: str | None,
    output_format: str,
    output: Path,
) -> None:
    """Check prerequisites for Renovate migration.

    Validates that:

    \b
    - renovate.json5 configuration exists
    - No Dependabot version updates config exists (.github/dependabot.yaml)
    - Dependabot security updates are disabled
    - Token has commit statuses permission

    Use --format=github to output results for $GITHUB_OUTPUT, allowing
    workflows to use the values in conditional steps.

    \b
    Examples:
        # Human-readable output (default)
        gha-utils check-renovate

    \b
        # JSON output for parsing
        gha-utils check-renovate --format=json

    \b
        # GitHub Actions output format
        gha-utils check-renovate --format=github --output "$GITHUB_OUTPUT"

    \b
        # Manual invocation
        gha-utils check-renovate --repo owner/repo --sha abc123
    """
    # Apply defaults from environment.
    if repo is None:
        repo = os.getenv("GITHUB_REPOSITORY", "")
    if sha is None:
        sha = os.getenv("GITHUB_SHA", "")

    if not repo:
        logging.error("No repository specified. Set --repo or $GITHUB_REPOSITORY.")
        ctx.exit(1)
    if not sha:
        logging.error("No SHA specified. Set --sha or $GITHUB_SHA.")
        ctx.exit(1)

    # For text format, use the original function with console output.
    if output_format == "text":
        exit_code = run_migration_checks(repo, sha)
        ctx.exit(exit_code)

    # For json/github formats, collect results and output structured data.
    results = collect_check_results(repo, sha)

    if output_format == "json":
        content = results.to_json()
    else:  # github format
        content = results.to_github_output()

    echo(content, file=prep_path(output))


@gha_utils.command(short_help="Run repository consistency checks")
@option(
    "--repo-name",
    default=None,
    help="Repository name. Defaults to $GITHUB_REPOSITORY name component.",
)
@option(
    "--repo",
    default=None,
    help="Repository in 'owner/repo' format. Defaults to $GITHUB_REPOSITORY.",
)
@pass_context
def lint_repo(
    ctx: Context,
    repo_name: str | None,
    repo: str | None,
) -> None:
    """Run consistency checks on repository metadata.

    Reads ``package_name``, ``is_sphinx``, and ``project_description`` directly
    from ``pyproject.toml`` in the current directory.

    Checks:
    - Package name vs repository name (warning).
    - Website field set for Sphinx projects (warning).
    - Repository description matches project description (error).

    \b
    Examples:
        # In GitHub Actions (reads pyproject.toml automatically)
        gha-utils lint-repo --repo-name my-package

    \b
        # Local run (derives repo from $GITHUB_REPOSITORY or --repo)
        gha-utils lint-repo --repo owner/repo
    """
    # Apply defaults from environment.
    if repo is None:
        repo = os.getenv("GITHUB_REPOSITORY", "")
    if repo_name is None and repo:
        # Extract repo name from owner/repo format.
        repo_name = repo.split("/")[-1] if "/" in repo else repo

    # Derive package_name, is_sphinx, project_description from pyproject.toml.
    metadata = Metadata()
    package_name = get_project_name()
    is_sphinx = metadata.is_sphinx
    project_description = metadata.project_description

    exit_code = run_repo_lint(
        package_name=package_name,
        repo_name=repo_name,
        is_sphinx=is_sphinx,
        project_description=project_description,
        repo=repo if repo else None,
    )
    ctx.exit(exit_code)


@gha_utils.command(short_help="Create and push a Git tag")
@option(
    "--tag",
    required=True,
    help="Tag name to create (e.g., v1.2.3).",
)
@option(
    "--commit",
    default=None,
    help="Commit to tag. Defaults to HEAD.",
)
@option(
    "--push/--no-push",
    default=True,
    help="Push the tag to remote after creation.",
)
@option(
    "--skip-existing/--error-existing",
    default=True,
    help="Skip silently if tag exists, or fail with an error.",
)
@option(
    "-o",
    "--output",
    type=file_path(writable=True, resolve_path=True, allow_dash=True),
    default=None,
    help="Output file for created=true/false (e.g., $GITHUB_OUTPUT).",
)
@pass_context
def git_tag(
    ctx: Context,
    tag: str,
    commit: str | None,
    push: bool,
    skip_existing: bool,
    output: Path | None,
) -> None:
    """Create and optionally push a Git tag.

    This command is idempotent: if the tag already exists and --skip-existing
    is used, it exits successfully without making changes. This allows safe
    re-runs of workflows interrupted after tag creation.

    \b
    Examples:
        # Create and push a tag
        gha-utils git-tag --tag v1.2.3

    \b
        # Tag a specific commit
        gha-utils git-tag --tag v1.2.3 --commit abc123def

    \b
        # Create tag without pushing
        gha-utils git-tag --tag v1.2.3 --no-push

    \b
        # Fail if tag exists
        gha-utils git-tag --tag v1.2.3 --error-existing

    \b
        # Output result for GitHub Actions
        gha-utils git-tag --tag v1.2.3 --output "$GITHUB_OUTPUT"
    """
    try:
        created = create_and_push_tag(
            tag=tag,
            commit=commit,
            push=push,
            skip_existing=skip_existing,
        )
        if created:
            echo(f"Created{' and pushed' if push else ''} tag {tag!r}")
        else:
            echo(f"Tag {tag!r} already exists, skipped.")

        if output:
            echo(f"created={'true' if created else 'false'}", file=prep_path(output))

    except ValueError as e:
        logging.error(str(e))
        ctx.exit(1)
    except Exception as e:
        logging.error(f"Failed to create/push tag: {e}")
        ctx.exit(1)


@gha_utils.command(short_help="Generate PR body with workflow metadata")
@option(
    "--prefix",
    envvar="GHA_PR_BODY_PREFIX",
    default="",
    help="Content to prepend before the metadata details block. "
    "Can also be set via the GHA_PR_BODY_PREFIX environment variable.",
)
@option(
    "--template",
    type=Choice(get_template_names(), case_sensitive=False),
    default=None,
    help="Use a built-in prefix template instead of --prefix.",
)
@option(
    "--version",
    "version",
    default=None,
    help="Version string passed to the template (e.g. 1.2.0).",
)
@option(
    "--part",
    default=None,
    help="Version part passed to bump-version template (e.g. minor, major).",
)
@option(
    "-o",
    "--output",
    type=file_path(writable=True, resolve_path=True, allow_dash=True),
    default="-",
    help="Output file path. Defaults to stdout.",
)
def pr_body(
    prefix: str,
    template: str | None,
    version: str | None,
    part: str | None,
    output: Path,
) -> None:
    """Generate a PR body with a collapsible workflow metadata block.

    Reads ``GITHUB_*`` environment variables to produce a ``<details>`` block
    containing a metadata table (trigger, actor, ref, commit, job, workflow, run).

    When ``--output`` points to ``$GITHUB_OUTPUT``, the body is written in the
    heredoc format required by GitHub Actions multiline outputs.

    The prefix can be set via ``--template`` (built-in templates) or ``--prefix``
    (arbitrary content, also via ``GHA_PR_BODY_PREFIX`` env var). If both are
    given, ``--template`` takes precedence.

    \b
    Examples:
        # Preview metadata block locally
        gha-utils pr-body

    \b
        # Write to $GITHUB_OUTPUT for use in a workflow
        gha-utils pr-body --output "$GITHUB_OUTPUT"

    \b
        # Use a built-in template
        gha-utils pr-body --template bump-version --version 1.2.0 --part minor

    \b
        # With a prefix via environment variable
        GHA_PR_BODY_PREFIX="Fix formatting" gha-utils pr-body
    """

    def _auto_version() -> str:
        """Read current_version from bumpversion config and strip .dev suffix."""
        ver = Metadata.get_current_version()
        if not ver:
            msg = "Cannot auto-detect version: no bumpversion config found."
            raise SystemExit(msg)
        ver = re.sub(r"\.dev\d*$", "", ver)
        logging.info(f"Auto-detected version: {ver}")
        return ver

    # Map argument names to their values or callables.
    arg_sources: dict[str, str | None | Callable[[], str]] = {
        "version": version if version is not None else _auto_version,
        "part": part,
        "repo_url": _repo_url,  # Callable, will be invoked if needed.
    }

    title_str = ""
    commit_msg_str = ""

    if template:
        kwargs: dict[str, str] = {}
        for arg in template_args(template):
            value = arg_sources.get(arg)
            if value is None:
                msg = f"--{arg} is required for template '{template}'"
                raise SystemExit(msg)
            # Call if callable, otherwise use the value directly.
            kwargs[arg] = value() if callable(value) else value

        prefix = render_template(template, **kwargs)
        title_str = render_title(template, **kwargs)
        commit_msg_str = render_commit_message(template, **kwargs)

    metadata_block = generate_pr_metadata_block()
    body = build_pr_body(prefix, metadata_block)

    github_output_path = os.getenv("GITHUB_OUTPUT", "")
    is_github_output = (
        not is_stdout(output)
        and github_output_path
        and str(output) == github_output_path
    )

    if is_github_output:
        # Write in heredoc format for $GITHUB_OUTPUT.
        parts = [format_multiline_output("body", body)]
        if title_str:
            parts.append(f"title={title_str}")
        if commit_msg_str:
            parts.append(f"commit_message={commit_msg_str}")
        content = "\n".join(parts)
    else:
        content = body

    if is_stdout(output):
        logging.info(f"Print PR body to {sys.stdout.name}")
    else:
        logging.info(f"Write PR body to {output}")

    echo(content, file=prep_path(output))


@gha_utils.command(short_help="Update SHA-256 checksums for binary downloads")
@argument(
    "workflow_file",
    type=file_path(exists=True, readable=True, writable=True, resolve_path=True),
)
def update_checksums_cmd(workflow_file: Path) -> None:
    """Update SHA-256 checksums for direct binary downloads in a workflow file.

    Scans the file for GitHub release download URLs paired with
    ``sha256sum --check`` verification lines. Downloads each binary,
    computes the SHA-256, and replaces stale hashes in-place.

    \b
    Designed for Renovate ``postUpgradeTasks``: after a version bump changes a
    download URL, this command downloads the new binary and updates the hash.

    \b
    Examples:
        # Update checksums in a single workflow file
        gha-utils update-checksums .github/workflows/docs.yaml

    \b
        # Verify all checksums are current (no output if all match)
        gha-utils update-checksums .github/workflows/autofix.yaml
    """
    updated = update_checksums(workflow_file)
    for url, old_hash, new_hash in updated:
        echo(f"Updated: {url}")
        echo(f"  Old: {old_hash}")
        echo(f"  New: {new_hash}")
    if not updated:
        logging.info("All checksums are up to date.")
