# Why does this file exist, and why not put this in `__main__`?
#
# You might be tempted to import things from `__main__` later,
# but that will cause problems: the code will get executed twice:
#
# - When you run `python -m git_changelog` python will execute
#   `__main__.py` as a script. That means there won't be any
#   `git_changelog.__main__` in `sys.modules`.
# - When you import `__main__` it will get executed again (as a module) because
#   there's no `git_changelog.__main__` in `sys.modules`.

from __future__ import annotations

import argparse
import re
import sys
import warnings
from importlib import metadata
from pathlib import Path
from re import Pattern
from typing import TYPE_CHECKING, Any, Literal, TextIO

from jinja2.exceptions import TemplateNotFound
from platformdirs import user_config_dir

from git_changelog._internal import debug, templates
from git_changelog._internal.build import Changelog, Version
from git_changelog._internal.commit import (
    AngularConvention,
    BasicConvention,
    CommitConvention,
    ConventionalCommitConvention,
    LinuxConvention,
)
from git_changelog._internal.providers import Bitbucket, GitHub, GitLab, ProviderRefParser
from git_changelog._internal.versioning import bump_pep440, bump_semver

# YORE: EOL 3.10: Replace block with line 2.
if sys.version_info >= (3, 11):
    import tomllib
else:
    import tomli as tomllib

if TYPE_CHECKING:
    from collections.abc import Sequence

_MARKER_LINE_PREPEND = ":prepend:"

DEFAULT_VERSIONING = "semver"
"""Default versioning strategy."""
DEFAULT_VERSION_REGEX = r"^## \[(?P<version>v?[^\]]+)"
"""Default version regex for finding versions."""
DEFAULT_MARKER_LINE = "<!-- insertion marker -->"
"""Default marker line for finding insertion points."""
DEFAULT_CHANGELOG_FILE = "CHANGELOG.md"
"""Default changelog file name."""
CONVENTIONS = sorted(Changelog.CONVENTION)
"""Available commit message conventions."""
DEFAULT_CONFIG_FILES = [
    "pyproject.toml",
    ".git-changelog.toml",
    "config/git-changelog.toml",
    ".config/git-changelog.toml",
    str(Path(user_config_dir()) / "git-changelog.toml"),
]
"""Default configuration files read by git-changelog."""

_DEFAULT_DEBIAN_MARKER_LINE = _MARKER_LINE_PREPEND
_DEFAULT_DEBIAN_VERSION_REGEX = r"^[\w-]+ \((?:\d+:)?(?P<version>[^-+~)]+)(?!.*UNRELEASED)"

DEFAULT_SETTINGS: dict[str, Any] = {
    "bump": None,
    # YORE: Bump 3: Remove line.
    "bump_latest": None,
    "convention": "basic",
    "filter_commits": None,
    "in_place": False,
    "include_all": False,
    "input": DEFAULT_CHANGELOG_FILE,
    "marker_line": None,
    "omit_empty_versions": False,
    "output": sys.stdout,
    "parse_refs": False,
    "parse_trailers": False,
    "provider": None,
    "release_notes": False,
    "bumped_version": False,
    "repository": ".",
    "sections": None,
    "template": "keepachangelog",
    "jinja_context": {},
    "version_regex": None,
    "versioning": DEFAULT_VERSIONING,
    "zerover": True,
}
"""Default settings for the CLI."""


class Templates(tuple):  # noqa: SLOT001
    """Helper to pick a template on the command line."""

    def __contains__(self, item: object) -> bool:
        """Check if the template is in the list."""
        if isinstance(item, str):
            return item.startswith("path:") or super().__contains__(item)
        return False


def get_version() -> str:
    """Return the current `git-changelog` version.

    Returns:
        The current `git-changelog` version.
    """
    try:
        return metadata.version("git-changelog")
    except metadata.PackageNotFoundError:
        return "0.0.0"


def _comma_separated_list(value: str) -> list[str]:
    return value.split(",")


class _ParseDictAction(argparse.Action):
    def __call__(
        self,
        parser: argparse.ArgumentParser,  # noqa: ARG002
        namespace: argparse.Namespace,
        values: str | Sequence[Any] | None,
        option_string: str | Sequence[Any] | None = None,  # noqa: ARG002
    ):
        attribute = getattr(namespace, self.dest)
        if not isinstance(attribute, dict):
            setattr(namespace, self.dest, {})
        if isinstance(values, str):
            key, value = values.split("=", 1)
            getattr(namespace, self.dest)[key] = value


providers: dict[str, type[ProviderRefParser]] = {
    "github": GitHub,
    "gitlab": GitLab,
    "bitbucket": Bitbucket,
}
"""Available version control providers."""


class _DebugInfo(argparse.Action):
    def __init__(self, nargs: int | str | None = 0, **kwargs: Any) -> None:
        super().__init__(nargs=nargs, **kwargs)

    def __call__(self, *args: Any, **kwargs: Any) -> None:  # noqa: ARG002
        debug._print_debug_info()
        sys.exit(0)


def get_parser() -> argparse.ArgumentParser:
    """Return the CLI argument parser.

    Returns:
        An argparse parser.
    """
    parser = argparse.ArgumentParser(
        add_help=False,
        prog="git-changelog",
        description=re.sub(
            r"\n *",
            "\n",
            f"""
            Automatic Changelog generator using Jinja2 templates.

            This tool parses your commit messages to extract useful data
            that is then rendered using Jinja2 templates, for example to
            a changelog file formatted in Markdown.

            Each Git tag will be treated as a version of your project.
            Each version contains a set of commits, and will be an entry
            in your changelog. Commits in each version will be grouped
            by sections, depending on the commit convention you follow.

            ### Conventions

            {BasicConvention._format_sections_help()}
            {AngularConvention._format_sections_help()}
            {ConventionalCommitConvention._format_sections_help()}
            {LinuxConvention._format_sections_help()}
            """,
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )

    parser.add_argument(
        "repository",
        metavar="REPOSITORY",
        nargs="?",
        help="The repository path, relative or absolute. Default: current working directory.",
    )

    parser.add_argument(
        "--config-file",
        metavar="PATH",
        nargs="*",
        help="Configuration file(s).",
    )

    # YORE: Bump 3: Remove block.
    parser.add_argument(
        "-b",
        "--bump-latest",
        action="store_true",
        dest="bump_latest",
        help="Deprecated, use --bump=auto instead. "
        "Guess the new latest version by bumping the previous one based on the set of unreleased commits. "
        "For example, if a commit contains breaking changes, bump the major number (or the minor number for 0.x versions). "
        "Else if there are new features, bump the minor number. Else just bump the patch number. "
        "Default: unset (false).",
    )

    parser.add_argument(
        "--bumped-version",
        action="store_true",
        dest="bumped_version",
        help="Show bumped version and exit.",
    )
    parser.add_argument(
        "-B",
        "--bump",
        action="store",
        dest="bump",
        metavar="VERSION",
        help="Specify the bump from latest version for the set of unreleased commits. "
        "Can be one of `auto`, `major`, `minor`, `patch` or a valid SemVer version (eg. 1.2.3). "
        "For both SemVer and PEP 440 versioning schemes (see -n), `auto` will bump the major number "
        "if a commit contains breaking changes (or the minor number for 0.x versions, see -Z), "
        "else the minor number if there are new features, else the patch number. "
        "Default: unset (false).",
    )
    parser.add_argument(
        "-n",
        "--versioning",
        action="store",
        dest="versioning",
        metavar="SCHEME",
        help="Versioning scheme to use when bumping and comparing versions. "
        "The selected scheme will impact the values accepted by the `--bump` option. "
        "Supported: `pep440`, `semver`. PEP 440 provides the following bump strategies: `auto`, "
        f"`{'`, `'.join(part for part in bump_pep440.strategies if '+' not in part)}`. "
        "Values `auto`, `major`, `minor`, `micro` can be suffixed with one of `+alpha`, `+beta`, `+candidate`, "
        "and/or `+dev`. Values `alpha`, `beta` and `candidate` can be suffixed with `+dev`. "
        "Examples: `auto+alpha`, `major+beta+dev`, `micro+dev`, `candidate+dev`, etc.. "
        "SemVer provides the following bump strategies: `auto`, "
        f"`{'`, `'.join(bump_semver.strategies)}`. "
        "See the docs for more information. Default: unset (`semver`).",
    )
    parser.add_argument(
        "-h",
        "--help",
        action="help",
        default=argparse.SUPPRESS,
        help="Show this help message and exit.",
    )
    parser.add_argument(
        "-i",
        "--in-place",
        action="store_true",
        dest="in_place",
        help="Insert new entries (versions missing from changelog) in-place. "
        "An output file must be specified. With custom templates, "
        "you can pass two additional Parameters: `--version-regex` and `--marker-line`. "
        "When writing in-place, an `in_place` variable "
        "will be injected in the Jinja context, "
        "allowing to adapt the generated contents "
        "(for example to skip changelog headers or footers). Default: unset (false).",
    )
    parser.add_argument(
        "-g",
        "--version-regex",
        action="store",
        metavar="REGEX",
        dest="version_regex",
        help="A regular expression to match versions in the existing changelog "
        "(used to find the latest release) when writing in-place. "
        "The regular expression must be a Python regex with a `version` named group. "
        f"Default: `{DEFAULT_VERSION_REGEX}`.",
    )

    parser.add_argument(
        "-m",
        "--marker-line",
        action="store",
        metavar="MARKER",
        dest="marker_line",
        help="A marker line at which to insert new entries "
        "(versions missing from changelog). "
        "If two marker lines are present in the changelog, "
        "the contents between those two lines will be overwritten "
        "(useful to update an 'Unreleased' entry for example). "
        f"Default: `{DEFAULT_MARKER_LINE}`.",
    )
    parser.add_argument(
        "-o",
        "--output",
        action="store",
        metavar="FILE",
        dest="output",
        help="Output to given file. Default: standard output.",
    )
    parser.add_argument(
        "-p",
        "--provider",
        metavar="PROVIDER",
        dest="provider",
        choices=providers.keys(),
        help="Explicitly specify the repository provider. Default: unset.",
    )
    parser.add_argument(
        "-r",
        "--parse-refs",
        action="store_true",
        dest="parse_refs",
        help="Parse provider-specific references in commit messages (GitHub/GitLab/Bitbucket "
        "issues, PRs, etc.). Default: unset (false).",
    )
    parser.add_argument(
        "-R",
        "--release-notes",
        action="store_true",
        dest="release_notes",
        help="Output release notes to stdout based on the last entry in the changelog. Default: unset (false).",
    )
    parser.add_argument(
        "-I",
        "--input",
        metavar="FILE",
        dest="input",
        help=f"Read from given file when creating release notes. Default: `{DEFAULT_CHANGELOG_FILE}`.",
    )
    parser.add_argument(
        "-c",
        "--convention",
        "--commit-style",
        "--style",
        metavar="CONVENTION",
        choices=CONVENTIONS,
        dest="convention",
        help=f"The commit convention to match against. Default: `{DEFAULT_SETTINGS['convention']}`.",
    )
    parser.add_argument(
        "-s",
        "--sections",
        action="store",
        type=_comma_separated_list,
        metavar="SECTIONS",
        dest="sections",
        help="A comma-separated list of sections to render. "
        "Use `:all:` to include all available sections for the selected convention. "
        "See the available sections for each supported convention in the description. "
        "Default: unset (None).",
    )
    parser.add_argument(
        "-a",
        "--include-all",
        action="store_true",
        dest="include_all",
        help="Include all commits, even those without a recognized type. "
        "These commits will be rendered in a 'Misc' section at the end of each version. "
        "Default: unset (false).",
    )
    parser.add_argument(
        "-t",
        "--template",
        choices=Templates(("angular", "keepachangelog", "debian")),
        metavar="TEMPLATE",
        dest="template",
        help="The Jinja2 template to use. Prefix it with `path:` to specify the path "
        "to a Jinja templated file. "
        f"Default: `{DEFAULT_SETTINGS['template']}`.",
    )
    parser.add_argument(
        "-T",
        "--trailers",
        "--git-trailers",
        action="store_true",
        dest="parse_trailers",
        help="Parse Git trailers in the commit message. "
        "See https://git-scm.com/docs/git-interpret-trailers. Default: unset (false).",
    )
    parser.add_argument(
        "-E",
        "--omit-empty-versions",
        action="store_true",
        dest="omit_empty_versions",
        help="Omit empty versions from the output. Default: unset (false).",
    )
    parser.add_argument(
        "-Z",
        "--no-zerover",
        action="store_false",
        dest="zerover",
        help="By default, breaking changes on a 0.x don't bump the major version, maintaining it at 0. "
        "With this option, a breaking change will bump a 0.x version to 1.0.",
    )
    parser.add_argument(
        "-F",
        "--filter-commits",
        action="store",
        metavar="RANGE",
        dest="filter_commits",
        help="The Git revision-range filter to use (e.g. `v1.2.0..`). Default: no filter.",
    )
    parser.add_argument(
        "-j",
        "--jinja-context",
        action=_ParseDictAction,
        metavar="KEY=VALUE",
        dest="jinja_context",
        help="Pass additional key/value pairs to the template. Option can be used multiple times. "
        "The key/value pairs are accessible as 'jinja_context' in the template.",
    )
    parser.add_argument(
        "-V",
        "--version",
        action="version",
        version=f"%(prog)s {debug._get_version()}",
        help="Show the current version of the program and exit.",
    )
    parser.add_argument("--debug-info", action=_DebugInfo, help="Print debug information.")

    return parser


def _latest(lines: list[str], regex: Pattern) -> tuple[str | None, int]:
    for idx, line in enumerate(lines):
        match = regex.search(line)
        if match:
            return match.groupdict()["version"], idx
    return None, 0


def _unreleased(versions: list[Version], last_release: str) -> list[Version]:
    for index, version in enumerate(versions):
        if version.tag == last_release:
            return versions[:index]
    return versions


def read_config(
    config_file: Sequence[str | Path] | str | Path | None = DEFAULT_CONFIG_FILES,
) -> dict:
    """Find config files and initialize settings with the one of highest priority.

    Parameters:
        config_file: A path or list of paths to configuration file(s); or `None` to
            disable config file settings. Default: a list of paths given by
            [`git_changelog.DEFAULT_CONFIG_FILES`][].

    Returns:
        A settings dictionary. Default settings if no config file is found or `config_file` is `None`.

    """
    project_config = DEFAULT_SETTINGS.copy()
    if config_file is None:  # Unset config file.
        return project_config

    for filename in config_file if isinstance(config_file, (list, tuple)) else [config_file]:
        _path = Path(filename)  # ty:ignore[invalid-argument-type]

        if not _path.exists():
            continue

        with _path.open("rb") as file:
            new_settings = tomllib.load(file)
        if _path.name == "pyproject.toml":
            new_settings = new_settings.get("tool", {}).get("git-changelog", {}) or new_settings.get(
                "tool.git-changelog",
                {},
            )

            if not new_settings:  # pyproject.toml did not have a git-changelog section.
                continue

        # Settings can have hyphens like in the CLI.
        new_settings = {key.replace("-", "_"): value for key, value in new_settings.items()}

        # YORE: Bump 3: Remove block.
        if "bump_latest" in new_settings:
            _opt_value = new_settings["bump_latest"]
            _suggestion = (
                "remove it from the config file" if not _opt_value else "set `bump = 'auto'` in the config file instead"
            )
            warnings.warn(
                f"`bump-latest = {str(_opt_value).lower()}` option found "
                f"in config file ({_path.absolute()}). This option will be removed in the future. "
                f"To achieve the same result, please {_suggestion}.",
                FutureWarning,
                stacklevel=1,
            )

        # Massage found values to meet expectations.
        # Parse sections.
        if "sections" in new_settings:
            # Remove "sections" from dict, only restore if the list is valid.
            sections = new_settings.pop("sections", None)
            if isinstance(sections, str):
                sections = sections.split(",")

            sections = [s.strip() for s in sections if isinstance(s, str) and s.strip()]

            if sections:  # TOML doesn't store null/nil.
                new_settings["sections"] = sections

        project_config.update(new_settings)
        break

    return project_config


def parse_settings(args: list[str] | None = None) -> dict:
    """Parse arguments and config files to build the final settings set.

    Parameters:
        args: Arguments passed from the command line.

    Returns:
        A dictionary with the final settings.
    """
    parser = get_parser()
    opts = vars(parser.parse_args(args=args))

    # Determine which arguments were explicitly set with the CLI
    sentinel = object()
    sentinel_ns = argparse.Namespace(**dict.fromkeys(opts, sentinel))
    explicit_opts_dict = {
        key: opts.get(key, None)
        for key, value in vars(parser.parse_args(namespace=sentinel_ns, args=args)).items()
        if value is not sentinel
    }

    config_file = explicit_opts_dict.pop("config_file", DEFAULT_CONFIG_FILES)
    if str(config_file).strip().lower() in ("no", "none", "off", "false", "0", ""):
        config_file = None
    elif str(config_file).strip().lower() in ("yes", "default", "on", "true", "1"):
        config_file = DEFAULT_CONFIG_FILES

    jinja_context = explicit_opts_dict.pop("jinja_context", {})

    settings = read_config(config_file)

    # CLI arguments override the config file settings
    settings.update(explicit_opts_dict)

    # Merge jinja context, CLI values override config file values.
    settings["jinja_context"].update(jinja_context)

    # YORE: Bump 3: Remove block.
    if "bump_latest" in explicit_opts_dict:
        warnings.warn("`--bump-latest` is deprecated in favor of `--bump=auto`", FutureWarning, stacklevel=1)

    return settings


def build(
    repository: str,
    convention: str | CommitConvention,
    parse_refs: bool = False,  # noqa: FBT001,FBT002
    parse_trailers: bool = False,  # noqa: FBT001,FBT002
    sections: list[str] | None = None,
    # YORE: Bump 3: Remove line.
    bump_latest: bool = False,  # noqa: FBT001,FBT002
    omit_empty_versions: bool = False,  # noqa: FBT001,FBT002
    provider: str | None = None,
    bump: str | None = None,
    zerover: bool = True,  # noqa: FBT001,FBT002
    filter_commits: str | None = None,
    include_all: bool = False,  # noqa: FBT001,FBT002
    versioning: Literal["pep440", "semver"] = "semver",
    **kwargs: Any,  # noqa: ARG001
) -> Changelog:
    """Build a changelog.

    Parameters:
        repository: Path to a local repository.
        convention: Name of a commit message style/convention.
        parse_refs: Whether to parse provider-specific references (GitHub/GitLab issues, PRs, etc.).
        parse_trailers: Whether to parse Git trailers.
        sections: Sections to render (features, bug fixes, etc.).
        bump_latest: Deprecated, use --bump=auto instead.
            Whether to try and bump the latest version to guess the new one.
        omit_empty_versions: Whether to omit empty versions from the output.
        provider: Provider class used by this repository.
        bump: Whether to try and bump to a given version.
        zerover: Keep major version at zero, even for breaking changes.
        filter_commits: The Git revision-range used to filter commits in git-log.
        include_all: Whether to include commits without a recognized type.
        versioning: Versioning scheme to use when grouping commits and bumping versions.
        **kwargs: Swallowing kwargs to allow passing all settings at once.

    Raises:
        ValueError: When some arguments are incompatible or missing.

    Returns:
        The built changelog.
    """
    # YORE: Bump 3: Remove block.
    if bump_latest:
        warnings.warn("`bump_latest=True` is deprecated in favor of `bump='auto'`", DeprecationWarning, stacklevel=1)
        if bump is None:
            bump = "auto"

    # Build data.
    changelog = Changelog(
        repository,
        provider=providers[provider] if provider else None,
        convention=convention,
        parse_provider_refs=parse_refs,
        parse_trailers=parse_trailers,
        sections=sections,
        bump=bump,
        zerover=zerover,
        filter_commits=filter_commits,
        include_all=include_all,
        versioning=versioning,
    )

    # Remove empty versions from changelog data.
    if omit_empty_versions:
        section_set = set(changelog.sections)
        empty_versions = [
            version for version in changelog.versions_list if section_set.isdisjoint(version.sections_dict.keys())
        ]
        for version in empty_versions:
            changelog.versions_list.remove(version)
            changelog.versions_dict.pop(version.tag)

    return changelog


def render(
    changelog: Changelog,
    template: str,
    in_place: bool = False,  # noqa: FBT001,FBT002
    output: str | TextIO | None = None,
    version_regex: str | None = None,
    marker_line: str | None = None,
    # YORE: Bump 3: Remove line.
    bump_latest: bool = False,  # noqa: FBT001,FBT002
    bump: str | None = None,
    jinja_context: dict[str, Any] | None = None,
) -> str:
    """Render a changelog.

    This function updates the specified output file (side-effect) or writes to stdout.

    Parameters:
        changelog: The changelog to render.
        template: Name of a builtin template, or path to a custom template (prefixed with `path:`).
        in_place: Whether to update the changelog in-place.
        output: Output/changelog file.
        version_regex: Regular expression to match versions in an existing changelog file.
        marker_line: Marker line used to insert contents in an existing changelog.
        bump_latest: Deprecated, use --bump=auto instead.
            Whether to try and bump the latest version to guess the new one.
        bump: Whether to try and bump to a given version.
        jinja_context: Key/value pairs passed to the Jinja template.

    Raises:
        ValueError: When some arguments are incompatible or missing.

    Returns:
        The rendered changelog.
    """
    # Get template.
    if template.startswith("path:"):
        path = template.replace("path:", "", 1)
        try:
            jinja_template = templates.get_custom_template(path)
        except TemplateNotFound as error:
            raise ValueError(f"No such file: {path}") from error
    else:
        jinja_template = templates.get_template(template)

    if output is None:
        output = sys.stdout

    # Handle misconfiguration early.
    if in_place and output is sys.stdout:
        raise ValueError("Cannot write in-place to stdout")

    # YORE: Bump 3: Remove block.
    if bump_latest:
        warnings.warn("`bump_latest=True` is deprecated in favor of `bump='auto'`", DeprecationWarning, stacklevel=1)
        if bump is None:
            bump = "auto"

    # Render new entries in-place.
    if in_place:
        # Read current changelog lines.
        with open(output, encoding="utf8") as changelog_file:  # ty:ignore[invalid-argument-type]
            lines = changelog_file.read().splitlines()

        # Prepare version regex and marker line.
        if version_regex is None:
            version_regex = _DEFAULT_DEBIAN_VERSION_REGEX if template == "debian" else DEFAULT_VERSION_REGEX
        if marker_line is None:
            marker_line = _DEFAULT_DEBIAN_MARKER_LINE if template == "debian" else DEFAULT_MARKER_LINE

        # Only keep new entries (missing from changelog).
        last_released, last_released_line = _latest(lines, re.compile(version_regex))
        if last_released:
            # Check if the latest version is already in the changelog.
            if last_released in [
                changelog.versions_list[0].tag,
                changelog.versions_list[0].planned_tag,
            ]:
                raise ValueError(f"Version {last_released} already in changelog")
            changelog.versions_list = _unreleased(
                changelog.versions_list,
                last_released,
            )

        # Render new entries.
        rendered = (
            jinja_template.render(
                changelog=changelog,
                jinja_context=jinja_context,
                in_place=True,
            ).rstrip("\n")
            + "\n"
        )

        # Find marker line(s) in current changelog.
        if marker_line == ":prepend:":
            # With prepend mode, take account of last_released_line to replace unreleased changelog.
            lines[0:last_released_line] = [rendered]
        else:
            marker = lines.index(marker_line)
            try:
                marker2 = lines[marker + 1 :].index(marker_line)
            except ValueError:
                # Apply new entries at marker line.
                lines[marker] = rendered
            else:
                # Apply new entries between marker lines.
                lines[marker : marker + marker2 + 2] = [rendered]

        # Write back updated changelog lines.
        with open(output, "w", encoding="utf8") as changelog_file:  # ty:ignore[no-matching-overload]
            changelog_file.write("\n".join(lines).rstrip("\n") + "\n")

    # Overwrite output file.
    else:
        rendered = jinja_template.render(changelog=changelog, jinja_context=jinja_context)

        # Write result in specified output.
        if output is sys.stdout:
            sys.stdout.write(rendered)
        else:
            with open(output, "w", encoding="utf8") as stream:  # ty:ignore[no-matching-overload]
                stream.write(rendered)

    return rendered


def build_and_render(
    repository: str,
    template: str,
    convention: str | CommitConvention,
    parse_refs: bool = False,  # noqa: FBT001,FBT002
    parse_trailers: bool = False,  # noqa: FBT001,FBT002
    sections: list[str] | None = None,
    in_place: bool = False,  # noqa: FBT001,FBT002
    output: str | TextIO | None = None,
    version_regex: str | None = None,
    marker_line: str | None = None,
    # YORE: Bump 3: Remove line.
    bump_latest: bool = False,  # noqa: FBT001,FBT002
    omit_empty_versions: bool = False,  # noqa: FBT001,FBT002
    provider: str | None = None,
    bump: str | None = None,
    zerover: bool = True,  # noqa: FBT001,FBT002
    filter_commits: str | None = None,
    jinja_context: dict[str, Any] | None = None,
    versioning: Literal["pep440", "semver"] = "semver",
    include_all: bool = False,  # noqa: FBT001,FBT002
    **kwargs: Any,  # noqa: ARG001
) -> tuple[Changelog, str]:
    """Build a changelog and render it.

    This function returns the changelog instance and the rendered contents,
    but also updates the specified output file (side-effect) or writes to stdout.

    Parameters:
        repository: Path to a local repository.
        template: Name of a builtin template, or path to a custom template (prefixed with `path:`).
        convention: Name of a commit message style/convention.
        parse_refs: Whether to parse provider-specific references (GitHub/GitLab issues, PRs, etc.).
        parse_trailers: Whether to parse Git trailers.
        sections: Sections to render (features, bug fixes, etc.).
        in_place: Whether to update the changelog in-place.
        output: Output/changelog file.
        version_regex: Regular expression to match versions in an existing changelog file.
        marker_line: Marker line used to insert contents in an existing changelog.
        bump_latest: Deprecated, use --bump=auto instead.
            Whether to try and bump the latest version to guess the new one.
        omit_empty_versions: Whether to omit empty versions from the output.
        provider: Provider class used by this repository.
        bump: Whether to try and bump to a given version.
        zerover: Keep major version at zero, even for breaking changes.
        filter_commits: The Git revision-range used to filter commits in git-log.
        include_all: Whether to include commits without a recognized type.
        jinja_context: Key/value pairs passed to the Jinja template.
        versioning: Versioning scheme to use when grouping commits and bumping versions.
        **kwargs: Swallowing kwargs to allow passing all settings at once.

    Raises:
        ValueError: When some arguments are incompatible or missing.

    Returns:
        The built changelog and the rendered contents.
    """
    changelog = build(
        repository=repository,
        convention=convention,
        parse_refs=parse_refs,
        parse_trailers=parse_trailers,
        sections=sections,
        bump_latest=bump_latest,
        omit_empty_versions=omit_empty_versions,
        provider=provider,
        bump=bump,
        zerover=zerover,
        filter_commits=filter_commits,
        include_all=include_all,
        versioning=versioning,
    )
    rendered = render(
        changelog=changelog,
        template=template,
        in_place=in_place,
        output=output,
        version_regex=version_regex,
        marker_line=marker_line,
        bump_latest=bump_latest,
        bump=bump,
        jinja_context=jinja_context,
    )
    return changelog, rendered


def get_release_notes(
    input_file: str | Path = "CHANGELOG.md",
    version_regex: str = DEFAULT_VERSION_REGEX,
    marker_line: str = DEFAULT_MARKER_LINE,
) -> str:
    """Get release notes from existing changelog.

    This will return the latest entry in the changelog.

    Parameters:
        input_file: The changelog to read from.
        version_regex: A regular expression to match version entries.
        marker_line: The insertion marker line in the changelog.

    Returns:
        The latest changelog entry.
    """
    release_notes = []
    found_version = False
    with open(input_file, encoding="utf8") as changelog:
        for line in changelog:
            if re.search(version_regex, line):
                if found_version:
                    break
                found_version = True
                release_notes.append(line.rstrip("\n"))
            elif found_version:
                release_notes.append(line.rstrip("\n"))
    result = "\n".join(release_notes).strip()
    if result.endswith(marker_line):
        result = result[: -len(marker_line)].strip()
    return result


def output_release_notes(
    input_file: str = "CHANGELOG.md",
    version_regex: str | None = None,
    marker_line: str | None = None,
    output_file: str | TextIO | None = None,
) -> None:
    """Print release notes from existing changelog.

    This will print the latest entry in the changelog.

    Parameters:
        input_file: The changelog to read from.
        version_regex: A regular expression to match version entries.
        marker_line: The insertion marker line in the changelog.
        output_file: Where to print/write the release notes.
    """
    version_regex = version_regex or DEFAULT_VERSION_REGEX
    marker_line = marker_line or DEFAULT_MARKER_LINE
    output_file = output_file or sys.stdout
    release_notes = get_release_notes(input_file, version_regex, marker_line)
    try:
        output_file.write(release_notes)  # ty:ignore[unresolved-attribute]
    except AttributeError:
        with open(output_file, "w") as file:  # ty:ignore[no-matching-overload]
            file.write(release_notes)


def main(args: list[str] | None = None) -> int:
    """Run the main program.

    This function is executed when you type `git-changelog` or `python -m git_changelog`.

    Parameters:
        args: Arguments passed from the command line.

    Returns:
        An exit code.
    """
    settings = parse_settings(args)

    if settings.pop("release_notes"):
        output_release_notes(
            input_file=settings["input"],
            version_regex=settings["version_regex"],
            marker_line=settings["marker_line"],
            output_file=None,  # force writing to stdout
        )
        return 0

    if settings.pop("bumped_version"):
        settings["bump"] = settings.get("bump") or "auto"
        changelog = build(**settings)
        if not changelog.versions_list:
            print("git-changelog: No version found in the repository.", file=sys.stderr)
            return 1
        print(changelog.versions_list[0].planned_tag)
        return 0

    try:
        build_and_render(**settings)
    except ValueError as error:
        print(f"git-changelog: {error}", file=sys.stderr)
        return 1

    return 0
