from __future__ import annotations

import inspect
import os
from pathlib import Path
from typing import TYPE_CHECKING

import pytest

from commitizen import cmd, git
from commitizen.exceptions import GitCommandError

if TYPE_CHECKING:
    from pytest_gitconfig import GitConfig
    from pytest_mock import MockFixture

    from tests.utils import UtilFixture


@pytest.mark.parametrize("date", ["2020-01-21", "1970-01-01"])
def test_git_tag_date(date: str):
    git_tag = git.GitTag(rev="sha1-code", name="0.0.1", date="2025-05-30")
    git_tag.date = date
    assert git_tag.date == date


def test_git_object_eq():
    git_commit = git.GitCommit(
        rev="sha1-code", title="this is title", body="this is body"
    )
    git_tag = git.GitTag(rev="sha1-code", name="0.0.1", date="2020-01-21")

    assert git_commit == git_tag
    assert git_commit != "sha1-code"


def test_get_tags(util: UtilFixture):
    tag_str = (
        "v1.0.0---inner_delimiter---333---inner_delimiter---2020-01-20---inner_delimiter---\n"
        "v0.5.0---inner_delimiter---222---inner_delimiter---2020-01-17---inner_delimiter---\n"
        "v0.0.1---inner_delimiter---111---inner_delimiter---2020-01-17---inner_delimiter---\n"
    )
    util.mock_cmd(out=tag_str)

    git_tags = git.get_tags()
    latest_git_tag = git_tags[0]
    assert latest_git_tag.rev == "333"
    assert latest_git_tag.name == "v1.0.0"
    assert latest_git_tag.date == "2020-01-20"

    util.mock_cmd(out="", err="No tag available")
    assert git.get_tags() == []


@pytest.mark.usefixtures("tmp_commitizen_project")
def test_get_reachable_tags(util: UtilFixture):
    util.create_file_and_commit("Initial state")
    util.create_tag("1.0.0")
    # create develop
    util.create_branch("develop")
    util.switch_branch("develop")

    # add a feature to develop
    util.create_file_and_commit("develop")
    util.create_tag("1.1.0b0")

    # create staging
    util.switch_branch("master")
    util.create_file_and_commit("master")
    util.create_tag("1.0.1")

    tags = git.get_tags(reachable_only=True)
    tag_names = set([t.name for t in tags])
    # 1.1.0b0 is not present
    assert tag_names == {"1.0.0", "1.0.1"}


@pytest.mark.usefixtures("tmp_commitizen_project")
@pytest.mark.parametrize("locale", ["en_US", "fr_FR"])
def test_get_reachable_tags_with_commits(locale: str, monkeypatch: pytest.MonkeyPatch):
    monkeypatch.setenv("LANG", f"{locale}.UTF-8")
    monkeypatch.setenv("LANGUAGE", f"{locale}.UTF-8")
    monkeypatch.setenv("LC_ALL", f"{locale}.UTF-8")
    assert git.get_tags(reachable_only=True) == []


def test_get_tag_names(util: UtilFixture):
    tag_str = "v1.0.0\nv0.5.0\nv0.0.1\n"
    util.mock_cmd(out=tag_str)

    assert git.get_tag_names() == ["v1.0.0", "v0.5.0", "v0.0.1"]

    util.mock_cmd(out="", err="No tag available")
    assert git.get_tag_names() == []


def test_git_message_with_empty_body():
    commit_title = "Some Title"
    commit = git.GitCommit("test_rev", "Some Title", body="")

    assert commit.message == commit_title


@pytest.mark.usefixtures("tmp_commitizen_project")
def test_get_log_as_str_list_empty():
    """
    Ensure an exception is raised or empty list in an empty project.
    The behavior is different depending on the version of git.
    """
    try:
        gitlog = git._get_log_as_str_list(start=None, end="HEAD", args=[])
    except GitCommandError:
        return
    assert len(gitlog) == 0, "list should be empty if no assert"


@pytest.mark.usefixtures("tmp_commitizen_project")
def test_get_commits(util: UtilFixture):
    util.create_file_and_commit("feat(users): add username")
    util.create_file_and_commit("fix: username exception")
    commits = git.get_commits()
    assert len(commits) == 2


@pytest.mark.usefixtures("tmp_commitizen_project")
def test_get_commits_author_and_email(util: UtilFixture):
    util.create_file_and_commit("fix: username exception")
    commit = git.get_commits()[0]

    assert commit.author != ""
    assert "@" in commit.author_email


def test_get_commits_without_email(util: UtilFixture):
    raw_commit = (
        "a515bb8f71c403f6f7d1c17b9d8ebf2ce3959395\n"
        "95bbfc703eb99cb49ba0d6ffd8469911303dbe63 12d3b4bdaa996ea7067a07660bb5df4772297bdd\n"
        "\n"
        "user name\n"
        "\n"
        "----------commit-delimiter----------\n"
        "12d3b4bdaa996ea7067a07660bb5df4772297bdd\n"
        "de33bc5070de19600f2f00262b3c15efea762408\n"
        "feat(users): add username\n"
        "user name\n"
        "\n"
        "----------commit-delimiter----------\n"
    )
    util.mock_cmd(out=raw_commit)

    commits = git.get_commits()

    assert commits[0].author == "user name"
    assert commits[1].author == "user name"

    assert commits[0].author_email == ""
    assert commits[1].author_email == ""

    assert commits[0].title == ""
    assert commits[1].title == "feat(users): add username"


def test_get_commits_without_breakline_in_each_commit(util: UtilFixture):
    raw_commit = (
        "ae9ba6fc5526cf478f52ef901418d85505109744\n"
        "ff2f56ca844de72a9d59590831087bf5a97bac84\n"
        "bump: version 2.13.0 → 2.14.0\n"
        "GitHub Action\n"
        "action@github.com\n"
        "----------commit-delimiter----------\n"
        "ff2f56ca844de72a9d59590831087bf5a97bac84\n"
        "b4dc83284dc8c9729032a774a037df1d1f2397d5 20a54bf1b82cd7b573351db4d1e8814dd0be205d\n"
        "Merge pull request #332 from cliles/feature/271-redux\n"
        "User\n"
        "user@email.com\n"
        "Feature/271 redux----------commit-delimiter----------\n"
        "20a54bf1b82cd7b573351db4d1e8814dd0be205d\n"
        "658f38c3fe832cdab63ed4fb1f7b3a0969a583be\n"
        "feat(#271): enable creation of annotated tags when bumping\n"
        "User 2\n"
        "user@email.edu\n"
        "----------commit-delimiter----------\n"
    )
    util.mock_cmd(out=raw_commit)

    commits = git.get_commits()

    assert commits[0].author == "GitHub Action"
    assert commits[1].author == "User"
    assert commits[2].author == "User 2"

    assert commits[0].author_email == "action@github.com"
    assert commits[1].author_email == "user@email.com"
    assert commits[2].author_email == "user@email.edu"

    assert commits[0].title == "bump: version 2.13.0 → 2.14.0"
    assert commits[1].title == "Merge pull request #332 from cliles/feature/271-redux"
    assert (
        commits[2].title == "feat(#271): enable creation of annotated tags when bumping"
    )


def test_get_commits_with_and_without_parents(util: UtilFixture):
    raw_commit = (
        "4206e661bacf9643373255965f34bbdb382cb2b9\n"
        "ae9ba6fc5526cf478f52ef901418d85505109744 bf8479e7aa1a5b9d2f491b79e3a4d4015519903e\n"
        "Merge pull request from someone\n"
        "Maintainer\n"
        "maintainer@email.com\n"
        "This is a much needed feature----------commit-delimiter----------\n"
        "ae9ba6fc5526cf478f52ef901418d85505109744\n"
        "ff2f56ca844de72a9d59590831087bf5a97bac84\n"
        "Release 0.1.0\n"
        "GitHub Action\n"
        "action@github.com\n"
        "----------commit-delimiter----------\n"
        "ff2f56ca844de72a9d59590831087bf5a97bac84\n"
        "\n"
        "Initial commit\n"
        "User\n"
        "user@email.com\n"
        "----------commit-delimiter----------\n"
    )
    util.mock_cmd(out=raw_commit)

    commits = git.get_commits()

    assert commits[0].author == "Maintainer"
    assert commits[1].author == "GitHub Action"
    assert commits[2].author == "User"

    assert commits[0].author_email == "maintainer@email.com"
    assert commits[1].author_email == "action@github.com"
    assert commits[2].author_email == "user@email.com"

    assert commits[0].title == "Merge pull request from someone"
    assert commits[1].title == "Release 0.1.0"
    assert commits[2].title == "Initial commit"

    assert commits[0].body == "This is a much needed feature"
    assert commits[1].body == ""
    assert commits[2].body == ""

    assert commits[0].parents == [
        "ae9ba6fc5526cf478f52ef901418d85505109744",
        "bf8479e7aa1a5b9d2f491b79e3a4d4015519903e",
    ]
    assert commits[1].parents == ["ff2f56ca844de72a9d59590831087bf5a97bac84"]
    assert commits[2].parents == []


@pytest.mark.usefixtures("in_repo_root")
def test_get_commits_with_signature(gitconfig: GitConfig):
    # temporarily turn on --show-signature
    gitconfig.set("log.showsignature", "true")

    # retrieve a commit that we know has a signature
    commit = git.get_commits(
        start="bec20ebf433f2281c70f1eb4b0b6a1d0ed83e9b2",
        end="9eae518235d051f145807ddf971ceb79ad49953a",
    )[0]

    assert commit.title.startswith("fix")


def test_get_tag_names_has_correct_arrow_annotation():
    arrow_annotation = inspect.getfullargspec(git.get_tag_names).annotations["return"]

    assert arrow_annotation == "list[str]"


@pytest.mark.usefixtures("tmp_commitizen_project")
def test_get_latest_tag_name(util: UtilFixture):
    tag_name = git.get_latest_tag_name()
    assert tag_name is None

    util.create_file_and_commit("feat(test): test")
    util.create_tag("1.0")
    tag_name = git.get_latest_tag_name()
    assert tag_name == "1.0"


@pytest.mark.usefixtures("tmp_commitizen_project")
def test_is_staging_clean_when_adding_file():
    assert git.is_staging_clean() is True

    Path("test_file").touch()

    assert git.is_staging_clean() is True

    cmd.run(["git", "add", "test_file"])

    assert git.is_staging_clean() is False


@pytest.mark.usefixtures("tmp_commitizen_project")
def test_is_staging_clean_when_updating_file():
    assert git.is_staging_clean() is True

    Path("test_file").touch()
    cmd.run(["git", "add", "test_file"])
    cmd.run(["git", "commit", "-m", "add test_file"])
    Path("test_file").write_text("test")

    assert git.is_staging_clean() is True

    cmd.run(["git", "add", "test_file"])

    assert git.is_staging_clean() is False


@pytest.mark.usefixtures("tmp_commitizen_project")
def test_get_eol_for_open():
    assert git.EOLType.for_open() == os.linesep

    cmd.run(["git", "config", "core.eol", "lf"])
    assert git.EOLType.for_open() == "\n"

    cmd.run(["git", "config", "core.eol", "crlf"])
    assert git.EOLType.for_open() == "\r\n"

    cmd.run(["git", "config", "core.eol", "native"])
    assert git.EOLType.for_open() == os.linesep


def test_get_core_editor(monkeypatch: pytest.MonkeyPatch, util: UtilFixture):
    monkeypatch.setenv("GIT_EDITOR", "nano")
    assert git.get_core_editor() == "nano"

    monkeypatch.delenv("GIT_EDITOR")

    util.mock_cmd(out="vim")
    assert git.get_core_editor() == "vim"

    util.mock_cmd()
    assert git.get_core_editor() is None


@pytest.mark.usefixtures("tmp_commitizen_project")
def test_create_tag_with_message(util: UtilFixture):
    util.create_file_and_commit("feat(test): test")
    tag_name = "1.0"
    tag_message = "test message"
    util.create_tag(tag_name, tag_message)
    assert git.get_latest_tag_name() == tag_name
    assert git.get_tag_message(tag_name) == tag_message


@pytest.mark.parametrize(
    ("file_path", "expected_cmd"),
    [
        (
            "/tmp/temp file",
            ["git", "commit", "--signoff", "-F", "/tmp/temp file"],
        ),
        (
            "/tmp dir/temp file",
            ["git", "commit", "--signoff", "-F", "/tmp dir/temp file"],
        ),
        (
            "/tmp/tempfile",
            ["git", "commit", "--signoff", "-F", "/tmp/tempfile"],
        ),
    ],
    ids=[
        "File contains spaces",
        "Path contains spaces",
        "Path does not contain spaces",
    ],
)
def test_commit_with_spaces_in_path(
    mocker: MockFixture, file_path: str, expected_cmd: list[str], util: UtilFixture
):
    mock_run = util.mock_cmd()
    mock_unlink = mocker.patch("os.unlink")
    mock_temp_file = mocker.patch("commitizen.git.NamedTemporaryFile")
    mock_temp_file.return_value.name = file_path

    git.commit("feat: new feature", ["--signoff"])

    mock_run.assert_called_once_with(expected_cmd, env=None)
    mock_unlink.assert_called_once_with(file_path)


@pytest.mark.usefixtures("tmp_commitizen_project")
def test_get_filenames_in_commit(util: UtilFixture):
    """Test get_filenames_in_commit returns filenames from the last commit."""
    util.create_file_and_commit("feat: old feature", filename="old_file.txt")

    filename = "test_feature_file.txt"
    util.create_file_and_commit("feat: add new feature", filename=filename)

    filenames = git.get_filenames_in_commit()
    assert [filename] == filenames


@pytest.mark.usefixtures("tmp_commitizen_project")
def test_get_filenames_in_commit_with_git_reference(util: UtilFixture):
    """Test get_filenames_in_commit with a specific git reference (commit SHA)."""
    first_filename = "first_feature.txt"
    util.create_file_and_commit("feat: first feature", filename=first_filename)
    first_commit_rev = cmd.run(["git", "rev-parse", "HEAD"]).out.strip()

    second_filename = "second_feature.txt"
    util.create_file_and_commit("feat: second feature", filename=second_filename)

    # Query the first commit by its SHA
    filenames = git.get_filenames_in_commit(git_reference=first_commit_rev)
    assert first_filename in filenames
    assert second_filename not in filenames


def test_get_filenames_in_commit_error(util: UtilFixture):
    """Test that GitCommandError is raised when git command fails."""
    util.mock_cmd(err="fatal: bad object HEAD", return_code=1)
    with pytest.raises(GitCommandError, match="fatal: bad object HEAD"):
        git.get_filenames_in_commit()


@pytest.mark.parametrize(
    "linebreak", ["\n", "\r\n"], ids=["line_feed", "carriage_return"]
)
def test_git_commit_from_rev_and_commit(linebreak):
    rev_and_commit = linebreak.join(
        [
            "abc123",  # rev
            "def456 ghi789",  # parents
            "feat: add new feature",  # title
            "John Doe",  # author
            "john@example.com",  # author_email
            "This is a detailed description",  # body
            "of the new feature",
            "with multiple lines",
        ]
    )

    commit = git.GitCommit.from_rev_and_commit(rev_and_commit)

    assert commit.rev == "abc123"
    assert commit.title == "feat: add new feature"
    assert (
        commit.body
        == "This is a detailed description\nof the new feature\nwith multiple lines"
    )
    assert commit.author == "John Doe"
    assert commit.author_email == "john@example.com"
    assert commit.parents == ["def456", "ghi789"]

    # Test with minimal data
    minimal_commit = linebreak.join(
        [
            "abc123",  # rev
            "",  # no parents
            "feat: minimal commit",  # title
            "John Doe",  # author
            "john@example.com",  # author_email
        ]
    )

    commit = git.GitCommit.from_rev_and_commit(minimal_commit)

    assert commit.rev == "abc123"
    assert commit.title == "feat: minimal commit"
    assert commit.body == ""
    assert commit.author == "John Doe"
    assert commit.author_email == "john@example.com"
    assert commit.parents == []


@pytest.mark.parametrize(
    "committer_date",
    [
        "2024-03-20",
        None,
    ],
)
def test_commit_uses_list_args_and_env(mocker: MockFixture, committer_date: str | None):
    """Test that git.commit uses list-based subprocess (no shell injection)."""
    mock_run = mocker.patch("commitizen.cmd.run")
    mock_run.return_value = cmd.Command("", "", b"", b"", 0)

    # We need to mock NamedTemporaryFile to control the file name
    mock_file = mocker.MagicMock()
    mock_file.name = "temp.txt"
    mock_file.__enter__ = mocker.MagicMock(return_value=mock_file)
    mock_file.__exit__ = mocker.MagicMock(return_value=False)
    mocker.patch("commitizen.git.NamedTemporaryFile", return_value=mock_file)
    mocker.patch("os.unlink")

    git.commit(
        "test message", args=["-a", "--no-verify"], committer_date=committer_date
    )

    call_args = mock_run.call_args
    # First positional arg should be a list (no shell injection)
    cmd_list = call_args[0][0]
    assert isinstance(cmd_list, list)
    assert cmd_list == ["git", "commit", "-a", "--no-verify", "-F", "temp.txt"]

    if committer_date:
        env = call_args[1]["env"]
        assert env == {"GIT_COMMITTER_DATE": committer_date}
    else:
        env = call_args[1]["env"]
        assert env is None


def test_get_default_branch_success(util: UtilFixture):
    util.mock_cmd(out="refs/remotes/origin/main\n")
    assert git.get_default_branch() == "refs/remotes/origin/main"


def test_get_default_branch_error(util: UtilFixture):
    util.mock_cmd(
        err="fatal: ref refs/remotes/origin/HEAD is not a symbolic ref", return_code=1
    )
    with pytest.raises(GitCommandError):
        git.get_default_branch()
