from __future__ import annotations

import contextlib
import os
import pickle
import sys
import textwrap

import pytest

import env
import pybind11_tests

if env.IOS:
    pytest.skip("Subinterpreters not supported on iOS", allow_module_level=True)

# 3.14.0b3+, though sys.implementation.supports_isolated_interpreters is being added in b4
# Can be simplified when we drop support for the first three betas
CONCURRENT_INTERPRETERS_SUPPORT = (
    sys.version_info >= (3, 14)
    and (
        sys.version_info != (3, 14, 0, "beta", 1)
        and sys.version_info != (3, 14, 0, "beta", 2)
    )
    and (
        sys.version_info == (3, 14, 0, "beta", 3)
        or sys.implementation.supports_isolated_interpreters
    )
)


def get_interpreters(*, modern: bool):
    if modern and CONCURRENT_INTERPRETERS_SUPPORT:
        from concurrent import interpreters

        def create():
            return contextlib.closing(interpreters.create())

        def run_string(
            interp: interpreters.Interpreter,
            code: str,
            *,
            shared: dict[str, object] | None = None,
        ) -> Exception | None:
            if shared:
                interp.prepare_main(**shared)
            try:
                interp.exec(code)
                return None
            except interpreters.ExecutionFailed as err:
                return err

        return run_string, create

    if sys.version_info >= (3, 12):
        interpreters = pytest.importorskip(
            "_interpreters" if sys.version_info >= (3, 13) else "_xxsubinterpreters"
        )

        @contextlib.contextmanager
        def create(config: str = ""):
            try:
                if config:
                    interp = interpreters.create(config)
                else:
                    interp = interpreters.create()
            except TypeError:
                pytest.skip(f"interpreters module needs to support {config} config")

            try:
                yield interp
            finally:
                interpreters.destroy(interp)

        def run_string(
            interp: int, code: str, shared: dict[str, object] | None = None
        ) -> Exception | None:
            kwargs = {"shared": shared} if shared else {}
            return interpreters.run_string(interp, code, **kwargs)

        return run_string, create

    pytest.skip("Test requires the interpreters stdlib module")


@pytest.mark.skipif(
    sys.platform.startswith("emscripten"), reason="Requires loadable modules"
)
def test_independent_subinterpreters():
    """Makes sure the internals object differs across independent subinterpreters"""

    sys.path.insert(0, os.path.dirname(pybind11_tests.__file__))

    run_string, create = get_interpreters(modern=True)

    import mod_per_interpreter_gil as m

    if not m.defined_PYBIND11_HAS_SUBINTERPRETER_SUPPORT:
        pytest.skip("Does not have subinterpreter support compiled in")

    code = textwrap.dedent(
        """
        import mod_per_interpreter_gil as m
        import pickle
        with open(pipeo, 'wb') as f:
            pickle.dump(m.internals_at(), f)
        """
    ).strip()

    with create() as interp1, create() as interp2:
        try:
            res0 = run_string(interp1, "import mod_shared_interpreter_gil")
            if res0 is not None:
                res0 = str(res0)
        except Exception as e:
            res0 = str(e)

        pipei, pipeo = os.pipe()
        run_string(interp1, code, shared={"pipeo": pipeo})
        with open(pipei, "rb") as f:
            res1 = pickle.load(f)

        pipei, pipeo = os.pipe()
        run_string(interp2, code, shared={"pipeo": pipeo})
        with open(pipei, "rb") as f:
            res2 = pickle.load(f)

    assert "does not support loading in subinterpreters" in res0, (
        "cannot use shared_gil in a default subinterpreter"
    )
    assert res1 != m.internals_at(), "internals should differ from main interpreter"
    assert res2 != m.internals_at(), "internals should differ from main interpreter"
    assert res1 != res2, "internals should differ between interpreters"


@pytest.mark.skipif(
    sys.platform.startswith("emscripten"), reason="Requires loadable modules"
)
@pytest.mark.skipif(not CONCURRENT_INTERPRETERS_SUPPORT, reason="Requires 3.14.0b3+")
def test_independent_subinterpreters_modern():
    """Makes sure the internals object differs across independent subinterpreters. Modern (3.14+) syntax."""

    sys.path.insert(0, os.path.dirname(pybind11_tests.__file__))

    import mod_per_interpreter_gil as m

    if not m.defined_PYBIND11_HAS_SUBINTERPRETER_SUPPORT:
        pytest.skip("Does not have subinterpreter support compiled in")

    from concurrent import interpreters

    code = textwrap.dedent(
        """
        import mod_per_interpreter_gil as m

        values.put_nowait(m.internals_at())
        """
    ).strip()

    with contextlib.closing(interpreters.create()) as interp1, contextlib.closing(
        interpreters.create()
    ) as interp2:
        with pytest.raises(
            interpreters.ExecutionFailed,
            match="does not support loading in subinterpreters",
        ):
            interp1.exec("import mod_shared_interpreter_gil")

        values = interpreters.create_queue()
        interp1.prepare_main(values=values)
        interp1.exec(code)
        res1 = values.get_nowait()

        interp2.prepare_main(values=values)
        interp2.exec(code)
        res2 = values.get_nowait()

    assert res1 != m.internals_at(), "internals should differ from main interpreter"
    assert res2 != m.internals_at(), "internals should differ from main interpreter"
    assert res1 != res2, "internals should differ between interpreters"


@pytest.mark.skipif(
    sys.platform.startswith("emscripten"), reason="Requires loadable modules"
)
def test_dependent_subinterpreters():
    """Makes sure the internals object differs across subinterpreters"""

    sys.path.insert(0, os.path.dirname(pybind11_tests.__file__))

    run_string, create = get_interpreters(modern=False)

    import mod_shared_interpreter_gil as m

    if not m.defined_PYBIND11_HAS_SUBINTERPRETER_SUPPORT:
        pytest.skip("Does not have subinterpreter support compiled in")

    code = textwrap.dedent(
        """
        import mod_shared_interpreter_gil as m
        import pickle
        with open(pipeo, 'wb') as f:
            pickle.dump(m.internals_at(), f)
        """
    ).strip()

    with create("legacy") as interp1:
        pipei, pipeo = os.pipe()
        run_string(interp1, code, shared={"pipeo": pipeo})
        with open(pipei, "rb") as f:
            res1 = pickle.load(f)

    assert res1 != m.internals_at(), "internals should differ from main interpreter"


PREAMBLE_CODE = textwrap.dedent(
    f"""
    def test():
        import sys

        sys.path.insert(0, {os.path.dirname(env.__file__)!r})
        sys.path.insert(0, {os.path.dirname(pybind11_tests.__file__)!r})

        import collections
        import mod_per_interpreter_gil_with_singleton as m

        objects = m.get_objects_in_singleton()
        expected = [
            type(None),               # static type: shared between interpreters
            tuple,                    # static type: shared between interpreters
            list,                     # static type: shared between interpreters
            dict,                     # static type: shared between interpreters
            collections.OrderedDict,  # static type: shared between interpreters
            collections.defaultdict,  # heap type: dynamically created per interpreter
            collections.deque,        # heap type: dynamically created per interpreter
        ]
        # Check that we have the expected objects. Avoid IndexError by checking lengths first.
        assert len(objects) == len(expected), (
            f"Expected {{expected!r}} ({{len(expected)}}), got {{objects!r}} ({{len(objects)}})."
        )
        # The first ones are static types shared between interpreters.
        assert objects[:-2] == expected[:-2], (
            f"Expected static objects {{expected[:-2]!r}}, got {{objects[:-2]!r}}."
        )
        # The last two are heap types created per-interpreter.
        # The expected objects are dynamically imported from `collections`.
        assert objects[-2:] == expected[-2:], (
            f"Expected heap objects {{expected[-2:]!r}}, got {{objects[-2:]!r}}."
        )

        assert hasattr(m, 'MyClass'), "Module missing MyClass"
        assert hasattr(m, 'MyGlobalError'), "Module missing MyGlobalError"
        assert hasattr(m, 'MyLocalError'), "Module missing MyLocalError"
        assert hasattr(m, 'MyEnum'), "Module missing MyEnum"
    """
).lstrip()


@pytest.mark.skipif(
    sys.platform.startswith("emscripten"), reason="Requires loadable modules"
)
@pytest.mark.skipif(not CONCURRENT_INTERPRETERS_SUPPORT, reason="Requires 3.14.0b3+")
def test_import_module_with_singleton_per_interpreter():
    """Tests that a singleton storing Python objects works correctly per-interpreter"""
    from concurrent import interpreters

    code = f"{PREAMBLE_CODE.strip()}\n\ntest()\n"
    with contextlib.closing(interpreters.create()) as interp:
        interp.exec(code)


@pytest.mark.skipif(
    sys.platform.startswith("emscripten"), reason="Requires loadable modules"
)
@pytest.mark.skipif(not CONCURRENT_INTERPRETERS_SUPPORT, reason="Requires 3.14.0b3+")
def test_import_in_subinterpreter_after_main():
    """Tests that importing a module in a subinterpreter after the main interpreter works correctly"""
    env.check_script_success_in_subprocess(
        PREAMBLE_CODE
        + textwrap.dedent(
            """
            import contextlib
            import gc
            from concurrent import interpreters

            test()

            interp = None
            with contextlib.closing(interpreters.create()) as interp:
                interp.call(test)

            del interp
            for _ in range(5):
                gc.collect()
            """
        )
    )

    env.check_script_success_in_subprocess(
        PREAMBLE_CODE
        + textwrap.dedent(
            """
            import contextlib
            import gc
            import random
            from concurrent import interpreters

            test()

            interps = interp = None
            with contextlib.ExitStack() as stack:
                interps = [
                    stack.enter_context(contextlib.closing(interpreters.create()))
                    for _ in range(8)
                ]
                random.shuffle(interps)
                for interp in interps:
                    interp.call(test)

            del interps, interp, stack
            for _ in range(5):
                gc.collect()
            """
        )
    )


@pytest.mark.skipif(
    sys.platform.startswith("emscripten"), reason="Requires loadable modules"
)
@pytest.mark.skipif(not CONCURRENT_INTERPRETERS_SUPPORT, reason="Requires 3.14.0b3+")
def test_import_in_subinterpreter_before_main():
    """Tests that importing a module in a subinterpreter before the main interpreter works correctly"""
    env.check_script_success_in_subprocess(
        PREAMBLE_CODE
        + textwrap.dedent(
            """
            import contextlib
            import gc
            from concurrent import interpreters

            interp = None
            with contextlib.closing(interpreters.create()) as interp:
                interp.call(test)

            test()

            del interp
            for _ in range(5):
                gc.collect()
            """
        )
    )

    env.check_script_success_in_subprocess(
        PREAMBLE_CODE
        + textwrap.dedent(
            """
            import contextlib
            import gc
            from concurrent import interpreters

            interps = interp = None
            with contextlib.ExitStack() as stack:
                interps = [
                    stack.enter_context(contextlib.closing(interpreters.create()))
                    for _ in range(8)
                ]
                for interp in interps:
                    interp.call(test)

            test()

            del interps, interp, stack
            for _ in range(5):
                gc.collect()
            """
        )
    )

    env.check_script_success_in_subprocess(
        PREAMBLE_CODE
        + textwrap.dedent(
            """
            import contextlib
            import gc
            from concurrent import interpreters

            interps = interp = None
            with contextlib.ExitStack() as stack:
                interps = [
                    stack.enter_context(contextlib.closing(interpreters.create()))
                    for _ in range(8)
                ]
                for interp in interps:
                    interp.call(test)

                test()

            del interps, interp, stack
            for _ in range(5):
                gc.collect()
            """
        )
    )


@pytest.mark.skipif(
    sys.platform.startswith("emscripten"), reason="Requires loadable modules"
)
@pytest.mark.xfail(
    env.MUSLLINUX,
    reason="Flaky on musllinux, see also: https://github.com/pybind/pybind11/pull/5972#discussion_r2755283335",
    strict=False,
)
@pytest.mark.skipif(not CONCURRENT_INTERPRETERS_SUPPORT, reason="Requires 3.14.0b3+")
def test_import_in_subinterpreter_concurrently():
    """Tests that importing a module in multiple subinterpreters concurrently works correctly"""
    env.check_script_success_in_subprocess(
        PREAMBLE_CODE
        + textwrap.dedent(
            """
            import gc
            from concurrent.futures import InterpreterPoolExecutor, as_completed

            futures = future = None
            with InterpreterPoolExecutor(max_workers=16) as executor:
                futures = [executor.submit(test) for _ in range(32)]
                for future in as_completed(futures):
                    future.result()
            del futures, future, executor

            for _ in range(5):
                gc.collect()
            """
        )
    )
