import gc
import os
import textwrap
import time
import unittest

from test.support import (
    Py_GIL_DISABLED,
    import_helper,
    requires_gil_enabled,
    requires_remote_subprocess_debugging,
)
from test.test_profiling.test_sampling_profiler.helpers import test_subprocess

try:
    import _remote_debugging  # noqa: F401
except ImportError:
    raise unittest.SkipTest(
        "Test only runs when _remote_debugging is available"
    )


GC_STATS_FIELDS = (
    "gen", "iid", "ts_start", "ts_stop", "collections", "collected",
    "uncollectable", "candidates", "heap_size", "duration")


def get_interpreter_identifiers(gc_stats) -> tuple[int,...]:
    return tuple(sorted({s.iid for s in gc_stats}))


def get_generations(gc_stats) -> tuple[int,int,int]:
    generations = set()
    for s in gc_stats:
        generations.add(s.gen)

    return tuple(sorted(generations))


def get_last_item(gc_stats, generation: int, iid: int):
    item = None
    for s in gc_stats:
        if s.gen == generation and s.iid == iid:
            if item is None or item.ts_start < s.ts_start:
                item = s

    return item


def has_local_process_debugging():
    try:
        return _remote_debugging.is_python_process(os.getpid())
    except Exception:
        return False


def check_gc_stats_fields(testcase, stats):
    testcase.assertIsInstance(stats, list)
    testcase.assertGreater(len(stats), 0)
    for item in stats:
        testcase.assertIsInstance(item, _remote_debugging.GCStatsInfo)
        testcase.assertEqual(type(item).__match_args__, GC_STATS_FIELDS)
        testcase.assertEqual(len(item), len(GC_STATS_FIELDS))


def gc_stats_counters_advanced(before_stats, after_stats, generations, iid):
    for generation in generations:
        before = get_last_item(before_stats, generation, iid)
        after = get_last_item(after_stats, generation, iid)
        if after is None or before is None:
            return False
        if after.duration <= before.duration:
            return False
        if after.candidates <= before.candidates:
            return False
    return True


@unittest.skipUnless(
    has_local_process_debugging(), "requires local process debugging")
class TestLocalGCStats(unittest.TestCase):

    _main_iid = 0  # main interpreter ID

    def test_gc_stats_fields(self):
        monitor = _remote_debugging.GCMonitor(os.getpid(), debug=True)
        stats = monitor.get_gc_stats(all_interpreters=False)
        check_gc_stats_fields(self, stats)

    def test_module_get_gc_stats_fields(self):
        stats = _remote_debugging.get_gc_stats(
            os.getpid(), all_interpreters=False)
        check_gc_stats_fields(self, stats)

    def test_all_interpreters_filter_for_local_process(self):
        interpreters = import_helper.import_module("concurrent.interpreters")
        source = """
            import gc
            objects = []
            obj = []
            obj.append(obj)
            objects.append(obj)
            gc.collect(0)
            gc.collect(1)
            gc.collect(2)
        """
        interp = interpreters.create()
        try:
            interp.exec(textwrap.dedent(source))
            for generation in range(3):
                gc.collect(generation)

            main_stats = _remote_debugging.get_gc_stats(
                os.getpid(), all_interpreters=False)
            all_stats = _remote_debugging.get_gc_stats(
                os.getpid(), all_interpreters=True)
        finally:
            interp.close()

        self.assertEqual(get_interpreter_identifiers(main_stats), (0,))
        self.assertIn(0, get_interpreter_identifiers(all_stats))
        self.assertGreater(len(get_interpreter_identifiers(all_stats)), 1)
        self.assertEqual(get_generations(main_stats), (0, 1, 2))
        self.assertEqual(get_generations(all_stats), (0, 1, 2))
        for iid in get_interpreter_identifiers(all_stats):
            for generation in range(3):
                self.assertIsNotNone(get_last_item(all_stats, generation, iid))

    @unittest.skipUnless(Py_GIL_DISABLED, "requires free-threaded GC")
    def test_gc_stats_counters_for_main_interpreter_free_threaded(self):
        generations = (0, 1, 2)
        before_stats = _remote_debugging.get_gc_stats(
            os.getpid(), all_interpreters=False)
        for generation in generations:
            self.assertIsNotNone(
                get_last_item(before_stats, generation, self._main_iid))

        objects = []
        for _ in range(1000):
            obj = []
            obj.append(obj)
            objects.append(obj)
        for generation in generations:
            gc.collect(generation)

        after_stats = _remote_debugging.get_gc_stats(
            os.getpid(), all_interpreters=False)
        self.assertTrue(
            gc_stats_counters_advanced(
                before_stats, after_stats, generations, self._main_iid),
            (before_stats, after_stats)
        )


@requires_remote_subprocess_debugging()
class TestGCStats(unittest.TestCase):

    @classmethod
    def setUpClass(cls):
        cls._main_iid = 0 # main interpreter ID
        cls._main_interpreter_script = '''
            import gc
            import time

            gc.collect(0)
            gc.collect(1)
            gc.collect(2)

            _test_sock.sendall(b"working")
            objects = []
            while True:
                if len(objects) > 100:
                    objects = []

                obj = []
                obj.append(obj)
                objects.append(obj)

                time.sleep(0.1)
                gc.collect(0)
                gc.collect(1)
                gc.collect(2)
            '''
        cls._script = '''
            import concurrent.interpreters as interpreters
            import gc
            import time

            source = """if True:
                import gc

                if "objects" not in globals():
                    objects = []
                if len(objects) > 100:
                    objects = []

                obj = []
                obj.append(obj)
                objects.append(obj)

                gc.collect(0)
                gc.collect(1)
                gc.collect(2)
            """

            if {0}:
                interp = interpreters.create()
                interp.exec(source)

            gc.collect(0)
            gc.collect(1)
            gc.collect(2)

            _test_sock.sendall(b"working")
            objects = []
            while True:
                if len(objects) > 100:
                    objects = []

                obj = []
                obj.append(obj)
                objects.append(obj)

                time.sleep(0.1)
                if {0}:
                    interp.exec(source)
                gc.collect(0)
                gc.collect(1)
                gc.collect(2)
            '''

    def _gc_stats_advanced(self, before_stats, after_stats, generations):
        for generation in generations:
            before = get_last_item(before_stats, generation, self._main_iid)
            after = get_last_item(after_stats, generation, self._main_iid)
            if after is None or before is None:
                return False
            if after.ts_stop <= before.ts_stop:
                return False
        return True

    def _collect_gc_stats(self, script: str, all_interpreters: bool,
                          generations=(2,)):
        with (test_subprocess(script, wait_for_working=True) as subproc):
            monitor = _remote_debugging.GCMonitor(subproc.process.pid, debug=True)
            before_stats = monitor.get_gc_stats(all_interpreters=all_interpreters)
            for generation in generations:
                before = get_last_item(before_stats, generation, self._main_iid)
                self.assertIsNotNone(before)

            after_stats = before_stats
            for _ in range(10):
                time.sleep(0.5)
                after_stats = monitor.get_gc_stats(all_interpreters=all_interpreters)
                if self._gc_stats_advanced(before_stats, after_stats, generations):
                    break
            else:
                self.fail(
                    f"GC stats for generations {generations!r} did not "
                    f"advance: {before_stats!r} -> {after_stats!r}"
                )

        return before_stats, after_stats

    def _check_gc_stats(self, before, after):
        self.assertIsNotNone(before)
        self.assertIsNotNone(after)

        self.assertGreater(after.collections, before.collections, (before, after))
        self.assertGreater(after.ts_start, before.ts_start, (before, after))
        self.assertGreater(after.ts_stop, before.ts_stop, (before, after))
        self.assertGreater(after.duration, before.duration, (before, after))

        self.assertGreater(after.candidates, before.candidates, (before, after))

        # may not grow
        self.assertGreaterEqual(after.collected, before.collected, (before, after))
        self.assertGreaterEqual(after.uncollectable, before.uncollectable, (before, after))

    def _check_interpreter_gc_stats(self, before_stats, after_stats):
        before_iids = get_interpreter_identifiers(before_stats)
        after_iids = get_interpreter_identifiers(after_stats)

        self.assertEqual(before_iids, after_iids)

        self.assertEqual(get_generations(before_stats), (0, 1, 2))
        self.assertEqual(get_generations(after_stats), (0, 1, 2))

        for iid in after_iids:
            with self.subTest(f"interpreter id={iid}"):
                before_last_items = (get_last_item(before_stats, 0, iid),
                                     get_last_item(before_stats, 1, iid),
                                     get_last_item(before_stats, 2, iid))

                after_last_items = (get_last_item(after_stats, 0, iid),
                                    get_last_item(after_stats, 1, iid),
                                    get_last_item(after_stats, 2, iid))

                for before, after in zip(before_last_items, after_last_items):
                    self._check_gc_stats(before, after)

    def test_gc_stats_timestamps_for_main_interpreter(self):
        script = textwrap.dedent(self._main_interpreter_script)
        before_stats, after_stats = self._collect_gc_stats(
            script, False, generations=(0, 1, 2))

        for generation in range(3):
            with self.subTest(generation=generation):
                before = get_last_item(before_stats, generation, self._main_iid)
                after = get_last_item(after_stats, generation, self._main_iid)

                self.assertIsNotNone(before)
                self.assertIsNotNone(after)
                self.assertGreater(
                    after.collections, before.collections,
                    (before, after))
                self.assertGreater(
                    after.ts_start, before.ts_start,
                    (before, after))
                self.assertGreater(
                    after.ts_stop, before.ts_stop,
                    (before, after))

    @requires_gil_enabled()
    def test_gc_stats_for_main_interpreter(self):
        script = textwrap.dedent(self._script.format(False))
        before_stats, after_stats = self._collect_gc_stats(script, False)

        self._check_interpreter_gc_stats(before_stats, after_stats)

    @requires_gil_enabled()
    def test_gc_stats_for_main_interpreter_if_subinterpreter_exists(self):
        script = textwrap.dedent(self._script.format(True))
        before_stats, after_stats = self._collect_gc_stats(script, False)

        self._check_interpreter_gc_stats(before_stats, after_stats)

    @requires_gil_enabled()
    def test_gc_stats_for_all_interpreters(self):
        script = textwrap.dedent(self._script.format(True))
        before_stats, after_stats = self._collect_gc_stats(script, True)

        before_iids = get_interpreter_identifiers(before_stats)
        after_iids = get_interpreter_identifiers(after_stats)

        self.assertGreater(len(before_iids), 1)
        self.assertGreater(len(after_iids), 1)
        self.assertEqual(before_iids, after_iids)

        self._check_interpreter_gc_stats(before_stats, after_stats)
