from __future__ import annotations

import datetime
import enum
import json
import logging
import os
import pathlib
import re
import sys
import tomllib
import typing as _t

import packaging.version
import semver
import yuio.app
import yuio.git
import yuio.io
import yuio.parse
import yuio.term
import yuio.theme
from markdown_it.tree import SyntaxTreeNode

from cl_keeper._version import __version__
from cl_keeper.check import check as _check
from cl_keeper.config import (
    PYTHON_PRESET,
    Config,
    GlobalConfig,
    Input,
    LinkTemplates,
    Output,
    VersionFormat,
)
from cl_keeper.context import Context, IssueCode, IssueScope
from cl_keeper.fix import fix as _fix
from cl_keeper.fix import format_section_heading as _format_section_heading
from cl_keeper.fix import format_section_label as _format_section_label
from cl_keeper.fix import make_link_for_section as _make_link_for_section
from cl_keeper.model import (
    Changelog,
    ReleaseSection,
    RepoVersion,
    Section,
    SubSection,
    SubSectionCategoryKind,
    UnreleasedSection,
    Version,
)
from cl_keeper.parse import canonize_version as _canonize_version
from cl_keeper.parse import detect_subsection_metadata as _detect_subsection_metadata
from cl_keeper.parse import parse as _parse
from cl_keeper.parse import parse_version as _parse_version
from cl_keeper.parse import split_into_sections as _split_into_sections
from cl_keeper.render import print_diff as _print_diff
from cl_keeper.render import render as _render
from cl_keeper.sort import merge_sections as _merge_sections
from cl_keeper.vcs import detect_origin as _detect_origin
from cl_keeper.vcs import get_repo_versions

logger = logging.getLogger(__name__)


_config: Config
_global_config: GlobalConfig

_COMMENT_RE = re.compile(r"\<\!\-\-.*?(\-\-\>|\Z)", re.MULTILINE | re.DOTALL)


@yuio.app.app(version=__version__, bug_report=True, doc_format="rst")
def main(
    global_config: GlobalConfig = yuio.app.inline(),
):
    global _config, _global_config

    logger.debug("changelog keeper version %s", __version__)
    logging.getLogger("markdown_it").setLevel("WARNING")

    _config, _global_config = _load_config(global_config)


main.description = """
A helper for maintaining changelog files that use keep-a-changelog__ format.

__ https://keepachangelog.com/

"""

main.epilog = """
.. if-not-sphinx::

    Further help
    ------------

    -   To get help for a specific subcommand:

        .. code-block:: sh

            clk <subcommand> --help

    -   To list all aliases and flags:

        .. code-block:: sh

            clk --help=all

    -   Online documentation: https://cl-keeper.readthedocs.io/.

    -   Changelog format: https://keepachangelog.com/

"""


@main.subcommand
def check():
    """
    Check contents of the changelog file.

    """

    ctx = _make_context()

    repo_versions = None
    if ctx.config.check_repo_tags:
        repo_versions = get_repo_versions(ctx.root, ctx)

    _check(_parse(ctx), ctx, repo_versions)

    if ctx.has_messages():
        ctx.report()
        ctx.exit_if_has_errors()
    else:
        yuio.io.success("No issues detected")


@main.subcommand
def fix(
    #: Don't save changes, print the diff instead. Implies :cli:flag:`--diff`.
    dry_run: bool = False,
    #: Print diff to ``stdout``.
    diff: bool = False,
):
    """
    Fix contents of the changelog file.

    """

    ctx = _make_context()
    original = ctx.src

    repo_versions = None
    if ctx.config.check_repo_tags:
        repo_versions = get_repo_versions(ctx.root, ctx)

    changelog = _parse(ctx)
    _fix(changelog, ctx, repo_versions)

    result = _render(changelog, ctx)

    ctx.reset(result)

    _check(_parse(ctx), ctx, repo_versions)

    if result == original and ctx.has_messages():
        yuio.io.success("No fixable issues detected")
    elif result == original:
        yuio.io.success("No issues detected")
    elif not dry_run:
        yuio.io.success("Changelog successfully fixed")
        if ctx.path is Input.STDIN:
            print(result, end="")
        else:
            ctx.path.write_text(result, encoding="utf-8")

    if ctx.has_messages():
        yuio.io.heading("Unfixed issues")
        ctx.report()

    if diff or dry_run:
        _print_diff(original, result, ctx.path)


class BumpMode(enum.Enum):
    AUTO = "auto"
    MAJOR = "major"
    MINOR = "minor"
    PATCH = "patch"
    POST = "post"

    def __str__(self) -> str:
        return self.value


_PRE_RELEASE_GROUP = yuio.app.MutuallyExclusiveGroup()


@main.subcommand
def bump(
    #: Release version or tag that will be used for a new release.
    #: Can start with :cli:field:`.tag_prefix`, in which case the prefix is stripped.
    #:
    #: .. cut-if-not-sphinx::
    #:
    #: If :cli:arg:`<version>` is ``major``, ``minor``, ``patch``, or ``post``, this
    #: command bumps the corresponding component of the latest version.
    #: In this case, the latest version is determined by comparing all releases
    #: (and tags, if :cli:field:`.check_repo_tags` is ``true``) with respect to the
    #: chosen :cli:field:`.version_format`.
    #:
    #: If :cli:arg:`<version>` is ``auto``, this command inspects contents
    #: of the ``unreleased`` section to determine which version component to bump:
    #:
    #: -   if there is a ``breaking`` category, it creates a major release;
    #: -   if there are ``added``, ``changed``, or ``removed`` categories, it creates
    #:     a minor release;
    #: -   if there are ``security``, ``deprecated``, ``performance``, or ``fixed``
    #:     categories, it creates a patch release;
    #: -   if none of the above categories are present, the command fails.
    #:
    #: The list of categories for automatic bumping can be adjusted, see config options
    #: :cli:field:`.bump_patch_categories`, :cli:field:`.bump_minor_categories`,
    #: :cli:field:`.bump_major_categories`.
    version: (
        _t.Annotated[BumpMode, yuio.parse.WithMeta(desc="version component")]
        | str
        | None
        | yuio.git.Tag
    ) = None,
    /,
    #: Create an alpha pre-release.
    #:
    #: .. cut-if-not-sphinx::
    #:
    #: This option is only allowed if :cli:arg:`<version>` is ``auto``, ``major``,
    #: ``minor``, ``patch``, or ``post``, or if :cli:arg:`<version>` is not given
    #: at all. In the later case, the previous release should itself be a pre-release.
    alpha: bool = yuio.app.field(default=False, mutex_group=_PRE_RELEASE_GROUP),
    #: Create a beta pre-release, similar to :cli:flag:`--alpha`.
    beta: bool = yuio.app.field(default=False, mutex_group=_PRE_RELEASE_GROUP),
    #: Create a release candidate, similar to :cli:flag:`--alpha`.
    rc: bool = yuio.app.field(
        default=False, mutex_group=_PRE_RELEASE_GROUP, flags=["--rc", "--pre"]
    ),
    #: Produce result even if errors are detected.
    ignore_errors: bool = False,
    #: Don't save changes, print the diff instead. Implies :cli:flag:`--diff`.
    dry_run: bool = False,
    #: Print diff to ``stdout``.
    diff: bool = False,
    #: Open generated changes for editing.
    edit: bool = False,
    #: Commit and tag the release after updating changelog.
    #:
    #: Changelog Keeper will pause before committing and allow you to inspect
    #: repository or cancel the commit.
    commit: bool = False,
):
    """
    Move entries from ``unreleased`` to a new release.

    First form moves entries from the ``unreleased`` section to a new release
    for the specific version.

    Second form does the same, but the new version number is generated based
    on the last release.

    """

    if version is None and not alpha and not beta and not rc:
        raise yuio.parse.ParsingError(
            "<version> is required when --alpha, --beta, and --rc are not specified"
        )
    if isinstance(version, str) and (alpha or beta or rc):
        raise yuio.parse.ParsingError(
            "--alpha, --beta, --rc are not allowed when specifying custom versions"
        )

    ctx = _make_context()
    original = ctx.src

    if commit:
        repo = yuio.git.Repo(ctx.root)
        status = repo.status()
        if status.cherry_pick_head:
            raise yuio.app.AppError("Can't bump changelog: cherry pick is in progress")
        if status.merge_head:
            raise yuio.app.AppError("Can't bump changelog: merge is in progress")
        if status.rebase_head:
            raise yuio.app.AppError("Can't bump changelog: rebase is in progress")
        if status.revert_head:
            raise yuio.app.AppError("Can't bump changelog: revert is in progress")
        if status.bisect_start:
            raise yuio.app.AppError("Can't bump changelog: bisect is in progress")
        if not status.branch:
            raise yuio.app.AppError("Can't bump changelog: git head is detached")

    if isinstance(version, str) and version.startswith(ctx.config.tag_prefix):
        version = version[len(ctx.config.tag_prefix) :]

    changelog = _parse(ctx)

    # We will insert new section at this position.ss
    insert_at_pos = None

    # Find all unreleased section and remove them from changelog. We will insert
    # new section at position of the first found unreleased section.
    found = None
    to_remove = set()
    for i, section, _ in _find_sections(FindMode.UNRELEASED, changelog, ctx.config):
        to_remove.add(i)
        if found is None:
            found = section
            insert_at_pos = i
        else:
            _merge_sections(found, section)
    changelog.sections = [
        section for i, section in enumerate(changelog.sections) if i not in to_remove
    ]
    if found is None:
        found = UnreleasedSection(
            heading=None, subsections=[], version_link=None, version_label=None
        )

    # Find latest release in the changelog. We will use its version for new link.
    # If we didn't find any unreleased sections, insert new section before
    # the first release.
    found_latest = None
    for i, section, _ in _find_sections(FindMode.LATEST, changelog, ctx.config):
        found_latest = section.as_release()
        if insert_at_pos is None:
            insert_at_pos = max(0, i - 1)
        break
    # If there are no releases, insert section at the end.
    if insert_at_pos is None:
        insert_at_pos = len(changelog.sections)

    # Bump version.
    repo_versions = None
    if isinstance(version, BumpMode) or version is None:
        repo_versions = get_repo_versions(ctx.root, ctx)
        latest_version, _, _ = _find_latest_version(
            changelog, repo_versions, ctx.config
        )
        if latest_version is None:
            raise yuio.app.AppError("No previous release to bump")
        version = _bump_version(
            latest_version, version, alpha, beta, rc, found, ctx.config
        )
    else:
        if ctx.config.check_repo_tags:
            repo_versions = get_repo_versions(ctx.root, ctx)

    parsed_version = _parse_version(version, ctx.config)
    if parsed_version is None and ctx.config.version_format is not VersionFormat.NONE:
        ctx.issue(
            IssueCode.INVALID_VERSION,
            "New version `%s` doesn't follow %s specification",
            version,
            ctx.config.version_format.value,
            scope=IssueScope.EXTERNAL,
        )

    _check(changelog, ctx, repo_versions)

    if ctx.has_errors():
        ctx.report()
    if not ignore_errors:
        ctx.exit_if_has_errors()

    # Ensure we don't create a version that's already present.
    for _, section, _ in _find_sections(version, changelog, ctx.config):
        if section.map is not None:
            pos = f" on line `{section.map[0] + 1}`"
        else:
            pos = ""
        raise yuio.app.AppError(f"release {version} already exists{pos}")

    new_section = ReleaseSection(
        heading=None,
        subsections=found.subsections,
        version=version,
        parsed_version=parsed_version,
        canonized_version=_canonize_version(parsed_version, ctx.config) or version,
        release_date=datetime.date.today(),
        version_link=None,
        version_label=None,
        release_date_fmt=datetime.date.today().isoformat(),
        release_comment=None,
    )

    if edit and not dry_run:
        _edit_section(changelog, ctx, new_section)

    changelog.sections[insert_at_pos:insert_at_pos] = new_sections = [
        UnreleasedSection(
            heading=None, subsections=[], version_link=None, version_label=None
        ),
        new_section,
    ]

    # Generate headings and links manually.
    prev_tag = (
        f"{ctx.config.tag_prefix}{found_latest.version}"
        if found_latest is not None
        else None
    )
    for section in reversed(new_sections):
        if ctx.config.add_release_link:
            section.version_link, prev_tag = _make_link_for_section(
                section, prev_tag, repo_versions, ctx
            )
            section.version_label = _format_section_label(section, ctx)
            if section.version_label:
                changelog.parser_env["references"][section.version_label] = {
                    "title": "",
                    "href": section.version_link,
                    "map": None,
                }

        section.heading = _format_section_heading(section, ctx)

    result = _render(changelog, ctx)

    if not dry_run:
        yuio.io.success("Changelog successfully updated")
        if ctx.path is Input.STDIN:
            print(result, end="")
        else:
            ctx.path.write_text(result, encoding="utf-8")
    if commit or dry_run or diff:
        _print_diff(original, result, ctx.path)

    if commit:
        yuio.io.heading("Commit")
    if ctx.has_errors() and commit:
        yuio.io.warning("Not committing changes: errors detected")
        ctx.exit_if_has_errors()
    tag = f"{ctx.config.tag_prefix}{version}"
    if commit and not dry_run and ctx.path is not Input.STDIN:
        repo = yuio.git.Repo(ctx.root)
        repo.git("add", str(ctx.path))
        repo.print_status()
        ok = None
        while ok is None:
            yuio.io.info(
                "You can start a shell, take a look around, and make any changes before proceeding.\n"
                "Alternatively, you can cancel now and make a release commit later.\n"
            )
            ok = yuio.io.ask[bool | _t.Literal["shell"]](
                "Proceed with commit and tag?",
                default=False,
                default_non_interactive=True,
            )
            if ok == "shell":
                yuio.io.hr()
                yuio.io.success(
                    "Starting an interactive shell. "
                    "Press <c kbd>Ctrl+D</c> to exit and proceed."
                )
                yuio.io.shell(prompt_marker="(clk bump)")
                yuio.io.hr()
                ok = None

        message = f"Release {version}"
        if not ok:
            raise yuio.app.AppError(
                yuio.io.Md(
                    f"""
                    Commit canceled\\
                    Use this command to commit changes:

                    ```sh
                    git commit -m '{message}'
                    git tag {tag}
                    ```
                """
                )
            )
        repo.git("commit", "-m", message)
        repo.git("tag", tag)
        status = repo.status()
        yuio.io.success("Created commit `%s` and tag `%s`.", status.commit, tag)
        yuio.io.md(
            f"""
            Use `git commit --amend` and `git tag` to update commit contents or message:

            ```sh
            git commit --amend && git tag -f {tag}
            ```
            """,
        )
    if commit and dry_run:
        yuio.io.success("Dry-run: not committing and creating tag `%s`", tag)


bump.usage = """
%(prog)s <options> [--ignore-errors] [--dry-run] [--edit] <version>
%(prog)s <options> [--ignore-errors] [--dry-run] [--edit] [auto|major|minor|patch|post] [--alpha|--beta|--rc]
"""

bump.epilog = """
Examples
--------

Specifying version manually
~~~~~~~~~~~~~~~~~~~~~~~~~~~

You can always supply a specific version for a new release. As long as there are no
prior releases with the same version, this operation will succeed:

.. code-block:: sh

    clk bump 1.0.5

Automatic version detection
~~~~~~~~~~~~~~~~~~~~~~~~~~~

Changelog keeper can suggest next version based on changelog content
(the :cli:flag:`--dry-run` flag is handy here):

.. code-block:: sh

    clk bump auto

Bumping a version component
~~~~~~~~~~~~~~~~~~~~~~~~~~~

you can bump a version component by setting :cli:arg:`<version>` to ``major``, ``minor``,
``patch``, or ``post``:

.. code-block:: sh

    clk bump minor  # 1.0.0 -> 1.1.0

Pre-releases
~~~~~~~~~~~~

You can make a pre-release by adding :cli:flag:`--alpha`, :cli:flag:`--beta`,
or :cli:flag:`--rc`:

.. code-block:: sh

    clk bump major --beta  # 1.5.1 -> 2.0.0b0

If the last release is a pre-release itself, you can bump its pre-release version
component:

.. code-block:: sh

    clk bump --beta  # 1.0.0b0 -> 1.0.0b1
    clk bump --rc  # 1.0.0b0 -> 1.0.0rc0

"""


class FindMode(enum.Enum):
    LATEST = "latest"
    UNRELEASED = "unreleased"

    def __str__(self) -> str:
        return self.value


@main.subcommand
def find(
    #: Version that will be extracted from the changelog. Can start with
    #: :cli:field:`.tag_prefix`, in which case the prefix is stripped.
    version: (
        _t.Annotated[FindMode, yuio.parse.WithMeta(desc="query")] | str | yuio.git.Tag
    ),
    /,
    #: Produce result even if errors are detected.
    ignore_errors: bool = False,
    #: Print data in JSON format.
    format_json: bool = yuio.app.field(default=False, flags="--json"),
    #: Where to write results. Pass ``-`` to write to ``stdout``.
    output: Output | pathlib.Path = yuio.app.field(
        default=Output.STDOUT, flags=["-o", "--output"]
    ),
):
    """
    Find a changelog entry for a given release version.

    First form prints a changelog entry for the given release version.

    Second form prints contents of the unreleased section.

    Third form scans the changelog file and prints contents of the top-most
    non-``unreleased`` entry. This form is intended for situations when
    :cli:field:`.version_format` is :cli:field:`~VersionFormat.none`, i.e. there is
    no way to canonize version number.

    If errors detected in the changelog, the command fails unless
    :cli:flag:`--ignore-errors` was given.

    If changelog entry isn't found, the requested version is searched in the repo tags.
    If it's found in the repo, and it satisfies config options
    :cli:field:`.ignore_missing_releases_before`
    or :cli:field:`.ignore_missing_releases_regexp`, an empty result is printed.

    """

    ctx = _make_context()

    if isinstance(version, str):
        version = version.removeprefix("refs/tags/").removeprefix(ctx.config.tag_prefix)

    repo_versions = None
    if ctx.config.check_repo_tags:
        repo_versions = get_repo_versions(ctx.root, ctx)

    changelog = _parse(ctx)

    _check(changelog, ctx, repo_versions)

    if ctx.has_errors():
        ctx.report()
    if not ignore_errors:
        ctx.exit_if_has_errors()

    found = None
    found_latest_in_changelog = None
    for _, section, is_latest in _find_sections(version, changelog, ctx.config):
        if found is None:
            found = section
            found_latest_in_changelog = is_latest
        else:
            _merge_sections(found, section)

    if found is None and isinstance(version, str):
        parsed_version = _parse_version(version, ctx.config)
        canonized_version = _canonize_version(parsed_version, ctx.config) or version

        lower_bound = ctx.config.parsed_ignore_missing_releases_before
        regex_bound = ctx.config.ignore_missing_releases_regexp

        if (repo_versions is None or canonized_version in repo_versions) and (
            (lower_bound and parsed_version and parsed_version < lower_bound)
            or (regex_bound is not None and re.search(regex_bound, version))
        ):
            found = ReleaseSection(
                heading=None,
                subsections=[],
                version=version,
                parsed_version=parsed_version,
                canonized_version=canonized_version,
                version_link=None,
                version_label=None,
                release_date=None,
                release_date_fmt=None,
                release_comment=None,
            )

    if found is not None:
        if output is Output.STDOUT:
            ch = yuio.io.MessageChannel(to_stdout=True)
        else:
            output_stream = open(output, "w", encoding="utf-8")  # noqa: SIM115
            ch = yuio.io.MessageChannel(
                term=yuio.term.get_term_from_stream(output_stream)
            )

        tokens = found.to_tokens(include_heading=False)
        if format_json:
            found_version = None
            found_canonized_version = None
            found_latest = None
            tag = None
            is_pre_release = None
            is_post_release = None
            if release := found.as_release():
                found_version = release.version
                found_canonized_version = release.canonized_version
                if repo_versions and (
                    repo_version := repo_versions.get(release.canonized_version)
                ):
                    tag = f"{ctx.config.tag_prefix}{repo_version.version}"
                is_pre_release = _is_pre_release(release.parsed_version)
                is_post_release = _is_post_release(release.parsed_version)
                try:
                    _, _, latest_version_canonized = _find_latest_version(
                        changelog, repo_versions, ctx.config
                    )
                except:
                    pass
                else:
                    found_latest = release.canonized_version == latest_version_canonized
            data = {
                "version": found_version,
                "canonizedVersion": found_canonized_version,
                "tag": tag,
                "text": _render(changelog, ctx, tokens, disable_wrapping=True),
                "isLatestInChangelog": found_latest_in_changelog,
                "isLatestInSemanticOrder": found_latest,
                "isLatest": (
                    found_latest
                    if found_latest is not None
                    else found_latest_in_changelog
                ),
                "isPreRelease": is_pre_release,
                "isPostRelease": is_post_release,
                "isUnreleased": found.is_unreleased(),
            }
            ch.hl(json.dumps(data, indent="  ") + "\n", syntax="json")
        else:
            ch.raw(_render(changelog, ctx, tokens, disable_wrapping=True))
    else:
        raise yuio.app.AppError("Can't find changelog entry for version `%s`", version)


find.usage = """
%(prog)s <options> [--ignore-errors] [--json] <version>
%(prog)s <options> [--ignore-errors] [--json] unreleased
%(prog)s <options> [--ignore-errors] [--json] latest
"""


find.epilog = """
Examples
--------

Find an exact version:

.. code-block:: sh

    clk find v1.0.0-rc2

Find the first version (barring the ``unreleased`` section)
that appears in the changelog:

.. code-block:: sh

    clk find latest

Find the ``unreleased`` section:

.. code-block:: sh

    clk find unreleased


Json output
-----------

If :cli:flag:`--json` flag is given, a JSON object is printed to stdout. It will contain
the following fields:

``version``
    Version string, as appears in the changelog.

    Can be ``null`` if unreleased section is requested.

``canonizedVersion``
    Version string, canonized according to the used :cli:field:`.version_format`.

    If :cli:field:`.version_format` is ``null`` or canonization fails, this will contain
    string from ``version``.

``tag``
    Tag that corresponds to this version.

    Can be ``null`` if requested release not found, if unreleased section is requested,
    if :cli:field:`.check_repo_tags` is ``false``, if version canonization fails, or if tag is not
    found for this release.

``text``
    Text extracted from the changelog entry.

    Can be empty if requested release not found.

``isLatestInChangelog``
    ``true`` if found version appears first in the changelog file.

    Can be ``null`` if requested release not found, or if unreleased section
    is requested.

``isLatestInSemanticOrder``
    ``true`` if this is the latest known release.

    Release versions are compared with respect to the selected :cli:field:`.version_format`.
    If :cli:field:`.check_repo_tags` is ``true``, this flag also checks all tags found
    in the repository.

    Can be ``null`` if requested release not found, or if unreleased section
    is requested.

``isLatest``
    Equals to ``isLatestInSemanticOrder`` if it's not ``null``,
    otherwise equals to ``isLatestInChangelog``.

``isPreRelease``
    ``true`` if release version contains a pre-release component,
    like ``beta`` or ``rc``.

    Can be ``null`` if requested release not found, if unreleased section
    is requested, or if version canonization fails.

``isPostRelease``
    ``true`` if release version contains a post-release component.

    Can be ``null`` if requested release not found, if unreleased section
    is requested, or if version canonization fails.

``isUnreleased``
    ``true`` if unreleased section is requested.

"""


@main.subcommand
def check_tag(
    #: Full name of the tag to check.
    tag: yuio.git.Tag | str,
    /,
):
    """
    Check if a git tag conforms to the versioning specification.

    This command is handy to use in release CI or in a pre-push git hook to verify
    that all tags conform to the selected versioning specification.

    """

    error = False
    if not tag.startswith(_config.tag_prefix):
        yuio.io.error("Tag `%s` should start with `%r`", tag, _config.tag_prefix)
        error = True

    tag_without_prefix = tag.removeprefix(_config.tag_prefix)
    parsed = _parse_version(tag_without_prefix, _config)
    if not parsed:
        error = True

        if _config.version_format is VersionFormat.SEMVER_STRICT:
            config_non_strict = Config(_config, version_format=VersionFormat.SEMVER)  # type: ignore
            parsed_non_strict = _parse_version(tag_without_prefix, config_non_strict)
        elif _config.version_format in [
            VersionFormat.PYTHON_STRICT,
            VersionFormat.PYTHON_SEMVER,
        ]:
            config_non_strict = Config(_config, version_format=VersionFormat.PYTHON)  # type: ignore
            parsed_non_strict = _parse_version(tag_without_prefix, config_non_strict)
        else:
            parsed_non_strict = None

        canonized = _canonize_version(parsed_non_strict, _config)
        if canonized and canonized != tag_without_prefix:
            yuio.io.error(
                "Tag `%s` does not follow %s specification, should be `%s%s`",
                tag,
                _config.version_format.value,
                _config.tag_prefix,
                canonized,
            )
        else:
            yuio.io.error(
                "Tag `%s` does not follow %s specification",
                tag,
                _config.version_format.value,
            )

    if error:
        raise yuio.app.AppError("Tag verification failed")
    else:
        yuio.io.success("Tag `%s` is valid", tag)


@main.subcommand(help=yuio.DISABLED)
def pre_commit_check(
    changed_files: set[pathlib.Path],
    /,
    diff: bool = False,
):
    """
    Same as :cli:cmd:`clk fix`, but expects list of changed files as command line
    arguments, and doesn't perform fix if changelog is not updated.

    """

    file = _locate_changelog()

    if file not in changed_files and (
        _global_config.config_path is None
        or _global_config.config_path not in changed_files
    ):
        return

    if yuio.term.detect_ci():
        check.wrapped()
    else:
        fix.wrapped(diff=diff)


@main.subcommand(help=yuio.DISABLED)
def pre_commit_check_tag():
    """
    Same as :cli:cmd:`clk check-tag`, but tag is supplied via
    :envvar:`PRE_COMMIT_REMOTE_BRANCH` env var.

    """

    remote_branch = os.environ.get("PRE_COMMIT_REMOTE_BRANCH")
    if not remote_branch:
        return
    prefix = f"refs/tags/{_config.tag_prefix}"
    if not remote_branch.startswith(prefix):
        return
    check_tag.wrapped(remote_branch.removeprefix("refs/tags/"))


def _load_config(global_config: GlobalConfig) -> tuple[Config, GlobalConfig]:
    config = global_config.cfg
    if global_config.config_path is None:
        if global_config.file is Input.STDIN:
            root = pathlib.Path.cwd()
        else:
            root = global_config.file or pathlib.Path.cwd()
        while root:
            changelog_toml = root.joinpath(".cl-keeper.toml")
            changelog_toml_exists = changelog_toml.exists()
            changelog_yaml = root.joinpath(".cl-keeper.yaml")
            changelog_yaml_exists = changelog_yaml.exists()
            changelog_yml = root.joinpath(".cl-keeper.yml")
            changelog_yml_exists = changelog_yml.exists()
            pyproject_toml = root.joinpath("pyproject.toml")
            pyproject_toml_exists = pyproject_toml.exists()

            if changelog_toml_exists + changelog_yaml_exists + changelog_yml_exists > 1:
                found = ", ".join(
                    f"<c path>{file}</c>"
                    for file, exists in [
                        (changelog_toml, changelog_toml_exists),
                        (changelog_yaml, changelog_yaml_exists),
                        (changelog_yml, changelog_yml_exists),
                    ]
                    if exists
                )
                raise yuio.app.AppError(f"Found multiple config files: {found}")
            if changelog_toml_exists:
                global_config.config_path = changelog_toml
                break
            elif changelog_yaml_exists:
                global_config.config_path = changelog_yaml
                break
            elif changelog_yml_exists:
                global_config.config_path = changelog_yml
                break
            elif pyproject_toml_exists:
                global_config.config_path = pyproject_toml
                break
            next_root = root.parent
            if next_root == root:
                break
            root = next_root
        if global_config.config_path is None:
            logger.info("using default config")
            return config.process_config(), global_config
        else:
            logger.debug("found config %s", global_config.config_path)
    else:
        logger.debug("loading config %s", global_config.config_path)

    if not global_config.config_path.exists():
        raise yuio.app.AppError(
            "Config file <c path>%s</c> doesn't exist", global_config.config_path
        )
    if not global_config.config_path.is_file():
        raise yuio.app.AppError(
            "Config path <c path>%s</c> is not a file", global_config.config_path
        )
    if global_config.config_path.name == "pyproject.toml":
        config.update(PYTHON_PRESET)
        try:
            data = tomllib.loads(global_config.config_path.read_text(encoding="utf-8"))
        except tomllib.TOMLDecodeError as e:
            yuio.io.warning(
                "Failed to parse config file <c path>%s</c>: %s",
                global_config.config_path,
                e,
            )
            return config.process_config(), global_config
        try:
            data = data["tool"]["cl_keeper"]
        except KeyError:
            logger.debug(
                "%s doesn't have section tool.cl_keeper",
                global_config.config_path,
            )
            logger.info("using config from %s", global_config.config_path)
            return config.process_config(), global_config

        config.update(
            Config.load_from_parsed_file(data, path=global_config.config_path)
        )
    elif global_config.config_path.suffix == ".toml":
        config.update(Config.load_from_toml_file(global_config.config_path))
    elif global_config.config_path.suffix in [".yaml", ".yml"]:
        config.update(Config.load_from_yaml_file(global_config.config_path))
    else:
        raise yuio.app.AppError(
            "Unknown config format `%s`: <c path>%s</c>",
            global_config.config_path.suffix,
            global_config.config_path,
        )

    logger.info("using config from %s", global_config.config_path)
    return config.process_config(), global_config


def _locate_changelog() -> pathlib.Path | Input:
    if _global_config.file is Input.STDIN:
        return _global_config.file
    if (file := _global_config.file) is None:
        if _global_config.config_path is None:
            file = _config.file
        else:
            file = _global_config.config_path.parent / _config.file
    file = file.expanduser().resolve()
    if not file.exists():
        raise yuio.app.AppError("File `%s` doesn't exist", file)
    if not file.is_file():
        raise yuio.app.AppError("Path `%s` is not a file", file)
    return file


def _make_context() -> Context:
    changelog_location = _locate_changelog()
    if changelog_location is Input.STDIN:
        src = sys.stdin.read()
        if _global_config.config_path is not None:
            root = _global_config.config_path.parent
        else:
            root = pathlib.Path.cwd()
    else:
        src = changelog_location.read_text(encoding="utf-8")
        root = changelog_location.parent
    return Context(
        changelog_location,
        root,
        src,
        _config,
        _global_config.strict,
        _make_link_templates(root, _config),
        _global_config.machine_readable_diagnostics,
    )


def _make_link_templates(repo_root: pathlib.Path, config: Config) -> LinkTemplates:
    if not config.add_release_link:
        return LinkTemplates("", "", "", {})

    link_templates = LinkTemplates(
        config.release_link_template or "",
        config.release_link_template_last or "",
        config.release_link_template_first or "",
        config.release_link_template_vars or {},
    )

    if link_templates.has_unresolved_links() and config.release_link_preset:
        link_templates.update(config.release_link_preset.get_links())

    if link_templates.has_unresolved_links():
        logger.debug("link templates are not set, trying to detect repo url from git")
        origin = _detect_origin(repo_root)
        if origin:
            link_templates.update(origin)
        else:
            raise yuio.app.AppError(
                "Can't detect url to use in link templates. Please, set "
                "`release_link_preset` or `release_link_template`, "
                "`release_link_template_last`, `release_link_template_first` "
                "in config. Alternatively, set `add_release_link` to `false`"
            )

    logger.debug("link_templates = %#+r", link_templates)

    _check_link_templates(link_templates)

    return link_templates


def _check_link_templates(link_templates: LinkTemplates):
    class _UnknownKeyDetector:
        def __init__(self, vars: dict[str, str]) -> None:
            self._vars = vars
            self.unknown_keys: set[str] = set()

        def __getitem__(self, key: str) -> str:
            if key not in ["tag", "prev_tag"] and key not in self._vars:
                self.unknown_keys.add(key)
            return ""

    mapping = _UnknownKeyDetector(link_templates.vars)
    link_templates.template.format_map(mapping)
    link_templates.template_first.format_map(mapping)
    link_templates.template_last.format_map(mapping)
    if mapping.unknown_keys:
        yuio.io.error(
            "Some variables used in link templates are missing "
            "from `release_link_template_vars`: `%s`. Please, add them to config",
            ", ".join(sorted(mapping.unknown_keys)),
        )
        raise yuio.app.AppError("Configuration is incorrect")


def _find_sections(version: FindMode | str, changelog: Changelog, config: Config):
    canonized_version = None
    if isinstance(version, str):
        canonized_version = (
            _canonize_version(_parse_version(version, config), config) or version
        )
    logger.debug(
        "searching for release %r, canonized_version=%r", version, canonized_version
    )
    is_latest = True
    for i, section in enumerate(changelog.sections):
        if section.is_unreleased():
            logger.debug("checking unreleased section")
            if version is FindMode.UNRELEASED:
                yield i, section, None
        elif release := section.as_release():
            logger.debug("checking release %r", release.version)
            if version is FindMode.LATEST or (
                release.canonized_version == canonized_version
            ):
                yield i, section, is_latest
                if version is FindMode.LATEST:
                    version = release.version
                    canonized_version = release.canonized_version
            is_latest = False


def _edit_section(changelog: Changelog, ctx: Context, section: Section):
    logger.debug("external edit for %s", section.what())
    to_edit = (
        "<!--\n"
        f" Edit changelog for {section.what()}.\n"
        " An empty changelog aborts the command.\n"
        "-->\n\n"
    )
    to_edit += _render(changelog, ctx, section.to_tokens(include_heading=False))
    to_edit = yuio.io.edit(to_edit, comment_marker=None, file_ext=".md")
    to_edit = _COMMENT_RE.sub("", to_edit).strip()
    if not to_edit:
        raise yuio.app.AppError("Got an empty changelog, command aborted")
    else:
        content = changelog.parser.parse(to_edit, changelog.parser_env)
        root = SyntaxTreeNode(content)
        subsections: list[SubSection] = []
        for subheading, content in _split_into_sections(root.children, 3):
            subsection = SubSection(heading=subheading, content=content)
            _detect_subsection_metadata(subsection, ctx)
            subsections.append(subsection)
        section.subsections = subsections
        logger.debug("external edit successful")


def _find_latest_version(
    changelog: Changelog, repo_versions: dict[str, RepoVersion] | None, config: Config
) -> tuple[Version | None, str | None, str | None]:
    logger.debug("trying to determine the latest release")
    max_version = None
    max_version_str = None
    max_version_str_canonized = None
    for section in changelog.sections:
        release = section.as_release()
        if not release:
            continue
        if release.parsed_version is None:
            _report_latest_version_fail("release", release.version, release.map, config)
        logger.debug("checking release version %r", release.canonized_version)
        if max_version is None or release.parsed_version > max_version:
            max_version = release.parsed_version
            max_version_str = release.version
            max_version_str_canonized = release.canonized_version
    if repo_versions is not None:
        for data in repo_versions.values():
            if data.parsed_version is None:
                _report_latest_version_fail("release", data.version, None, config)
            logger.debug("checking tag %r", data.canonized_version)
            if max_version is None or data.parsed_version > max_version:
                max_version = data.parsed_version
                max_version_str = data.version
                max_version_str_canonized = data.canonized_version
    logger.debug("latest release is %r", max_version_str)
    return max_version, max_version_str, max_version_str_canonized


def _report_latest_version_fail(
    what: str, version: str | None, map: tuple[int, int] | None, config: Config
) -> _t.Never:
    if config.version_format is VersionFormat.NONE:
        reason = "`version_format` is set to `none`"
        args = ()
    else:
        reason = f"{what} `%s` "
        if map:
            reason += f"on line `{map[0] + 1}` "
        reason += "does not follow %s specification"
        args = (version, config.version_format.value)
    raise yuio.app.AppError(
        "Can't determine the latest version because " + reason, *args
    )


_PY_TO_SEMVER_PRE_RELEASE = {
    "a": "alpha",
    "alpha": "alpha",
    "b": "beta",
    "beta": "beta",
    "rc": "rc",
}


def _bump_version(
    version: Version,
    mode: BumpMode | None,
    alpha: bool,
    beta: bool,
    rc: bool,
    unreleased: Section,
    config: Config,
) -> str:
    if isinstance(version, packaging.version.Version):
        epoch = version.epoch
        major, minor, patch = tuple(
            version.release[i] if len(version.release) >= i else 0 for i in range(3)
        )
        pre = version.pre
        post = version.post
    elif isinstance(version, semver.Version):
        if mode is BumpMode.POST:
            raise yuio.app.AppError(
                "Semver schema doesn't support creating post releases",
            )
        epoch = 0
        major, minor, patch = version.major, version.minor, version.patch
        pre = _split_semver_prerelease(version.prerelease)
        post = None
    else:
        assert False
    if mode is BumpMode.AUTO:
        mode = _suggest_bump(unreleased, config)
    match mode:
        case BumpMode.MAJOR:
            major, minor, patch, pre, post = major + 1, 0, 0, None, None
        case BumpMode.MINOR:
            major, minor, patch, pre, post = major, minor + 1, 0, None, None
        case BumpMode.PATCH:
            major, minor, patch, pre, post = major, minor, patch + 1, None, None
        case BumpMode.POST:
            if pre is not None:
                raise yuio.app.AppError(
                    "Creating a post-release for a pre-release is probably "
                    "a mistake. Please, specify version manually if you're sure "
                    "you want to do this",
                    version,
                )
            if post is None:
                post = 0
            else:
                post += 1
        case BumpMode.AUTO:
            assert False
        case None:
            assert alpha or beta or rc
            if not pre:
                raise yuio.app.AppError(
                    "Can't create a pre-release without bumping "
                    "a primary version component: latest release `%s` "
                    "is not a pre-release",
                    version,
                )

    if (alpha or beta or rc) and pre and pre[0] not in _PY_TO_SEMVER_PRE_RELEASE:
        raise yuio.app.AppError(
            "Can't create a pre-release: latest release `%s` has "
            "unknown label `%r`. Please, specify version manually",
            version,
            pre[0],
        )
    elif alpha:
        if pre and pre[0] in ("a", "alpha"):
            pre = ("a", pre[1] + 1)
        elif pre:
            if pre[0] == "rc":
                what = "a release candidate"
            else:
                what = "a beta pre-release"
            raise yuio.app.AppError("Can't create an alpha pre-release after %s", what)
        else:
            pre = ("a", 0)
    elif beta:
        if pre and pre[0] in ("b", "beta"):
            pre = ("b", pre[1] + 1)
        elif pre and pre[0] == "rc":
            raise yuio.app.AppError(
                "Can't create a beta pre-release after a release candidate"
            )
        else:
            pre = ("b", 0)
    elif rc:
        if pre and pre[0] == "rc":
            pre = ("rc", pre[1] + 1)
        else:
            pre = ("rc", 0)

    if isinstance(version, packaging.version.Version):
        if epoch > 0:
            prefix = f"{epoch}!"
        else:
            prefix = ""
        suffix = ""
        if pre:
            suffix += "".join(map(str, pre))
        if post is not None:
            suffix += f".post{post}"
        bumped = packaging.version.Version(f"{prefix}{major}.{minor}.{patch}{suffix}")
    else:
        suffix = ""
        if pre:
            pre_str = f"{_PY_TO_SEMVER_PRE_RELEASE[pre[0]]}{pre[1]}"
        else:
            pre_str = None
        bumped = semver.Version(major, minor, patch, pre_str)

    logger.debug(
        "performed version bump: mode=%r, alpha=%r, beta=%r, rc=%r, result=%r",
        mode,
        alpha,
        beta,
        rc,
        bumped,
    )

    canonical = _canonize_version(_t.cast(Version, bumped), config)
    assert canonical
    return canonical


_SEMVER_PRERELEASE_RE = re.compile(r"^([a-zA-Z0-9.-]*?)([0-9]*)$")


def _split_semver_prerelease(pre: str | None) -> tuple[str, int] | None:
    if pre is None:
        return None
    if match := _SEMVER_PRERELEASE_RE.match(pre):
        return match.group(1).rstrip(".-"), int(match.group(2))
    else:
        return pre, 0


def _suggest_bump(unreleased: Section, config: Config) -> BumpMode:
    logger.debug("checking change categories to determine appropriate version bump")

    has_major = has_minor = has_patch = False

    def check_category(category: str | None):
        nonlocal has_major, has_minor, has_patch
        if not category:
            logger.debug("-> unknown category")
            return
        if category in config.bump_major_categories:
            logger.debug("-> this warrants a major bump")
            has_major = True
        elif category in config.bump_minor_categories:
            logger.debug("-> this warrants a minor bump")
            has_minor = True
        elif category in config.bump_patch_categories:
            logger.debug("-> this warrants a patch bump")
            has_patch = True
        else:
            logger.debug("-> unknown category")

    for subsection in unreleased.subsections:
        logger.debug("inspecting items in %s", subsection.what())
        for node in subsection.content:
            if not node.meta.get("cl_is_changelist", False):
                continue
            logger.debug("found a change list %s", node)
            for child in node.children:
                category = child.meta.get("cl_category")
                logger.debug("inspecting change list item with category %s", category)
                check_category(category)

        if subsection.category_kind != SubSectionCategoryKind.KNOWN:
            logger.debug(
                "skipping title for %s: category %r is unknown",
                subsection.what(),
                subsection.category,
            )
            continue

        logger.debug("inspecting title for %s", subsection.what())
        check_category(subsection.category)
    if has_major:
        return BumpMode.MAJOR
    elif has_minor:
        return BumpMode.MINOR
    elif has_patch:
        return BumpMode.PATCH
    else:
        raise yuio.app.AppError(
            "Can't determine which version component to bump: changelog for unreleased "
            "version doesn't have change categories from `bump_major_categories`, "
            "`bump_minor_categories`, or `bump_patch_categories`"
        )


def _is_pre_release(version: Version | None) -> bool | None:
    if version is None:
        return None
    elif isinstance(version, packaging.version.Version):
        return version.is_prerelease
    elif isinstance(version, semver.Version):
        return version.prerelease is not None
    else:
        assert False


def _is_post_release(version: Version | None) -> bool | None:
    if version is None:
        return None
    elif isinstance(version, packaging.version.Version):
        return version.is_postrelease
    elif isinstance(version, semver.Version):
        return False
    else:
        assert False


class Theme(yuio.theme.DefaultTheme):
    colors = {
        "msg/text:report_error": "red",
        "msg/text:report_warning": "yellow",
        "msg/text:report_weak_warning": "cyan",
        "msg/text:report_info": "cyan",
    }


main.theme = Theme
