import os
import subprocess
import sys
import types
from functools import partial

import pytest
from pytest_mock import MockFixture

from commitizen import cli
from commitizen.exceptions import (
    ConfigFileNotFound,
    ExpectedExit,
    InvalidCommandArgumentError,
    NoCommandFoundError,
    NotAGitProjectError,
)


def test_sysexit_no_argv(mocker: MockFixture, capsys):
    testargs = ["cz"]
    mocker.patch.object(sys, "argv", testargs)

    with pytest.raises(ExpectedExit):
        cli.main()
        out, _ = capsys.readouterr()
        assert out.startswith("usage")


def test_cz_config_file_without_correct_file_path(mocker: MockFixture, capsys):
    testargs = ["cz", "--config", "./config/pyproject.toml", "example"]
    mocker.patch.object(sys, "argv", testargs)

    with pytest.raises(ConfigFileNotFound) as excinfo:
        cli.main()
    assert "Cannot found the config file" in str(excinfo.value)


def test_cz_with_arg_but_without_command(mocker: MockFixture):
    testargs = ["cz", "--name", "cz_jira"]
    mocker.patch.object(sys, "argv", testargs)

    with pytest.raises(NoCommandFoundError) as excinfo:
        cli.main()
    assert "Command is required" in str(excinfo.value)


def test_name(mocker: MockFixture, capsys):
    testargs = ["cz", "-n", "cz_jira", "example"]
    mocker.patch.object(sys, "argv", testargs)

    cli.main()
    out, _ = capsys.readouterr()
    assert out.startswith("JRA")


@pytest.mark.usefixtures("tmp_git_project")
def test_name_default_value(mocker: MockFixture, capsys):
    testargs = ["cz", "example"]
    mocker.patch.object(sys, "argv", testargs)

    cli.main()
    out, _ = capsys.readouterr()
    assert out.startswith("fix: correct minor typos in code")


def test_ls(mocker: MockFixture, capsys):
    testargs = ["cz", "-n", "cz_jira", "ls"]
    mocker.patch.object(sys, "argv", testargs)
    cli.main()
    out, err = capsys.readouterr()

    assert "cz_conventional_commits" in out
    assert isinstance(out, str)


def test_arg_debug(mocker: MockFixture):
    testargs = ["cz", "--debug", "info"]
    mocker.patch.object(sys, "argv", testargs)
    cli.main()
    excepthook = sys.excepthook
    # `sys.excepthook` is replaced by a `partial` in `cli.main`
    # it's not a normal function
    assert isinstance(excepthook, partial)
    assert excepthook.keywords.get("debug") is True


def test_commitizen_excepthook(capsys):
    with pytest.raises(SystemExit) as excinfo:
        cli.commitizen_excepthook(NotAGitProjectError, NotAGitProjectError(), "")

    assert excinfo.type is SystemExit
    assert excinfo.value.code == NotAGitProjectError.exit_code


def test_commitizen_debug_excepthook(capsys):
    with pytest.raises(SystemExit) as excinfo:
        cli.commitizen_excepthook(
            NotAGitProjectError,
            NotAGitProjectError(),
            "",
            debug=True,
        )

    assert excinfo.type is SystemExit
    assert excinfo.value.code == NotAGitProjectError.exit_code
    assert "NotAGitProjectError" in str(excinfo.traceback[0])


@pytest.mark.skipif(
    os.name == "nt",
    reason="`argcomplete` does not support Git Bash on Windows.",
)
def test_argcomplete_activation():
    """
    This function is testing the one-time activation of argcomplete for
    commitizen only.

    Equivalent to run:
    $ eval "$(register-python-argcomplete pytest)"
    """
    output = subprocess.run(["register-python-argcomplete", "cz"])

    assert output.returncode == 0


def test_commitizen_excepthook_no_raises(capsys):
    with pytest.raises(SystemExit) as excinfo:
        cli.commitizen_excepthook(
            NotAGitProjectError,
            NotAGitProjectError(),
            "",
            no_raise=[NotAGitProjectError.exit_code],
        )

    assert excinfo.type is SystemExit
    assert excinfo.value.code == 0


def test_parse_no_raise_single_integer():
    input_str = "1"
    result = cli.parse_no_raise(input_str)
    assert result == [1]


def test_parse_no_raise_integers():
    input_str = "1,2,3"
    result = cli.parse_no_raise(input_str)
    assert result == [1, 2, 3]


def test_parse_no_raise_error_code():
    input_str = "NO_COMMITIZEN_FOUND,NO_COMMITS_FOUND,NO_PATTERN_MAP"
    result = cli.parse_no_raise(input_str)
    assert result == [1, 3, 5]


def test_parse_no_raise_mix_integer_error_code():
    input_str = "NO_COMMITIZEN_FOUND,2,NO_COMMITS_FOUND,4"
    result = cli.parse_no_raise(input_str)
    assert result == [1, 2, 3, 4]


def test_parse_no_raise_mix_invalid_arg_is_skipped():
    input_str = "NO_COMMITIZEN_FOUND,2,nothing,4"
    result = cli.parse_no_raise(input_str)
    assert result == [1, 2, 4]


def test_unknown_args_raises(mocker: MockFixture):
    testargs = ["cz", "c", "-this_arg_is_not_supported"]
    mocker.patch.object(sys, "argv", testargs)
    with pytest.raises(InvalidCommandArgumentError) as excinfo:
        cli.main()
    assert "Invalid commitizen arguments were found" in str(excinfo.value)


def test_unknown_args_before_double_dash_raises(mocker: MockFixture):
    testargs = ["cz", "c", "-this_arg_is_not_supported", "--"]
    mocker.patch.object(sys, "argv", testargs)
    with pytest.raises(InvalidCommandArgumentError) as excinfo:
        cli.main()
    assert "Invalid commitizen arguments were found before -- separator" in str(
        excinfo.value
    )


def test_commitizen_excepthook_non_commitizen_exception(mocker: MockFixture):
    """Test that commitizen_excepthook delegates to sys.__excepthook__ for non-CommitizenException."""
    # Mock the original excepthook
    mock_original_excepthook = mocker.Mock()
    mocker.patch("commitizen.cli.sys.__excepthook__", mock_original_excepthook)

    # Create a regular exception
    test_exception = ValueError("test error")

    # Call commitizen_excepthook with the regular exception
    cli.commitizen_excepthook(ValueError, test_exception, None)

    # Verify sys.__excepthook__ was called with correct arguments
    mock_original_excepthook.assert_called_once_with(ValueError, test_exception, None)


def test_commitizen_excepthook_non_commitizen_exception_with_traceback(
    mocker: MockFixture,
):
    """Test that commitizen_excepthook handles traceback correctly for non-CommitizenException."""
    # Mock the original excepthook
    mock_original_excepthook = mocker.Mock()
    mocker.patch("commitizen.cli.sys.__excepthook__", mock_original_excepthook)

    # Create a regular exception with a traceback
    test_exception = ValueError("test error")
    test_traceback = mocker.Mock(spec=types.TracebackType)

    # Call commitizen_excepthook with the regular exception and traceback
    cli.commitizen_excepthook(ValueError, test_exception, test_traceback)

    # Verify sys.__excepthook__ was called with correct arguments including traceback
    mock_original_excepthook.assert_called_once_with(
        ValueError, test_exception, test_traceback
    )


def test_commitizen_excepthook_non_commitizen_exception_with_invalid_traceback(
    mocker: MockFixture,
):
    """Test that commitizen_excepthook handles invalid traceback correctly for non-CommitizenException."""
    # Mock the original excepthook
    mock_original_excepthook = mocker.Mock()
    mocker.patch("commitizen.cli.sys.__excepthook__", mock_original_excepthook)

    # Create a regular exception with an invalid traceback
    test_exception = ValueError("test error")
    test_traceback = mocker.Mock()  # Not a TracebackType

    # Call commitizen_excepthook with the regular exception and invalid traceback
    cli.commitizen_excepthook(ValueError, test_exception, test_traceback)

    # Verify sys.__excepthook__ was called with None as traceback
    mock_original_excepthook.assert_called_once_with(ValueError, test_exception, None)
