from __future__ import annotations

import re
from dataclasses import replace
from datetime import date, datetime, timedelta, timezone
from typing import Any

import pytest
from vcs_versioning import Configuration, NonNormalizedVersion
from vcs_versioning._scm_version import ScmVersion, meta
from vcs_versioning._version_schemes import (
    calver_by_date,
    format_version,
    guess_next_date_ver,
    guess_next_version,
    no_guess_dev_version,
    only_version,
    release_branch_semver_version,
    simplified_semver_version,
)

c = Configuration()
c_non_normalize = Configuration(version_cls=NonNormalizedVersion)


@pytest.mark.parametrize(
    ("version", "expected_next"),
    [
        pytest.param(meta("1.0.0", config=c), "1.0.0", id="exact"),
        pytest.param(meta("1.0", config=c), "1.0", id="short_tag"),
        pytest.param(meta("1.0.0rc1", config=c), "1.0.0rc1", id="exact_rc"),
        pytest.param(meta("2.0.dev0", config=c), "2.0.dev0", id="exact_dev0"),
        pytest.param(
            meta("1.0.0", distance=2, branch="default", config=c),
            "1.0.1.dev2",
            id="normal_branch",
        ),
        pytest.param(
            meta("1.0", distance=2, branch="default", config=c),
            "1.0.1.dev2",
            id="normal_branch_short_tag",
        ),
        pytest.param(
            meta("1.0.0", distance=2, branch="feature", config=c),
            "1.1.0.dev2",
            id="feature_branch",
        ),
        pytest.param(
            meta("1.0", distance=2, branch="feature", config=c),
            "1.1.0.dev2",
            id="feature_branch_short_tag",
        ),
        pytest.param(
            meta("1.0.0", distance=2, branch="features/test", config=c),
            "1.1.0.dev2",
            id="feature_in_branch",
        ),
        pytest.param(
            meta(NonNormalizedVersion("v1.0"), distance=2, branch="default", config=c),
            "1.0.1.dev2",
            id="non-normalized-allowed",
        ),
        pytest.param(
            meta("2.0.dev0", distance=5, branch="default", config=c),
            "2.0.0.dev5",
            id="dev0_anchor_default_branch",
        ),
        pytest.param(
            meta("2.0.dev0", distance=5, branch="feature/fun", config=c),
            "2.0.0.dev5",
            id="dev0_anchor_feature_branch",
        ),
        pytest.param(
            meta("3.0.dev0", distance=1, branch="default", config=c),
            "3.0.0.dev1",
            id="dev0_anchor_single_commit",
        ),
    ],
)
def test_next_semver(version: ScmVersion, expected_next: str) -> None:
    computed = simplified_semver_version(version)
    assert computed == expected_next


@pytest.mark.parametrize(
    ("version", "expected_next"),
    [
        pytest.param(meta("1.0.0", config=c), "1.0.0", id="exact"),
        pytest.param(meta("2.0.dev0", config=c), "2.0.dev0", id="exact_dev0"),
        pytest.param(meta("1.0.0rc1", config=c), "1.0.0rc1", id="exact_rc"),
        pytest.param(
            meta("1.0.0", distance=2, branch="master", config=c),
            "1.1.0.dev2",
            id="development_branch",
        ),
        pytest.param(
            meta("1.0.0rc1", distance=2, branch="master", config=c),
            "1.1.0.dev2",
            id="development_branch_release_candidate",
        ),
        pytest.param(
            meta("1.0.0", distance=2, branch="maintenance/1.0.x", config=c),
            "1.0.1.dev2",
            id="release_branch_legacy_version",
        ),
        pytest.param(
            meta("1.0.0", distance=2, branch="v1.0.x", config=c),
            "1.0.1.dev2",
            id="release_branch_with_v_prefix",
        ),
        pytest.param(
            meta("1.0.0", distance=2, branch="release-1.0", config=c),
            "1.0.1.dev2",
            id="release_branch_with_prefix",
        ),
        pytest.param(
            meta("1.0.0", distance=2, branch="bugfix/3434", config=c),
            "1.1.0.dev2",
            id="false_positive_release_branch",
        ),
        pytest.param(
            meta("2.0.dev0", distance=5, branch="master", config=c),
            "2.0.0.dev5",
            id="dev0_anchor_development_branch",
        ),
        pytest.param(
            meta("2.0.dev0", distance=5, branch="release-2.0", config=c),
            "2.0.dev5",
            id="dev0_anchor_release_branch",
        ),
    ],
)
def test_next_release_branch_semver(version: ScmVersion, expected_next: str) -> None:
    computed = release_branch_semver_version(version)
    assert computed == expected_next


def m(tag: str, **kw: Any) -> ScmVersion:
    return meta(tag, **kw, config=c)


@pytest.mark.parametrize(
    ("version", "expected_next"),
    [
        pytest.param(
            m("1.0.0", distance=2),
            "1.0.0.post1.dev2",
            id="dev_distance",
        ),
        pytest.param(
            m("1.0.dev0", distance=2), "1.0.dev2", id="dev_distance_after_dev_tag"
        ),
        pytest.param(
            m("1.0", distance=2),
            "1.0.post1.dev2",
            id="dev_distance_short_tag",
        ),
        pytest.param(
            m("1.0.0"),
            "1.0.0",
            id="no_dev_distance",
        ),
    ],
)
def test_no_guess_version(version: ScmVersion, expected_next: str) -> None:
    computed = no_guess_dev_version(version)
    assert computed == expected_next


@pytest.mark.parametrize(
    ("version", "match"),
    [
        ("1.0.dev1", "choosing custom numbers for the `.devX` distance"),
        ("1.0.post1", "already is a post release"),
    ],
)
def test_no_guess_version_bad(version: str, match: str) -> None:
    with pytest.raises(ValueError, match=match):
        no_guess_dev_version(m(version, distance=1))


def test_bump_dev_version_zero() -> None:
    assert guess_next_version(m("1.0.dev0")) == "1.0"


def test_bump_dev_version_nonzero_raises() -> None:
    match = (
        "choosing custom numbers for the `.devX` distance "
        "is not supported.\n "
        "The 1.0.dev1 can't be bumped\n"
        "Please drop the tag or create a new supported one ending in .dev0"
    )

    with pytest.raises(ValueError, match=match):
        guess_next_version(m("1.0.dev1"))


@pytest.mark.parametrize(
    "version",
    [
        "1.dev0",
        "1.0.dev456",
        "1.0a1",
        "1.0a2.dev456",
        "1.0a12.dev456",
        "1.0a12",
        "1.0b1.dev456",
        "1.0b2",
        "1.0b2.post345.dev456",
        "1.0b2.post345",
        "1.0rc1.dev456",
        "1.0rc1",
        "1.0",
        "1.0.post456.dev34",
        "1.0.post456",
        "1.0.15",
        "1.1.dev1",
    ],
)
def test_only_version(version: str) -> None:
    assert version == only_version(meta(version, config=c))
    assert version == only_version(meta(version, distance=2, config=c))


@pytest.mark.parametrize(
    ("tag", "expected"),
    [
        ("v1.0.0", "1.0.0"),
        ("v1.0.0-rc.1", "1.0.0rc1"),
        ("v1.0.0-rc.1+-25259o4382757gjurh54", "1.0.0rc1"),
    ],
)
def test_tag_regex1(tag: str, expected: str) -> None:
    if "+" in tag:
        # pytest bug wrt cardinality
        with pytest.warns(UserWarning):
            result = meta(tag, config=c)
    else:
        result = meta(tag, config=c)
    assert result.tag.public == expected


def test_regex_match_but_no_version() -> None:
    with pytest.raises(
        ValueError,
        match=(
            r'The tag_regex "\(\?P<version>\)\.\*" matched tag "v1",'
            " however the matched group has no value"
        ),
    ):
        meta("v1", config=replace(c, tag_regex=re.compile(r"(?P<version>).*")))


@pytest.mark.issue("https://github.com/pypa/setuptools-scm/issues/471")
def test_version_bump_bad() -> None:
    class YikesVersion:
        val: str

        def __init__(self, val: str) -> None:
            self.val = val

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

    config = Configuration(version_cls=YikesVersion)  # type: ignore[arg-type]
    with pytest.raises(
        ValueError,
        match=r".*does not end with a number to bump, "
        "please correct or use a custom version scheme",
    ):
        guess_next_version(tag_version=meta("2.0.0-alpha.5-PMC", config=config))


def test_format_version_schemes() -> None:
    version = meta(
        "1.0",
        config=replace(
            c,
            local_scheme="no-local-version",
            version_scheme=[
                lambda v: None,
                "guess-next-dev",
            ],
        ),
    )
    assert format_version(version) == "1.0"


def test_custom_version_schemes() -> None:
    version = meta(
        "1.0",
        config=replace(
            c,
            local_scheme="no-local-version",
            version_scheme="vcs_versioning._version_schemes:no_guess_dev_version",
        ),
    )
    custom_computed = format_version(version)
    assert custom_computed == no_guess_dev_version(version)


# Fixed time for consistent test behavior across timezone boundaries
# This prevents issue #687 where tests failed around midnight in non-UTC timezones
_TEST_TIME = datetime(2023, 12, 15, 12, 0, 0, tzinfo=timezone.utc)


def date_offset(base_date: date | None = None, days_offset: int = 0) -> date:
    if base_date is None:
        base_date = _TEST_TIME.date()
    return base_date - timedelta(days=days_offset)


def date_to_str(
    base_date: date | None = None,
    days_offset: int = 0,
    fmt: str = "%y.%m.%d",
) -> str:
    return format(date_offset(base_date, days_offset), fmt)


@pytest.mark.parametrize(
    ("version", "expected_next"),
    [
        pytest.param(
            meta(date_to_str(days_offset=3), config=c_non_normalize),
            date_to_str(days_offset=3),
            id="exact",
        ),
        pytest.param(
            meta(date_to_str() + ".1", config=c_non_normalize),
            date_to_str() + ".1",
            id="exact patch",
        ),
        pytest.param(
            meta("20.01.02", config=c),
            "20.1.2",
            id="leading 0s",
        ),
        pytest.param(
            meta(
                date_to_str(days_offset=3),
                config=c_non_normalize,
                dirty=True,
                time=_TEST_TIME,
            ),
            date_to_str() + ".0.dev0",
            id="dirty other day",
        ),
        pytest.param(
            meta(
                date_to_str(),
                config=c_non_normalize,
                distance=2,
                branch="default",
                time=_TEST_TIME,
            ),
            date_to_str() + ".1.dev2",
            id="normal branch",
        ),
        pytest.param(
            meta(date_to_str(fmt="%Y.%m.%d"), config=c_non_normalize),
            date_to_str(fmt="%Y.%m.%d"),
            id="4 digits year",
        ),
        pytest.param(
            meta(
                date_to_str(),
                config=c_non_normalize,
                distance=2,
                branch="release-2021.05.06",
            ),
            "2021.05.06",
            id="release branch",
        ),
        pytest.param(
            meta(
                date_to_str() + ".2",
                config=c_non_normalize,
                distance=2,
                branch="release-21.5.1",
            ),
            "21.5.1",
            id="release branch short",
        ),
        pytest.param(
            meta(
                date_to_str(days_offset=3) + ".2",
                config=c_non_normalize,
                node_date=date_offset(days_offset=2),
            ),
            date_to_str(days_offset=3) + ".2",
            id="node date clean",
        ),
        pytest.param(
            meta(
                date_to_str(days_offset=2) + ".2",
                config=c_non_normalize,
                distance=2,
                node_date=date_offset(days_offset=2),
            ),
            date_to_str(days_offset=2) + ".3.dev2",
            id="node date distance",
        ),
        pytest.param(
            meta(
                "1.2.0",
                config=c_non_normalize,
                distance=2,
                node_date=date_offset(days_offset=2),
            ),
            date_to_str(days_offset=2) + ".0.dev2",
            marks=pytest.mark.filterwarnings(
                "ignore:.*not correspond to a valid versioning date.*:UserWarning"
            ),
            id="using on old version tag",
        ),
    ],
)
def test_calver_by_date(version: ScmVersion, expected_next: str) -> None:
    computed = calver_by_date(version)
    assert computed == expected_next


@pytest.mark.parametrize(
    ("version", "expected_next"),
    [
        pytest.param(meta("1.0.0", config=c), "1.0.0", id="SemVer exact stays"),
        pytest.param(
            meta("1.0.0", config=c_non_normalize, dirty=True, time=_TEST_TIME),
            "23.12.15.0.dev0",
            id="SemVer dirty is replaced by date",
            marks=pytest.mark.filterwarnings("ignore:.*legacy version.*:UserWarning"),
        ),
    ],
)
def test_calver_by_date_semver(version: ScmVersion, expected_next: str) -> None:
    computed = calver_by_date(version)
    assert computed == expected_next


def test_calver_by_date_future_warning() -> None:
    with pytest.warns(UserWarning, match="your previous tag*"):
        calver_by_date(
            meta(
                date_to_str(days_offset=-2),
                config=c_non_normalize,
                distance=2,
                time=_TEST_TIME,
            )
        )


@pytest.mark.parametrize(
    ("tag", "node_date", "expected"),
    [
        pytest.param("20.03.03", date(2020, 3, 4), "20.03.04.0", id="next day"),
        pytest.param("20.03.03", date(2020, 3, 3), "20.03.03.1", id="same day"),
        pytest.param(
            "20.03.03.2", date(2020, 3, 3), "20.03.03.3", id="same day with patch"
        ),
        pytest.param(
            "v20.03.03", date(2020, 3, 4), "v20.03.04.0", id="next day with v prefix"
        ),
    ],
)
def test_calver_guess_next_data(tag: str, node_date: date, expected: str) -> None:
    version = meta(tag, config=c_non_normalize, node_date=node_date)
    next = guess_next_date_ver(
        version,
        node_date=node_date,
        version_cls=c_non_normalize.version_cls,
    )
    assert next == expected


def test_scm_version_matches() -> None:
    """Test ScmVersion.matches() method."""
    # Create a test version
    version = meta(
        "1.2.3",
        distance=5,
        dirty=True,
        node="abc123def456",
        branch="main",
        config=c,
    )

    # Test exact matches
    assert version.matches(tag="1.2.3")
    assert version.matches(distance=5)
    assert version.matches(dirty=True)
    assert version.matches(branch="main")
    assert version.matches(exact=False)  # distance > 0

    # Test multiple matches
    assert version.matches(tag="1.2.3", distance=5, dirty=True)

    # Test node prefix matching
    assert version.matches(node_prefix="abc")
    assert version.matches(node_prefix="abc123")
    assert version.matches(node_prefix="abc123def456")

    # Test mismatches - we only care that they're falsy
    assert not version.matches(tag="1.2.4")
    assert not version.matches(distance=3)
    assert not version.matches(dirty=False)
    assert not version.matches(node_prefix="xyz")

    # Test multiple mismatches
    assert not version.matches(tag="1.2.4", distance=3, dirty=False)


def test_scm_version_matches_exact() -> None:
    """Test ScmVersion.matches() with exact versions."""
    # Exact version (tag with no distance and not dirty)
    exact_version = meta("2.0.0", distance=0, dirty=False, config=c)
    assert exact_version.matches(exact=True)
    assert exact_version.matches(tag="2.0.0", exact=True)

    # Non-exact version
    non_exact = meta("2.0.0", distance=1, dirty=False, config=c)
    assert not non_exact.matches(exact=True)


def test_scm_version_matches_none_node() -> None:
    """Test ScmVersion.matches() when node is None."""
    version = meta("1.0.0", node=None, config=c)

    # Should fail node_prefix match when node is None
    assert not version.matches(node_prefix="abc")


def test_custom_version_cls() -> None:
    """Test that we can pass our own version class instead of pkg_resources"""

    class MyVersion:
        def __init__(self, tag_str: str) -> None:
            self.tag = tag_str

        def __str__(self) -> str:
            return f"Custom {self.tag}"

        def __repr__(self) -> str:
            return f"MyVersion<Custom{self.tag}>"

        @property
        def public(self) -> str:
            """The public portion of the version (without local part)."""
            return self.tag.split("+")[0]

        @property
        def local(self) -> str | None:
            """The local version segment."""
            if "+" in self.tag:
                return self.tag.split("+", 1)[1]
            return None

    config = Configuration(version_cls=MyVersion)  # type: ignore[arg-type]
    scm_version = meta("1.0.0-foo", config=config)

    assert isinstance(scm_version.tag, MyVersion)
    assert str(scm_version.tag) == "Custom 1.0.0-foo"


@pytest.mark.parametrize("config_key", ["version_scheme", "local_scheme"])
def test_no_matching_entrypoints(config_key: str) -> None:
    version = meta(
        "1.0",
        config=replace(c, **{config_key: "nonexistent"}),  # type: ignore[arg-type]
    )
    with pytest.raises(
        ValueError,
        match=(
            r'Couldn\'t find any implementations for entrypoint "setuptools_scm\..*?"'
            ' with value "nonexistent"'
        ),
    ):
        format_version(version)


@pytest.mark.parametrize(
    ("old_name", "new_name"),
    [
        ("python-simplified-semver", "semver-pep440"),
        ("release-branch-semver", "semver-pep440-release-branch"),
    ],
)
def test_deprecated_scheme_names_warn(old_name: str, new_name: str) -> None:
    with pytest.warns(
        DeprecationWarning,
        match=rf"'{re.escape(old_name)}' has been renamed to '{re.escape(new_name)}'",
    ):
        format_version(
            meta(
                "1.0.0",
                distance=2,
                branch="default",
                config=replace(
                    c, version_scheme=old_name, local_scheme="no-local-version"
                ),
            )
        )


@pytest.mark.parametrize(
    ("old_name", "new_name", "tag", "branch", "expected"),
    [
        pytest.param(
            "python-simplified-semver",
            "semver-pep440",
            "1.0.0",
            "default",
            "1.0.1.dev2",
            id="simplified-default-branch",
        ),
        pytest.param(
            "python-simplified-semver",
            "semver-pep440",
            "1.0.0",
            "feature/fun",
            "1.1.0.dev2",
            id="simplified-feature-branch",
        ),
        pytest.param(
            "release-branch-semver",
            "semver-pep440-release-branch",
            "1.0.0",
            "master",
            "1.1.0.dev2",
            id="release-branch-dev",
        ),
        pytest.param(
            "release-branch-semver",
            "semver-pep440-release-branch",
            "1.0.0",
            "release-1.0",
            "1.0.1.dev2",
            id="release-branch-maintenance",
        ),
    ],
)
def test_deprecated_scheme_names_still_work(
    old_name: str, new_name: str, tag: str, branch: str, expected: str
) -> None:
    base = replace(c, local_scheme="no-local-version")
    version_old = meta(
        tag, distance=2, branch=branch, config=replace(base, version_scheme=old_name)
    )
    version_new = meta(
        tag, distance=2, branch=branch, config=replace(base, version_scheme=new_name)
    )
    with pytest.warns(DeprecationWarning):
        result_old = format_version(version_old)
    result_new = format_version(version_new)
    assert result_old == expected
    assert result_old == result_new


def test_all_entrypoints_return_none() -> None:
    version = meta(
        "1.0",
        config=replace(
            c,
            version_scheme=lambda v: None,
        ),
    )
    with pytest.raises(
        ValueError,
        match=(
            'None of the "setuptools_scm.version_scheme" entrypoints matching'
            r" .*? returned a value."
        ),
    ):
        format_version(version)
