"""
Helpers/utils for working with tornado asynchronous stuff
"""

import contextlib
import logging
import sys
import threading
import warnings

import salt.ext.tornado.concurrent
import salt.ext.tornado.ioloop

# ``asyncio`` is imported lazily inside ``SyncWrapper`` so importing this
# module does not pull in the asyncio chain on legacy Python targets (e.g.
# the salt-ssh py3.6/3.7 thin tarball, where ``contextvars`` -> ``immutables``
# -> ``typing_extensions`` is not shipped).  See issue #65702.

log = logging.getLogger(__name__)


@contextlib.contextmanager
def current_ioloop(io_loop):
    """
    A context manager that will set the current ioloop to io_loop for the context
    """
    orig_loop = salt.ext.tornado.ioloop.IOLoop.current()
    io_loop.make_current()
    try:
        yield
    finally:
        orig_loop.make_current()


class SyncWrapper:
    """
    A wrapper to make Async classes synchronous

    This is uses as a simple wrapper, for example:

    asynchronous = AsyncClass()
    # this method would regularly return a future
    future = asynchronous.async_method()

    sync = SyncWrapper(async_factory_method, (arg1, arg2), {'kwarg1': 'val'})
    # the sync wrapper will automatically wait on the future
    ret = sync.async_method()
    """

    def __init__(
        self,
        cls,
        args=None,
        kwargs=None,
        async_methods=None,
        close_methods=None,
        loop_kwarg=None,
    ):
        # Imported lazily so this module loads cleanly on the salt-ssh
        # py3.6/3.7 thin tarball (which does not ship ``typing_extensions``
        # and therefore can't import ``asyncio`` -> ``contextvars`` ->
        # ``immutables``).  See issue #65702.
        import asyncio

        self.io_loop = salt.ext.tornado.ioloop.IOLoop(make_current=False)
        # Create a dedicated asyncio event loop and install it on the
        # worker thread before running coroutines.  Without this,
        # libraries that call ``asyncio.get_event_loop()`` (notably pyzmq's
        # ``zmq.eventloop.future`` sockets used by master-initiated job
        # publishes) raise ``RuntimeError: There is no current event loop``
        # on Python 3.12+, because Py3.12 removed the implicit
        # auto-creation of an event loop in non-main threads.
        # See issue #65702.
        self.asyncio_loop = asyncio.new_event_loop()
        if args is None:
            args = []
        if kwargs is None:
            kwargs = {}
        if async_methods is None:
            async_methods = []
        if close_methods is None:
            close_methods = []
        self.loop_kwarg = loop_kwarg
        self.cls = cls
        if loop_kwarg:
            kwargs[self.loop_kwarg] = self.io_loop
        with current_ioloop(self.io_loop):
            self.obj = cls(*args, **kwargs)
        self._async_methods = list(
            set(async_methods + getattr(self.obj, "async_methods", []))
        )
        self._close_methods = list(
            set(close_methods + getattr(self.obj, "close_methods", []))
        )

    def _populate_async_methods(self):
        """
        We need the '_coroutines' attribute on classes until we can depricate
        tornado<4.5. After that 'is_coroutine_fuction' will always be
        available.
        """
        if hasattr(self.obj, "_coroutines"):
            self._async_methods += self.obj._coroutines

    def __repr__(self):
        return f"<SyncWrapper(cls={self.cls})"

    def close(self):
        for method in self._close_methods:
            if method in self._async_methods:
                method = self._wrap(method)
            else:
                try:
                    method = getattr(self.obj, method)
                except AttributeError:
                    log.error("No sync method %s on object %r", method, self.obj)
                    continue
            try:
                method()
            except AttributeError:
                log.error("No async method %s on object %r", method, self.obj)
            except Exception:  # pylint: disable=broad-except
                log.exception("Exception encountered while running stop method")
        io_loop = self.io_loop
        io_loop.stop()
        io_loop.close(all_fds=True)
        # Close the asyncio loop created for this wrapper.  Suppress
        # exceptions so close() remains best-effort even if the loop was
        # never used or was already closed by a wrapped coroutine.
        try:
            if not self.asyncio_loop.is_closed():
                self.asyncio_loop.close()
        except Exception:  # pylint: disable=broad-except
            log.debug("Exception while closing SyncWrapper asyncio loop", exc_info=True)

    def __getattr__(self, key):
        if key in self._async_methods:
            return self._wrap(key)
        return getattr(self.obj, key)

    def _wrap(self, key):
        def wrap(*args, **kwargs):
            results = []
            thread = threading.Thread(
                target=self._target,
                args=(key, args, kwargs, results, self.io_loop),
            )
            thread.start()
            thread.join()
            if results[0]:
                return results[1]
            else:
                exc_info = results[1]
                raise exc_info[1].with_traceback(exc_info[2])

        return wrap

    def _target(self, key, args, kwargs, results, io_loop):
        # Imported lazily, see ``__init__`` for rationale (#65702).
        import asyncio

        # Install the SyncWrapper's dedicated asyncio event loop on this
        # worker thread so that code paths which call
        # ``asyncio.get_event_loop()`` (notably pyzmq's
        # ``zmq.eventloop.future`` sockets) succeed on Python 3.12+,
        # where the implicit per-thread loop creation was removed.
        # See issue #65702.
        asyncio.set_event_loop(self.asyncio_loop)
        try:
            result = io_loop.run_sync(lambda: getattr(self.obj, key)(*args, **kwargs))
            results.append(True)
            results.append(result)
        except Exception:  # pylint: disable=broad-except
            results.append(False)
            results.append(sys.exc_info())

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_val, tb):
        self.close()

    # pylint: disable=W1701
    def __del__(self):
        # PATCH: mirror ``SaltEvent.__del__`` at ``salt/utils/event.py``
        # -- deliberately do NOT close the wrapped ``obj`` / io_loop /
        # asyncio_loop from ``__del__``.  ``__del__`` fires during GC
        # (may be arbitrarily delayed, may skip on reference cycles)
        # and during interpreter shutdown, when the world is already
        # tearing down and touching a tornado/asyncio loop can raise
        # from a partially-freed C extension.  Instead, emit a
        # ``ResourceWarning`` so callers that missed ``close()`` /
        # context-manager surface loudly in tests / sentry / log
        # aggregators.
        #
        # Motivation: ``SyncWrapper``-owned asyncio loops are the
        # dominant leak surface on the minion under sustained
        # ``saltutil.refresh_pillar`` / re-auth churn -- each abandoned
        # wrapper holds a whole IOLoop, its ZMQ context, and the two
        # socketpairs backing the master REQ channel.  Observed ~451
        # leaked socketpairs (~902 fds) per minion, tripping the
        # 1024-file ulimit critical threshold and the minion's own
        # sock-throttle logic.
        try:
            unclosed = getattr(self, "obj", None) is not None or (
                getattr(self, "asyncio_loop", None) is not None
                and not self.asyncio_loop.is_closed()
            )
        except Exception:  # pylint: disable=broad-except
            return
        if not unclosed:
            return
        try:
            warnings.warn(
                f"unclosed {type(self).__name__} for cls="
                f"{getattr(self, 'cls', None)!r}; call ``close()`` or "
                f"use as a context manager",
                ResourceWarning,
                source=self,
            )
        except Exception:  # pylint: disable=broad-except
            # ``warnings.warn`` can raise during interpreter shutdown
            # when the ``warnings`` module has already been torn down.
            # A finalizer must not propagate exceptions.
            pass

    # pylint: enable=W1701
