from __future__ import annotations

import argparse
import logging
import sys
from copy import deepcopy
from functools import partial
from pathlib import Path
from types import TracebackType
from typing import TYPE_CHECKING, cast

import argcomplete
from decli import cli

from commitizen import commands, config, out, version_schemes
from commitizen.defaults import DEFAULT_SETTINGS
from commitizen.exceptions import (
    CommitizenException,
    ExitCode,
    ExpectedExit,
    InvalidCommandArgumentError,
    NoCommandFoundError,
)
from commitizen.version_increment import VersionIncrement

logger = logging.getLogger(__name__)


class ParseKwargs(argparse.Action):
    """
    Parse arguments in the for `key=value`.

    Quoted strings are automatically unquoted.
    Can be submitted multiple times:

    ex:
        -k key=value -k double-quotes="value" -k single-quotes='value'

        will result in

        namespace["opt"] == {
            "key": "value",
            "double-quotes": "value",
            "single-quotes": "value",
        }
    """

    def __call__(
        self,
        parser: argparse.ArgumentParser,
        namespace: argparse.Namespace,
        values: object,
        option_string: str | None = None,
    ) -> None:
        if not isinstance(values, str):
            return

        key, sep, value = values.partition("=")
        if not key or not sep:
            raise InvalidCommandArgumentError(
                f"Option {option_string} expect a key=value format"
            )
        kwargs = getattr(namespace, self.dest, None) or {}
        kwargs[key] = value.strip("'\"")
        setattr(namespace, self.dest, kwargs)


tpl_arguments = (
    {
        "name": ["--template", "-t"],
        "help": (
            "Changelog template file name (relative to the current working directory)."
        ),
    },
    {
        "name": ["--extra", "-e"],
        "action": ParseKwargs,
        "dest": "extras",
        "metavar": "EXTRA",
        "help": "Changelog extra variables (in the form 'key=value').",
    },
)

data = {
    "prog": "cz",
    "description": (
        "Commitizen is a powerful release management tool that helps teams maintain consistent and meaningful commit messages while automating version management.\n"
        "For more information, please visit https://commitizen-tools.github.io/commitizen"
    ),
    "formatter_class": argparse.RawDescriptionHelpFormatter,
    "arguments": [
        {
            "name": "--config",
            "help": "The path to the configuration file.",
        },
        {"name": "--debug", "action": "store_true", "help": "Use debug mode."},
        {
            "name": ["-n", "--name"],
            "help": "Use the given commitizen (default: cz_conventional_commits).",
        },
        {
            "name": ["-nr", "--no-raise"],
            "type": str,
            "required": False,
            "help": "Comma-separated error codes that won't raise error, e.g., cz -nr 1,2,3 bump. See codes at https://commitizen-tools.github.io/commitizen/exit_codes/",
        },
    ],
    "subcommands": {
        "title": "commands",
        "required": True,
        "commands": [
            {
                "name": ["init"],
                "description": "Initialize commitizen configuration",
                "help": "Initialize commitizen configuration.",
                "func": commands.Init,
            },
            {
                "name": ["commit", "c"],
                "description": "Create new commit",
                "help": "Create new commit.",
                "func": commands.Commit,
                "arguments": [
                    {
                        "name": ["--retry"],
                        "action": "store_true",
                        "help": "Retry the last commit.",
                    },
                    {
                        "name": ["--no-retry"],
                        "action": "store_true",
                        "default": False,
                        "help": "Skip retry if --retry or `retry_after_failure` is set to true.",
                    },
                    {
                        "name": "--dry-run",
                        "action": "store_true",
                        "help": "Perform a dry run, without committing or modifying files.",
                    },
                    {
                        "name": "--write-message-to-file",
                        "type": Path,
                        "metavar": "FILE_PATH",
                        "help": "Write message to FILE_PATH before committing (can be used with --dry-run).",
                    },
                    {
                        "name": ["-s", "--signoff"],
                        "action": "store_true",
                        "help": "Deprecated, use `cz commit -- -s` instead.",
                    },
                    {
                        "name": ["-a", "--all"],
                        "action": "store_true",
                        # The help text aligns with the description of git commit --all
                        "help": "Automatically stage files that have been modified and deleted, but new files you have not told Git about are not affected.",
                    },
                    {
                        "name": ["-e", "--edit"],
                        "action": "store_true",
                        "default": False,
                        "help": "Edit the commit message before committing.",
                    },
                    {
                        "name": ["-l", "--message-length-limit"],
                        "type": int,
                        "help": "Set the length limit of the commit message; 0 for no limit.",
                    },
                    {
                        "name": ["--"],
                        "action": "store_true",
                        "dest": "double_dash",
                        "help": "Positional arguments separator (recommended).",
                    },
                ],
            },
            {
                "name": "ls",
                "description": "Show available Commitizens",
                "help": "Show available Commitizens.",
                "func": commands.ListCz,
            },
            {
                "name": "example",
                "description": "Show commit example",
                "help": "Show commit example.",
                "func": commands.Example,
            },
            {
                "name": "info",
                "description": "Show information about the cz",
                "help": "Show information about the cz.",
                "func": commands.Info,
            },
            {
                "name": "schema",
                "description": "Show commit schema",
                "help": "Show commit schema.",
                "func": commands.Schema,
            },
            {
                "name": "bump",
                "description": "Bump semantic version based on the git log",
                "help": "Bump semantic version based on the git log.",
                "func": commands.Bump,
                "arguments": [
                    {
                        "name": "--dry-run",
                        "action": "store_true",
                        "help": "Perform a dry run, without committing or modifying files.",
                    },
                    {
                        "name": "--files-only",  # TODO: rename to --version-files-only
                        "action": "store_true",
                        "help": "Bump version in the `version_files` specified in the configuration file only(deprecated; use --version-files-only instead).",
                    },
                    {
                        "name": "--version-files-only",
                        "action": "store_true",
                        "help": "Bump version in the files from the config",
                    },
                    {
                        "name": "--local-version",
                        "action": "store_true",
                        "help": "Bump version only the local version portion (ignoring the public version).",
                    },
                    {
                        "name": ["--changelog", "-ch"],
                        "action": "store_true",
                        "default": False,
                        "help": "Generate the changelog for the latest version.",
                    },
                    {
                        "name": ["--no-verify"],
                        "action": "store_true",
                        "default": False,
                        # The help text aligns with the description of git commit --no-verify
                        "help": "Bypass the pre-commit and commit-msg hooks.",
                    },
                    {
                        "name": "--yes",
                        "action": "store_true",
                        "help": "Accept automatically answered questions.",
                    },
                    {
                        "name": "--tag-format",
                        "help": (
                            "The format used to tag the commit and read it. "
                            "Use it in existing projects, and wrap around simple quotes."
                        ),
                    },
                    {
                        "name": "--bump-message",
                        "help": (
                            "Template used to create the release commit, useful when working with CI."
                        ),
                    },
                    {
                        "name": ["--prerelease", "-pr"],
                        "help": "Type of prerelease.",
                        "choices": ["alpha", "beta", "rc"],
                    },
                    {
                        "name": ["--devrelease", "-d"],
                        "help": "Specify non-negative integer for dev release.",
                        "type": int,
                    },
                    {
                        "name": ["--increment"],
                        "help": "Specify the desired increment.",
                        "choices": ["MAJOR", "MINOR", "PATCH"],
                        "type": str.upper,
                    },
                    {
                        "name": ["--increment-mode"],
                        "choices": ["linear", "exact"],
                        "default": "linear",
                        "help": (
                            "Set the method by which the new version is chosen. "
                            "'linear' (default) resolves the next version based on typical linear version progression, "
                            "where bumping of a pre-release with lower precedence than the current pre-release "
                            "phase maintains the current phase of higher precedence. "
                            "'exact' applies the changes that have been specified (or determined from the commit log) "
                            "without interpretation, ensuring the increment and pre-release are always honored."
                        ),
                    },
                    {
                        "name": ["--check-consistency", "-cc"],
                        "help": (
                            "Check consistency among versions defined in Commitizen configuration file and `version_files`."
                        ),
                        "action": "store_true",
                    },
                    {
                        "name": ["--annotated-tag", "-at"],
                        "help": "Create annotated tag instead of lightweight one.",
                        "action": "store_true",
                    },
                    {
                        "name": ["--annotated-tag-message", "-atm"],
                        "help": "Create annotated tag message.",
                        "type": str,
                    },
                    {
                        "name": ["--gpg-sign", "-s"],
                        "help": "Sign tag instead of lightweight one.",
                        "default": False,
                        "action": "store_true",
                    },
                    {
                        "name": ["--changelog-to-stdout"],
                        "action": "store_true",
                        "default": False,
                        "help": "Output changelog to stdout.",
                    },
                    {
                        "name": ["--git-output-to-stderr"],
                        "action": "store_true",
                        "default": False,
                        "help": "Redirect git output to stderr.",
                    },
                    {
                        "name": ["--retry"],
                        "action": "store_true",
                        "default": False,
                        "help": "Retry commit if it fails for the first time.",
                    },
                    {
                        "name": ["--major-version-zero"],
                        "action": "store_true",
                        "default": None,
                        "help": "Keep major version at zero, even for breaking changes.",
                    },
                    *deepcopy(tpl_arguments),
                    {
                        "name": "--file-name",
                        "help": "File name of changelog (default: 'CHANGELOG.md').",
                    },
                    {
                        "name": ["--prerelease-offset"],
                        "type": int,
                        "default": None,
                        "help": "Start pre-releases with this offset.",
                    },
                    {
                        "name": ["--version-scheme"],
                        "help": "Choose version scheme.",
                        "default": None,
                        "choices": version_schemes.KNOWN_SCHEMES,
                    },
                    {
                        "name": ["--version-type"],
                        "help": "Deprecated, use `--version-scheme` instead.",
                        "default": None,
                        "choices": version_schemes.KNOWN_SCHEMES,
                    },
                    {
                        "name": "manual_version",
                        "type": str,
                        "nargs": "?",
                        "help": "Bump to the given version (e.g., 1.5.3).",
                        "metavar": "MANUAL_VERSION",
                    },
                    {
                        "name": ["--build-metadata"],
                        "help": "Add additional build-metadata to the version-number.",
                        "default": None,
                    },
                    {
                        "name": ["--get-next"],
                        "action": "store_true",
                        "help": "Determine the next version and write to stdout.",
                        "default": False,
                    },
                    {
                        "name": ["--allow-no-commit"],
                        "default": False,
                        "help": "Bump version without eligible commits.",
                        "action": "store_true",
                    },
                ],
            },
            {
                "name": ["changelog", "ch"],
                "description": (
                    "Generate changelog (note that it will overwrite existing files)"
                ),
                "help": (
                    "Generate changelog (note that it will overwrite existing files)."
                ),
                "func": commands.Changelog,
                "arguments": [
                    {
                        "name": "--dry-run",
                        "action": "store_true",
                        "default": False,
                        "help": "Show changelog to stdout.",
                    },
                    {
                        "name": "--file-name",
                        "help": "File name of changelog (default: 'CHANGELOG.md').",
                    },
                    {
                        "name": "--unreleased-version",
                        "help": (
                            "Set the value for the new version (use the tag value), "
                            "instead of using unreleased versions."
                        ),
                    },
                    {
                        "name": "--incremental",
                        "action": "store_true",
                        "default": False,
                        "help": (
                            "Generate changelog from the last created version, "
                            "useful if the changelog has been manually modified."
                        ),
                    },
                    {
                        "name": "rev_range",
                        "type": str,
                        "nargs": "?",
                        "help": "Generate changelog for the given version (e.g., 1.5.3) or version range (e.g., 1.5.3..1.7.9).",
                    },
                    {
                        "name": "--start-rev",
                        "default": None,
                        "help": (
                            "Start rev of the changelog. "
                            "If not set, it will generate changelog from the beginning."
                        ),
                    },
                    {
                        "name": "--merge-prerelease",
                        "action": "store_true",
                        "default": False,
                        "help": (
                            "Collect all changes from prereleases into the next non-prerelease. "
                            "If not set, it will include prereleases in the changelog."
                        ),
                    },
                    {
                        "name": ["--version-scheme"],
                        "help": "Choose version scheme.",
                        "default": None,
                        "choices": version_schemes.KNOWN_SCHEMES,
                    },
                    {
                        "name": "--export-template",
                        "default": None,
                        "help": "Export the changelog template into this file instead of rendering it.",
                    },
                    *deepcopy(tpl_arguments),
                    {
                        "name": "--tag-format",
                        "help": "The format of the tag, wrap around simple quotes.",
                    },
                ],
            },
            {
                "name": ["check"],
                "description": "Validate that a commit message matches the commitizen schema",
                "help": "Validate that a commit message matches the commitizen schema.",
                "func": commands.Check,
                "arguments": [
                    {
                        "name": "--commit-msg-file",
                        "help": (
                            "Ask for the name of the temporary file that contains the commit message. "
                            "Use it in a git hook script: MSG_FILE=$1."
                        ),
                        "exclusive_group": "group1",
                    },
                    {
                        "name": "--rev-range",
                        "help": "Validate the commits in the given range of git rev, e.g., master..HEAD.",
                        "exclusive_group": "group1",
                    },
                    {
                        "name": ["-d", "--use-default-range"],
                        "action": "store_true",
                        "default": False,
                        "help": "Validate the commits from the default branch to HEAD, e.g., refs/remotes/origin/master..HEAD.",
                        "exclusive_group": "group1",
                    },
                    {
                        "name": ["-m", "--message"],
                        "help": "Validate the given commit message.",
                        "exclusive_group": "group1",
                    },
                    {
                        "name": ["--allow-abort"],
                        "action": "store_true",
                        "default": False,
                        "help": "Allow empty commit messages, which typically abort a commit.",
                    },
                    {
                        "name": ["--allowed-prefixes"],
                        "nargs": "*",
                        "help": "Skip validation for commit messages that start with the specified prefixes.",
                    },
                    {
                        "name": ["-l", "--message-length-limit"],
                        "type": int,
                        "help": "Restrict the length of the **first line** of the commit message; 0 for no limit.",
                    },
                ],
            },
            {
                "name": ["version"],
                "description": (
                    "Get the version of the installed commitizen or the current project (default: installed commitizen)"
                ),
                "help": (
                    "Get the version of the installed commitizen or the current project (default: installed commitizen)."
                ),
                "func": commands.Version,
                "arguments": [
                    {
                        "name": ["-r", "--report"],
                        "help": "Output the system information for reporting bugs.",
                        "action": "store_true",
                        "exclusive_group": "group1",
                    },
                    {
                        "name": ["-p", "--project"],
                        "help": "Output the version of the current project.",
                        "action": "store_true",
                        "exclusive_group": "group1",
                    },
                    {
                        "name": ["-c", "--commitizen"],
                        "help": "Output the version of the installed commitizen.",
                        "action": "store_true",
                        "exclusive_group": "group1",
                    },
                    {
                        "name": ["-v", "--verbose"],
                        "help": (
                            "Output the version of both the installed commitizen and the current project."
                        ),
                        "action": "store_true",
                        "exclusive_group": "group1",
                    },
                    {
                        "name": ["--major"],
                        "help": (
                            "Output just the major version. Must be used with MANUAL_VERSION, "
                            "--project, or --verbose."
                        ),
                        "action": "store_true",
                        "exclusive_group": "group2",
                    },
                    {
                        "name": ["--minor"],
                        "help": (
                            "Output just the minor version. Must be used with MANUAL_VERSION, "
                            "--project, or --verbose."
                        ),
                        "action": "store_true",
                        "exclusive_group": "group2",
                    },
                    {
                        "name": ["--tag"],
                        "help": "get the version with tag prefix. Need to be used with --project or --verbose.",
                        "action": "store_true",
                        "exclusive_group": "group2",
                    },
                    {
                        "name": ["--patch"],
                        "help": (
                            "Output the patch version only. Must be used with MANUAL_VERSION, "
                            "--project, or --verbose."
                        ),
                        "action": "store_true",
                        "exclusive_group": "group2",
                    },
                    {
                        "name": ["--next"],
                        "help": "Output the next version.",
                        "type": str,
                        "nargs": "?",
                        "default": None,
                        "const": "USE_GIT_COMMITS",
                        "choices": ["USE_GIT_COMMITS"]
                        + [str(increment) for increment in VersionIncrement],
                        "exclusive_group": "group2",
                    },
                    {
                        "name": "manual_version",
                        "type": str,
                        "nargs": "?",
                        "help": "Use the version provided instead of the version from the project. Can be used to test the selected version scheme.",
                        "metavar": "MANUAL_VERSION",
                    },
                ],
            },
        ],
    },
}


def commitizen_excepthook(
    exctype: type[BaseException],
    value: BaseException,
    traceback: TracebackType | None,
    debug: bool = False,
    no_raise: list[int] | None = None,
) -> None:
    traceback = traceback if isinstance(traceback, TracebackType) else None
    if not isinstance(value, CommitizenException):
        sys.__excepthook__(exctype, value, traceback)
        return

    if value.message:
        value.output_method(value.message)
    if debug:
        sys.__excepthook__(exctype, value, traceback)
    exit_code = value.exit_code
    if no_raise is not None and exit_code in no_raise:
        sys.exit(ExitCode.EXPECTED_EXIT)
    sys.exit(exit_code)


def parse_no_raise(comma_separated_no_raise: str) -> list[int]:
    """Convert the given string to exit codes.

    Receives digits and strings and outputs the parsed integer which
    represents the exit code found in exceptions.
    """

    def exit_code_from_str_or_skip(s: str) -> ExitCode | None:
        try:
            return ExitCode.from_str(s)
        except (KeyError, ValueError):
            out.warn(f"WARN: no_raise value `{s}` is not a valid exit code. Skipping.")
            return None

    return [
        code.value
        for s in comma_separated_no_raise.split(",")
        if (code := exit_code_from_str_or_skip(s)) is not None
    ]


if TYPE_CHECKING:

    class Args(argparse.Namespace):
        config: str | None = None
        debug: bool = False
        name: str | None = None
        no_raise: str | None = None  # comma-separated string, later parsed as list[int]
        report: bool = False
        project: bool = False
        commitizen: bool = False
        verbose: bool = False
        func: type[
            commands.Init  # init
            | commands.Commit  # commit (c)
            | commands.ListCz  # ls
            | commands.Example  # example
            | commands.Info  # info
            | commands.Schema  # schema
            | commands.Bump  # bump
            | commands.Changelog  # changelog (ch)
            | commands.Check  # check
            | commands.Version  # version
        ]


def main() -> None:
    sys.excepthook = commitizen_excepthook

    parser: argparse.ArgumentParser = cli(data)
    argcomplete.autocomplete(parser)
    # Show help if no arg provided
    if len(sys.argv) == 1:
        parser.print_help(sys.stderr)
        raise ExpectedExit()

    # This is for the command required constraint in 2.0
    try:
        args, unknown_args = parser.parse_known_args()
    except SystemExit as e:
        if e.code == 2:
            raise NoCommandFoundError()
        raise e

    arguments = vars(args)
    if unknown_args:
        # Raise error for extra-args without -- separation
        if "--" not in unknown_args:
            raise InvalidCommandArgumentError(
                f"Invalid commitizen arguments were found: `{' '.join(unknown_args)}`. "
                "Please use -- separator for extra git args"
            )
        # Raise error for extra-args before --
        elif unknown_args[0] != "--":
            pos = unknown_args.index("--")
            raise InvalidCommandArgumentError(
                f"Invalid commitizen arguments were found before -- separator: `{' '.join(unknown_args[:pos])}`. "
            )
        # Log warning for -- without any extra args
        elif len(unknown_args) == 1:
            logger.warning(
                "\nWARN: Incomplete commit command: received -- separator without any following git arguments\n"
            )
        extra_args = unknown_args[1:]
        arguments["extra_cli_args"] = extra_args

    conf = config.read_cfg(args.config)
    args = cast("Args", args)
    if args.name:
        conf.update({"name": args.name})
    elif not conf.path:
        conf.update({"name": DEFAULT_SETTINGS["name"]})

    if args.debug:
        logging.getLogger("commitizen").setLevel(logging.DEBUG)
        sys.excepthook = partial(sys.excepthook, debug=True)
    if args.no_raise:
        sys.excepthook = partial(sys.excepthook, no_raise=parse_no_raise(args.no_raise))

    args.func(conf, arguments)()  # type: ignore[arg-type]


if __name__ == "__main__":
    main()
