import textwrap
from pathlib import Path, PurePath
from unittest.mock import Mock, call

import pytest

from cibuildwheel import errors
from cibuildwheel.ci import fix_ansi_codes_for_github_actions
from cibuildwheel.util.file import copy_test_sources
from cibuildwheel.util.helpers import (
    FlexibleVersion,
    format_safe,
    parse_key_value_string,
    prepare_command,
    unwrap,
    unwrap_preserving_paragraphs,
)
from cibuildwheel.util.packaging import find_compatible_wheel


def test_format_safe() -> None:
    assert format_safe("{wheel}", wheel="filename.whl") == "filename.whl"
    assert format_safe("command #{wheel}", wheel="filename.whl") == "command {wheel}"
    assert format_safe("{command #{wheel}}", wheel="filename.whl") == "{command {wheel}}"

    # check unmatched brackets
    assert format_safe("{command {wheel}", wheel="filename.whl") == "{command filename.whl"

    # check positional-style arguments i.e. {}
    assert (
        format_safe("find . -name  * -exec ls -a {} \\;", project="/project")
        == "find . -name  * -exec ls -a {} \\;"
    )

    assert format_safe("{param} {param}", param="1") == "1 1"
    assert format_safe("# {param} {param}", param="1") == "# 1 1"
    assert format_safe("#{not_a_param} {param}", param="1") == "#{not_a_param} 1"


def test_prepare_command() -> None:
    assert prepare_command("python -m {project}", project="project") == "python -m project"
    assert prepare_command("python -m {something}", project="project") == "python -m {something}"
    assert (
        prepare_command("python -m {something.abc}", project="project")
        == "python -m {something.abc}"
    )

    assert (
        prepare_command("python -m {something.abc[4]:3f}", project="project")
        == "python -m {something.abc[4]:3f}"
    )

    # test backslashes in the replacement
    assert (
        prepare_command(
            "command {wheel} \\Users\\Temp\\output_dir", wheel="\\Temporary Files\\cibw"
        )
        == "command \\Temporary Files\\cibw \\Users\\Temp\\output_dir"
    )

    # test some unusual syntax that used to trip up the str.format approach
    assert (
        prepare_command("{a}{a,b}{b:.2e}{c}{d%s}{e:3}{f[0]}", a="42", b="3.14159")
        == "42{a,b}{b:.2e}{c}{d%s}{e:3}{f[0]}"
    )


@pytest.mark.parametrize(
    ("wheel", "identifier"),
    [
        ("foo-0.1-cp38-abi3-win_amd64.whl", "cp310-win_amd64"),
        ("foo-0.1-cp38-abi3-macosx_11_0_x86_64.whl", "cp310-macosx_x86_64"),
        ("foo-0.1-cp38-abi3-manylinux2014_x86_64.whl", "cp310-manylinux_x86_64"),
        ("foo-0.1-cp38-abi3-musllinux_1_1_x86_64.whl", "cp310-musllinux_x86_64"),
        ("foo-0.1-py2.py3-none-win_amd64.whl", "cp310-win_amd64"),
        ("foo-0.1-py2.py3-none-win_amd64.whl", "pp310-win_amd64"),
        ("foo-0.1-py3-none-win_amd64.whl", "cp310-win_amd64"),
        ("foo-0.1-py38-none-win_amd64.whl", "cp310-win_amd64"),
        ("foo-0.1-py38-none-win_amd64.whl", "pp310-win_amd64"),
    ],
)
def test_find_compatible_wheel_found(wheel: str, identifier: str) -> None:
    wheel_ = PurePath(wheel)
    found = find_compatible_wheel([wheel_], identifier)
    assert found is wheel_


@pytest.mark.parametrize(
    ("wheel", "identifier"),
    [
        ("foo-0.1-cp38-abi3-win_amd64.whl", "cp310-win32"),
        ("foo-0.1-cp38-abi3-win_amd64.whl", "cp37-win_amd64"),
        ("foo-0.1-cp38-abi3-macosx_11_0_x86_64.whl", "cp310-macosx_universal2"),
        ("foo-0.1-cp38-abi3-manylinux2014_x86_64.whl", "cp310-musllinux_x86_64"),
        ("foo-0.1-cp38-abi3-musllinux_1_1_x86_64.whl", "cp310-manylinux_x86_64"),
        ("foo-0.1-py2-none-win_amd64.whl", "cp310-win_amd64"),
        ("foo-0.1-py38-none-win_amd64.whl", "cp37-win_amd64"),
        ("foo-0.1-py38-none-win_amd64.whl", "pp37-win_amd64"),
        ("foo-0.1-cp38-cp38-win_amd64.whl", "cp310-win_amd64"),
    ],
)
def test_find_compatible_wheel_not_found(wheel: str, identifier: str) -> None:
    assert find_compatible_wheel([PurePath(wheel)], identifier) is None


def test_fix_ansi_codes_for_github_actions() -> None:
    input = textwrap.dedent(
        """
        This line is normal
        \033[1mThis line is bold
        This line is also bold
        \033[31m this line is red and bold
        This line is red and bold, too\033[0m
        This line is normal again
        """
    )

    expected = textwrap.dedent(
        """
        This line is normal
        \033[1mThis line is bold
        \033[1mThis line is also bold
        \033[1m\033[31m this line is red and bold
        \033[1m\033[31mThis line is red and bold, too\033[0m
        This line is normal again
        """
    )

    output = fix_ansi_codes_for_github_actions(input)

    assert output == expected


def test_parse_key_value_string() -> None:
    assert parse_key_value_string("bar", positional_arg_names=["foo"]) == {"foo": ["bar"]}
    assert parse_key_value_string("foo:bar", kw_arg_names=["foo"]) == {"foo": ["bar"]}
    with pytest.raises(ValueError, match="Too many positional arguments"):
        parse_key_value_string("bar")
    with pytest.raises(ValueError, match="Unknown field name"):
        parse_key_value_string("foo:bar")
    assert parse_key_value_string("foo:bar", kw_arg_names=["foo"]) == {"foo": ["bar"]}
    assert parse_key_value_string("foo:bar", positional_arg_names=["foo"]) == {"foo": ["bar"]}
    assert parse_key_value_string("foo: bar", kw_arg_names=["foo"]) == {"foo": ["bar"]}
    assert parse_key_value_string("foo: bar", kw_arg_names=["foo"]) == {"foo": ["bar"]}
    assert parse_key_value_string("foo: bar; baz: qux", kw_arg_names=["foo", "baz"]) == {
        "foo": ["bar"],
        "baz": ["qux"],
    }

    # some common options
    assert parse_key_value_string(
        "docker; create_args: --some-option --another-option=foo",
        positional_arg_names=["name"],
        kw_arg_names=["create_args"],
    ) == {
        "name": ["docker"],
        "create_args": ["--some-option", "--another-option=foo"],
    }
    # semicolon in value
    assert parse_key_value_string(
        "docker; create_args: --some-option='this; that'",
        positional_arg_names=["name"],
        kw_arg_names=["create_args"],
    ) == {
        "name": ["docker"],
        "create_args": ["--some-option=this; that"],
    }
    # colon in value
    assert parse_key_value_string(
        "docker; create_args: --mount a:b",
        positional_arg_names=["name"],
        kw_arg_names=["create_args"],
    ) == {
        "name": ["docker"],
        "create_args": ["--mount", "a:b"],
    }
    assert parse_key_value_string(
        "docker;create_args:--mount a:b",
        positional_arg_names=["name"],
        kw_arg_names=["create_args"],
    ) == {
        "name": ["docker"],
        "create_args": ["--mount", "a:b"],
    }
    # quoted value with spaces
    assert parse_key_value_string(
        "docker;create_args:'some string with spaces'",
        positional_arg_names=["name"],
        kw_arg_names=["create_args"],
    ) == {
        "name": ["docker"],
        "create_args": ["some string with spaces"],
    }

    # colon in positional value
    assert parse_key_value_string(
        "docker; --mount a:b",
        positional_arg_names=["name", "create_args"],
    ) == {
        "name": ["docker"],
        "create_args": ["--mount", "a:b"],
    }

    # empty option gives empty array
    assert parse_key_value_string(
        "docker;create_args:",
        positional_arg_names=["name"],
        kw_arg_names=["create_args"],
    ) == {
        "name": ["docker"],
        "create_args": [],
    }


def test_flexible_version_comparisons() -> None:
    assert FlexibleVersion("2.0") == FlexibleVersion("2")
    assert FlexibleVersion("2.0") < FlexibleVersion("2.1")
    assert FlexibleVersion("2.1") > FlexibleVersion("2")
    assert FlexibleVersion("1.9.9") < FlexibleVersion("2.0")
    assert FlexibleVersion("1.10") > FlexibleVersion("1.9.9")
    assert FlexibleVersion("3.0.1") > FlexibleVersion("3.0")
    assert FlexibleVersion("3.0") < FlexibleVersion("3.0.1")
    # Suffix should not affect comparisons
    assert FlexibleVersion("1.0.1-rhel") > FlexibleVersion("1.0")
    assert FlexibleVersion("1.0.1-rhel") < FlexibleVersion("1.1")
    assert FlexibleVersion("1.0.1") == FlexibleVersion("v1.0.1")


@pytest.fixture
def sample_project(tmp_path: Path) -> Path:
    """Create a directory structure that contains a range of files."""
    project_path = tmp_path / "project"

    (project_path / "src/deep").mkdir(parents=True)
    (project_path / "tests/deep").mkdir(parents=True)
    (project_path / "other").mkdir(parents=True)

    (project_path / "pyproject.toml").write_text("A pyproject.toml file")
    (project_path / "test.cfg").write_text("A test config file")

    (project_path / "src/__init__.py").write_text("source init")
    (project_path / "src/module.py").write_text("source module")
    (project_path / "src/deep/__init__.py").write_text("deep source init")

    (project_path / "tests/test_module.py").write_text("test module")
    (project_path / "tests/deep/test_module.py").write_text("deep test module")
    (project_path / "tests/deep/__init__.py").write_text("deep test init")

    (project_path / "other/module.py").write_text("other module")

    return project_path


@pytest.mark.parametrize(
    ("test_sources", "expected", "not_expected"),
    [
        # Empty test_sources copies nothing.
        pytest.param(
            [],
            [],
            [
                "pyproject.toml",
                "test.cfg",
                "other/module.py",
                "src/__init__.py",
                "src/module.py",
                "src/deep/__init__.py",
                "tests/test_module.py",
                "tests/deep/__init__.py",
                "tests/deep/test_module.py",
            ],
            id="empty",
        ),
        # Single standalone files
        pytest.param(
            ["pyproject.toml", "tests/deep/test_module.py"],
            ["pyproject.toml", "tests/deep/test_module.py"],
            [
                "test.cfg",
                "other/module.py",
                "src/__init__.py",
                "src/module.py",
                "src/deep/__init__.py",
                "tests/test_module.py",
                "tests/deep/__init__.py",
            ],
            id="single-file",
        ),
        # A full Directory
        pytest.param(
            ["tests"],
            [
                "tests/test_module.py",
                "tests/deep/__init__.py",
                "tests/deep/test_module.py",
            ],
            [
                "pyproject.toml",
                "test.cfg",
                "other/module.py",
                "src/__init__.py",
                "src/module.py",
                "src/deep/__init__.py",
            ],
            id="top-level-directory",
        ),
        # A partial deep directory
        pytest.param(
            ["tests/deep"],
            [
                "tests/deep/__init__.py",
                "tests/deep/test_module.py",
            ],
            [
                "pyproject.toml",
                "test.cfg",
                "other/module.py",
                "src/__init__.py",
                "src/module.py",
                "src/deep/__init__.py",
                "tests/test_module.py",
            ],
            id="partial-directory",
        ),
    ],
)
def test_copy_test_sources(
    tmp_path: Path,
    sample_project: Path,
    test_sources: list[str],
    expected: list[str],
    not_expected: list[str],
) -> None:
    """Test sources can be copied into the test directory."""
    target = tmp_path / "somewhere/test_cwd"
    copy_test_sources(test_sources, sample_project, target)

    for path in expected:
        assert (tmp_path / "somewhere/test_cwd" / path).is_file()

    for path in not_expected:
        assert not (tmp_path / "somewhere/test_cwd" / path).exists()


def test_copy_test_sources_missing_file(tmp_path: Path, sample_project: Path) -> None:
    """If test_sources references a folder that doesn't exist, an error is raised."""

    with pytest.raises(
        errors.FatalError,
        match=r"Test source tests/does_not_exist.py does not exist.",
    ):
        copy_test_sources(
            ["pyproject.toml", "tests/does_not_exist.py"],
            sample_project,
            tmp_path / "somewhere/test_cwd",
        )


def test_copy_test_sources_alternate_copy_into(sample_project: Path) -> None:
    """If an alternate copy_into method is provided, it is used."""

    target = PurePath("/container/test_cwd")
    copy_into = Mock()

    copy_test_sources(["pyproject.toml", "tests"], sample_project, target, copy_into=copy_into)

    copy_into.assert_has_calls(
        [
            call(sample_project / "pyproject.toml", target / "pyproject.toml"),
            call(sample_project / "tests", target / "tests"),
        ],
        any_order=True,
    )


def test_unwrap() -> None:
    assert (
        unwrap("""
            This is a
            multiline
            string
        """)
        == "This is a multiline string"
    )


def test_unwrap_preserving_paragraphs() -> None:
    assert (
        unwrap("""
            This is a
            multiline
            string
        """)
        == "This is a multiline string"
    )
    assert (
        unwrap_preserving_paragraphs("""
            paragraph one

            paragraph two
        """)
        == "paragraph one\n\nparagraph two"
    )
