# mypy: allow-untyped-defs
from __future__ import annotations

from collections.abc import Generator
from collections.abc import Sequence
import os
from pathlib import Path
import textwrap
from typing import cast

from _pytest.config import ExitCode
from _pytest.config import PytestPluginManager
from _pytest.monkeypatch import MonkeyPatch
from _pytest.pathlib import symlink_or_skip
from _pytest.pytester import Pytester
from _pytest.tmpdir import TempPathFactory
import pytest


def ConftestWithSetinitial(path) -> PytestPluginManager:
    conftest = PytestPluginManager()
    conftest_setinitial(conftest, [path])
    return conftest


def conftest_setinitial(
    conftest: PytestPluginManager,
    args: Sequence[str | Path],
    confcutdir: Path | None = None,
) -> None:
    conftest._set_initial_conftests(
        args=args,
        pyargs=False,
        noconftest=False,
        rootpath=Path(args[0]),
        confcutdir=confcutdir,
        invocation_dir=Path.cwd(),
        importmode="prepend",
        consider_namespace_packages=False,
    )


@pytest.mark.usefixtures("_sys_snapshot")
class TestConftestValueAccessGlobal:
    @pytest.fixture(scope="module", params=["global", "inpackage"])
    def basedir(self, request, tmp_path_factory: TempPathFactory) -> Generator[Path]:
        tmp_path = tmp_path_factory.mktemp("basedir", numbered=True)
        tmp_path.joinpath("adir/b").mkdir(parents=True)
        tmp_path.joinpath("adir/conftest.py").write_text(
            "a=1 ; Directory = 3", encoding="utf-8"
        )
        tmp_path.joinpath("adir/b/conftest.py").write_text(
            "b=2 ; a = 1.5", encoding="utf-8"
        )
        if request.param == "inpackage":
            tmp_path.joinpath("adir/__init__.py").touch()
            tmp_path.joinpath("adir/b/__init__.py").touch()

        yield tmp_path

    def test_basic_init(self, basedir: Path) -> None:
        conftest = PytestPluginManager()
        p = basedir / "adir"
        conftest._loadconftestmodules(
            p, importmode="prepend", rootpath=basedir, consider_namespace_packages=False
        )
        assert conftest._rget_with_confmod("a", p)[1] == 1

    def test_immediate_initialization_and_incremental_are_the_same(
        self, basedir: Path
    ) -> None:
        conftest = PytestPluginManager()
        assert not len(conftest._dirpath2confmods)
        conftest._loadconftestmodules(
            basedir,
            importmode="prepend",
            rootpath=basedir,
            consider_namespace_packages=False,
        )
        snap1 = len(conftest._dirpath2confmods)
        assert snap1 == 1
        conftest._loadconftestmodules(
            basedir / "adir",
            importmode="prepend",
            rootpath=basedir,
            consider_namespace_packages=False,
        )
        assert len(conftest._dirpath2confmods) == snap1 + 1
        conftest._loadconftestmodules(
            basedir / "b",
            importmode="prepend",
            rootpath=basedir,
            consider_namespace_packages=False,
        )
        assert len(conftest._dirpath2confmods) == snap1 + 2

    def test_value_access_not_existing(self, basedir: Path) -> None:
        conftest = ConftestWithSetinitial(basedir)
        with pytest.raises(KeyError):
            conftest._rget_with_confmod("a", basedir)

    def test_value_access_by_path(self, basedir: Path) -> None:
        conftest = ConftestWithSetinitial(basedir)
        adir = basedir / "adir"
        conftest._loadconftestmodules(
            adir,
            importmode="prepend",
            rootpath=basedir,
            consider_namespace_packages=False,
        )
        assert conftest._rget_with_confmod("a", adir)[1] == 1
        conftest._loadconftestmodules(
            adir / "b",
            importmode="prepend",
            rootpath=basedir,
            consider_namespace_packages=False,
        )
        assert conftest._rget_with_confmod("a", adir / "b")[1] == 1.5

    def test_value_access_with_confmod(self, basedir: Path) -> None:
        startdir = basedir / "adir" / "b"
        startdir.joinpath("xx").mkdir()
        conftest = ConftestWithSetinitial(startdir)
        mod, value = conftest._rget_with_confmod("a", startdir)
        assert value == 1.5
        assert mod.__file__ is not None
        path = Path(mod.__file__)
        assert path.parent == basedir / "adir" / "b"
        assert path.stem == "conftest"


def test_conftest_in_nonpkg_with_init(tmp_path: Path, _sys_snapshot) -> None:
    tmp_path.joinpath("adir-1.0/b").mkdir(parents=True)
    tmp_path.joinpath("adir-1.0/conftest.py").write_text(
        "a=1 ; Directory = 3", encoding="utf-8"
    )
    tmp_path.joinpath("adir-1.0/b/conftest.py").write_text(
        "b=2 ; a = 1.5", encoding="utf-8"
    )
    tmp_path.joinpath("adir-1.0/b/__init__.py").touch()
    tmp_path.joinpath("adir-1.0/__init__.py").touch()
    ConftestWithSetinitial(tmp_path.joinpath("adir-1.0", "b"))


def test_doubledash_considered(pytester: Pytester) -> None:
    conf = pytester.mkdir("--option")
    conf.joinpath("conftest.py").touch()
    conftest = PytestPluginManager()
    conftest_setinitial(conftest, [conf.name, conf.name])
    values = conftest._getconftestmodules(conf)
    assert len(values) == 1


def test_issue151_load_all_conftests(pytester: Pytester) -> None:
    names = "code proj src".split()
    for name in names:
        p = pytester.mkdir(name)
        p.joinpath("conftest.py").touch()

    pm = PytestPluginManager()
    conftest_setinitial(pm, names)
    assert len(set(pm.get_plugins()) - {pm}) == len(names)


def test_conftest_global_import(pytester: Pytester) -> None:
    pytester.makeconftest("x=3")
    p = pytester.makepyfile(
        """
        from pathlib import Path
        import pytest
        from _pytest.config import PytestPluginManager
        conf = PytestPluginManager()
        mod = conf._importconftest(
            Path("conftest.py"),
            importmode="prepend",
            rootpath=Path.cwd(),
            consider_namespace_packages=False,
        )
        assert mod.x == 3
        import conftest
        assert conftest is mod, (conftest, mod)
        sub = Path("sub")
        sub.mkdir()
        subconf = sub / "conftest.py"
        subconf.write_text("y=4", encoding="utf-8")
        mod2 = conf._importconftest(
            subconf,
            importmode="prepend",
            rootpath=Path.cwd(),
            consider_namespace_packages=False,
        )
        assert mod != mod2
        assert mod2.y == 4
        import conftest
        assert conftest is mod2, (conftest, mod)
    """
    )
    res = pytester.runpython(p)
    assert res.ret == 0


def test_conftestcutdir(pytester: Pytester) -> None:
    conf = pytester.makeconftest("")
    p = pytester.mkdir("x")
    conftest = PytestPluginManager()
    conftest_setinitial(conftest, [pytester.path], confcutdir=p)
    conftest._loadconftestmodules(
        p,
        importmode="prepend",
        rootpath=pytester.path,
        consider_namespace_packages=False,
    )
    values = conftest._getconftestmodules(p)
    assert len(values) == 0
    conftest._loadconftestmodules(
        conf.parent,
        importmode="prepend",
        rootpath=pytester.path,
        consider_namespace_packages=False,
    )
    values = conftest._getconftestmodules(conf.parent)
    assert len(values) == 0
    assert not conftest.has_plugin(str(conf))
    # but we can still import a conftest directly
    conftest._importconftest(
        conf,
        importmode="prepend",
        rootpath=pytester.path,
        consider_namespace_packages=False,
    )
    values = conftest._getconftestmodules(conf.parent)
    assert values[0].__file__ is not None
    assert values[0].__file__.startswith(str(conf))
    # and all sub paths get updated properly
    values = conftest._getconftestmodules(p)
    assert len(values) == 1
    assert values[0].__file__ is not None
    assert values[0].__file__.startswith(str(conf))


def test_conftestcutdir_inplace_considered(pytester: Pytester) -> None:
    conf = pytester.makeconftest("")
    conftest = PytestPluginManager()
    conftest_setinitial(conftest, [conf.parent], confcutdir=conf.parent)
    values = conftest._getconftestmodules(conf.parent)
    assert len(values) == 1
    assert values[0].__file__ is not None
    assert values[0].__file__.startswith(str(conf))


@pytest.mark.parametrize("name", "test tests whatever .dotdir".split())
def test_setinitial_conftest_subdirs(pytester: Pytester, name: str) -> None:
    sub = pytester.mkdir(name)
    subconftest = sub.joinpath("conftest.py")
    subconftest.touch()
    pm = PytestPluginManager()
    conftest_setinitial(pm, [sub.parent], confcutdir=pytester.path)
    key = subconftest.resolve()
    if name not in ("whatever", ".dotdir"):
        assert pm.has_plugin(str(key))
        assert len(set(pm.get_plugins()) - {pm}) == 1
    else:
        assert not pm.has_plugin(str(key))
        assert len(set(pm.get_plugins()) - {pm}) == 0


def test_conftest_confcutdir(pytester: Pytester) -> None:
    pytester.makeconftest("assert 0")
    x = pytester.mkdir("x")
    x.joinpath("conftest.py").write_text(
        textwrap.dedent(
            """\
            def pytest_addoption(parser):
                parser.addoption("--xyz", action="store_true")
            """
        ),
        encoding="utf-8",
    )
    result = pytester.runpytest("-h", f"--confcutdir={x}", x)
    result.stdout.fnmatch_lines(["*--xyz*"])
    result.stdout.no_fnmatch_line("*warning: could not load initial*")


def test_installed_conftest_is_picked_up(pytester: Pytester, tmp_path: Path) -> None:
    """When using `--pyargs` to run tests in an installed packages (located e.g.
    in a site-packages in the PYTHONPATH), conftest files in there are picked
    up.

    Regression test for #9767.
    """
    # pytester dir - the source tree.
    # tmp_path - the simulated site-packages dir (not in source tree).

    pytester.syspathinsert(tmp_path)
    pytester.makepyprojecttoml("[tool.pytest.ini_options]")
    tmp_path.joinpath("foo").mkdir()
    tmp_path.joinpath("foo", "__init__.py").touch()
    tmp_path.joinpath("foo", "conftest.py").write_text(
        textwrap.dedent(
            """\
            import pytest
            @pytest.fixture
            def fix(): return None
            """
        ),
        encoding="utf-8",
    )
    tmp_path.joinpath("foo", "test_it.py").write_text(
        "def test_it(fix): pass", encoding="utf-8"
    )
    result = pytester.runpytest("--pyargs", "foo")
    assert result.ret == 0


def test_conftest_symlink(pytester: Pytester) -> None:
    """`conftest.py` discovery follows normal path resolution and does not resolve symlinks."""
    # Structure:
    # /real
    # /real/conftest.py
    # /real/app
    # /real/app/tests
    # /real/app/tests/test_foo.py

    # Links:
    # /symlinktests -> /real/app/tests (running at symlinktests should fail)
    # /symlink -> /real (running at /symlink should work)

    real = pytester.mkdir("real")
    realtests = real.joinpath("app/tests")
    realtests.mkdir(parents=True)
    symlink_or_skip(realtests, pytester.path.joinpath("symlinktests"))
    symlink_or_skip(real, pytester.path.joinpath("symlink"))
    pytester.makepyfile(
        **{
            "real/app/tests/test_foo.py": "def test1(fixture): pass",
            "real/conftest.py": textwrap.dedent(
                """
                import pytest

                print("conftest_loaded")

                @pytest.fixture
                def fixture():
                    print("fixture_used")
                """
            ),
        }
    )

    # Should fail because conftest cannot be found from the link structure.
    result = pytester.runpytest("-vs", "symlinktests")
    result.stdout.fnmatch_lines(["*fixture 'fixture' not found*"])
    assert result.ret == ExitCode.TESTS_FAILED

    # Should not cause "ValueError: Plugin already registered" (#4174).
    result = pytester.runpytest("-vs", "symlink")
    assert result.ret == ExitCode.OK


def test_conftest_symlink_files(pytester: Pytester) -> None:
    """Symlinked conftest.py are found when pytest is executed in a directory with symlinked
    files."""
    real = pytester.mkdir("real")
    source = {
        "app/test_foo.py": "def test1(fixture): pass",
        "app/__init__.py": "",
        "app/conftest.py": textwrap.dedent(
            """
            import pytest

            print("conftest_loaded")

            @pytest.fixture
            def fixture():
                print("fixture_used")
            """
        ),
    }
    pytester.makepyfile(**{f"real/{k}": v for k, v in source.items()})

    # Create a build directory that contains symlinks to actual files
    # but doesn't symlink actual directories.
    build = pytester.mkdir("build")
    build.joinpath("app").mkdir()
    for f in source:
        symlink_or_skip(real.joinpath(f), build.joinpath(f))
    os.chdir(build)
    result = pytester.runpytest("-vs", "app/test_foo.py")
    result.stdout.fnmatch_lines(["*conftest_loaded*", "PASSED"])
    assert result.ret == ExitCode.OK


@pytest.mark.skipif(
    os.path.normcase("x") != os.path.normcase("X"),
    reason="only relevant for case-insensitive file systems",
)
def test_conftest_badcase(pytester: Pytester) -> None:
    """Check conftest.py loading when directory casing is wrong (#5792)."""
    pytester.path.joinpath("JenkinsRoot/test").mkdir(parents=True)
    source = {"setup.py": "", "test/__init__.py": "", "test/conftest.py": ""}
    pytester.makepyfile(**{f"JenkinsRoot/{k}": v for k, v in source.items()})

    os.chdir(pytester.path.joinpath("jenkinsroot/test"))
    result = pytester.runpytest()
    assert result.ret == ExitCode.NO_TESTS_COLLECTED


def test_conftest_uppercase(pytester: Pytester) -> None:
    """Check conftest.py whose qualified name contains uppercase characters (#5819)"""
    source = {"__init__.py": "", "Foo/conftest.py": "", "Foo/__init__.py": ""}
    pytester.makepyfile(**source)

    os.chdir(pytester.path)
    result = pytester.runpytest()
    assert result.ret == ExitCode.NO_TESTS_COLLECTED


def test_no_conftest(pytester: Pytester) -> None:
    pytester.makeconftest("assert 0")
    result = pytester.runpytest("--noconftest")
    assert result.ret == ExitCode.NO_TESTS_COLLECTED

    result = pytester.runpytest()
    assert result.ret == ExitCode.USAGE_ERROR


def test_conftest_existing_junitxml(pytester: Pytester) -> None:
    x = pytester.mkdir("tests")
    x.joinpath("conftest.py").write_text(
        textwrap.dedent(
            """\
            def pytest_addoption(parser):
                parser.addoption("--xyz", action="store_true")
            """
        ),
        encoding="utf-8",
    )
    pytester.makefile(ext=".xml", junit="")  # Writes junit.xml
    result = pytester.runpytest("-h", "--junitxml", "junit.xml")
    result.stdout.fnmatch_lines(["*--xyz*"])


def test_conftest_import_order(pytester: Pytester, monkeypatch: MonkeyPatch) -> None:
    ct1 = pytester.makeconftest("")
    sub = pytester.mkdir("sub")
    ct2 = sub / "conftest.py"
    ct2.write_text("", encoding="utf-8")

    def impct(p, importmode, root, consider_namespace_packages):
        return p

    conftest = PytestPluginManager()
    conftest._confcutdir = pytester.path
    monkeypatch.setattr(conftest, "_importconftest", impct)
    conftest._loadconftestmodules(
        sub,
        importmode="prepend",
        rootpath=pytester.path,
        consider_namespace_packages=False,
    )
    mods = cast(list[Path], conftest._getconftestmodules(sub))
    expected = [ct1, ct2]
    assert mods == expected


def test_fixture_dependency(pytester: Pytester) -> None:
    pytester.makeconftest("")
    pytester.path.joinpath("__init__.py").touch()
    sub = pytester.mkdir("sub")
    sub.joinpath("__init__.py").touch()
    sub.joinpath("conftest.py").write_text(
        textwrap.dedent(
            """\
            import pytest

            @pytest.fixture
            def not_needed():
                assert False, "Should not be called!"

            @pytest.fixture
            def foo():
                assert False, "Should not be called!"

            @pytest.fixture
            def bar(foo):
                return 'bar'
            """
        ),
        encoding="utf-8",
    )
    subsub = sub.joinpath("subsub")
    subsub.mkdir()
    subsub.joinpath("__init__.py").touch()
    subsub.joinpath("test_bar.py").write_text(
        textwrap.dedent(
            """\
            import pytest

            @pytest.fixture
            def bar():
                return 'sub bar'

            def test_event_fixture(bar):
                assert bar == 'sub bar'
            """
        ),
        encoding="utf-8",
    )
    result = pytester.runpytest("sub")
    result.stdout.fnmatch_lines(["*1 passed*"])


def test_conftest_found_with_double_dash(pytester: Pytester) -> None:
    sub = pytester.mkdir("sub")
    sub.joinpath("conftest.py").write_text(
        textwrap.dedent(
            """\
            def pytest_addoption(parser):
                parser.addoption("--hello-world", action="store_true")
            """
        ),
        encoding="utf-8",
    )
    p = sub.joinpath("test_hello.py")
    p.write_text("def test_hello(): pass", encoding="utf-8")
    result = pytester.runpytest(str(p) + "::test_hello", "-h")
    result.stdout.fnmatch_lines(
        """
        *--hello-world*
    """
    )


class TestConftestVisibility:
    def _setup_tree(self, pytester: Pytester) -> dict[str, Path]:  # for issue616
        # example mostly taken from:
        # https://mail.python.org/pipermail/pytest-dev/2014-September/002617.html
        runner = pytester.mkdir("empty")
        package = pytester.mkdir("package")

        package.joinpath("conftest.py").write_text(
            textwrap.dedent(
                """\
                import pytest
                @pytest.fixture
                def fxtr():
                    return "from-package"
                """
            ),
            encoding="utf-8",
        )
        package.joinpath("test_pkgroot.py").write_text(
            textwrap.dedent(
                """\
                def test_pkgroot(fxtr):
                    assert fxtr == "from-package"
                """
            ),
            encoding="utf-8",
        )

        swc = package.joinpath("swc")
        swc.mkdir()
        swc.joinpath("__init__.py").touch()
        swc.joinpath("conftest.py").write_text(
            textwrap.dedent(
                """\
                import pytest
                @pytest.fixture
                def fxtr():
                    return "from-swc"
                """
            ),
            encoding="utf-8",
        )
        swc.joinpath("test_with_conftest.py").write_text(
            textwrap.dedent(
                """\
                def test_with_conftest(fxtr):
                    assert fxtr == "from-swc"
                """
            ),
            encoding="utf-8",
        )

        snc = package.joinpath("snc")
        snc.mkdir()
        snc.joinpath("__init__.py").touch()
        snc.joinpath("test_no_conftest.py").write_text(
            textwrap.dedent(
                """\
                def test_no_conftest(fxtr):
                    assert fxtr == "from-package"   # No local conftest.py, so should
                                                    # use value from parent dir's
                """
            ),
            encoding="utf-8",
        )
        print("created directory structure:")
        for x in pytester.path.glob("**/"):
            print("   " + str(x.relative_to(pytester.path)))

        return {"runner": runner, "package": package, "swc": swc, "snc": snc}

    # N.B.: "swc" stands for "subdir with conftest.py"
    #       "snc" stands for "subdir no [i.e. without] conftest.py"
    @pytest.mark.parametrize(
        "chdir,testarg,expect_ntests_passed",
        [
            # Effective target: package/..
            ("runner", "..", 3),
            ("package", "..", 3),
            ("swc", "../..", 3),
            ("snc", "../..", 3),
            # Effective target: package
            ("runner", "../package", 3),
            ("package", ".", 3),
            ("swc", "..", 3),
            ("snc", "..", 3),
            # Effective target: package/swc
            ("runner", "../package/swc", 1),
            ("package", "./swc", 1),
            ("swc", ".", 1),
            ("snc", "../swc", 1),
            # Effective target: package/snc
            ("runner", "../package/snc", 1),
            ("package", "./snc", 1),
            ("swc", "../snc", 1),
            ("snc", ".", 1),
        ],
    )
    def test_parsefactories_relative_node_ids(
        self, pytester: Pytester, chdir: str, testarg: str, expect_ntests_passed: int
    ) -> None:
        """#616"""
        dirs = self._setup_tree(pytester)
        print(f"pytest run in cwd: {dirs[chdir].relative_to(pytester.path)}")
        print(f"pytestarg        : {testarg}")
        print(f"expected pass    : {expect_ntests_passed}")
        os.chdir(dirs[chdir])
        reprec = pytester.inline_run(
            testarg,
            "-q",
            "--traceconfig",
            "--confcutdir",
            pytester.path,
        )
        reprec.assertoutcome(passed=expect_ntests_passed)


@pytest.mark.parametrize(
    "confcutdir,passed,error", [(".", 2, 0), ("src", 1, 1), (None, 1, 1)]
)
def test_search_conftest_up_to_inifile(
    pytester: Pytester, confcutdir: str, passed: int, error: int
) -> None:
    """Test that conftest files are detected only up to an ini file, unless
    an explicit --confcutdir option is given.
    """
    root = pytester.path
    src = root.joinpath("src")
    src.mkdir()
    src.joinpath("pytest.ini").write_text("[pytest]", encoding="utf-8")
    src.joinpath("conftest.py").write_text(
        textwrap.dedent(
            """\
            import pytest
            @pytest.fixture
            def fix1(): pass
            """
        ),
        encoding="utf-8",
    )
    src.joinpath("test_foo.py").write_text(
        textwrap.dedent(
            """\
            def test_1(fix1):
                pass
            def test_2(out_of_reach):
                pass
            """
        ),
        encoding="utf-8",
    )
    root.joinpath("conftest.py").write_text(
        textwrap.dedent(
            """\
            import pytest
            @pytest.fixture
            def out_of_reach(): pass
            """
        ),
        encoding="utf-8",
    )

    args = [str(src)]
    if confcutdir:
        args = [f"--confcutdir={root.joinpath(confcutdir)}"]
    result = pytester.runpytest(*args)
    match = ""
    if passed:
        match += f"*{passed} passed*"
    if error:
        match += f"*{error} error*"
    result.stdout.fnmatch_lines(match)


def test_issue1073_conftest_special_objects(pytester: Pytester) -> None:
    pytester.makeconftest(
        """\
        class DontTouchMe(object):
            def __getattr__(self, x):
                raise Exception('cant touch me')

        x = DontTouchMe()
        """
    )
    pytester.makepyfile(
        """\
        def test_some():
            pass
        """
    )
    res = pytester.runpytest()
    assert res.ret == 0


def test_conftest_exception_handling(pytester: Pytester) -> None:
    pytester.makeconftest(
        """\
        raise ValueError()
        """
    )
    pytester.makepyfile(
        """\
        def test_some():
            pass
        """
    )
    res = pytester.runpytest()
    assert res.ret == 4
    assert "raise ValueError()" in [line.strip() for line in res.errlines]


def test_hook_proxy(pytester: Pytester) -> None:
    """Session's gethookproxy() would cache conftests incorrectly (#2016).
    It was decided to remove the cache altogether.
    """
    pytester.makepyfile(
        **{
            "root/demo-0/test_foo1.py": "def test1(): pass",
            "root/demo-a/test_foo2.py": "def test1(): pass",
            "root/demo-a/conftest.py": """\
            def pytest_ignore_collect(collection_path, config):
                return True
            """,
            "root/demo-b/test_foo3.py": "def test1(): pass",
            "root/demo-c/test_foo4.py": "def test1(): pass",
        }
    )
    result = pytester.runpytest()
    result.stdout.fnmatch_lines(
        ["*test_foo1.py*", "*test_foo3.py*", "*test_foo4.py*", "*3 passed*"]
    )


def test_required_option_help(pytester: Pytester) -> None:
    pytester.makeconftest("assert 0")
    x = pytester.mkdir("x")
    x.joinpath("conftest.py").write_text(
        textwrap.dedent(
            """\
            def pytest_addoption(parser):
                parser.addoption("--xyz", action="store_true", required=True)
            """
        ),
        encoding="utf-8",
    )
    result = pytester.runpytest("-h", x)
    result.stdout.no_fnmatch_line("*argument --xyz is required*")
    assert "general:" in result.stdout.str()
