import sys
import shutil
import asyncio

from functools import partial
from threading import Thread
from queue import Queue as SyncQueue
from packaging.version import Version

from ..io.notebook import push_on_root
from ..io.resources import DIST_DIR, LOCAL_DIST
from ..io.state import state
from ..models import IDOM as _BkIDOM
from .base import PaneBase


_IDOM_MIN_VER = "0.23"
_IDOM_MAX_VER = "0.24"


def _spawn_threaded_event_loop(coro):
    loop_q = SyncQueue()

    def run_in_thread():
        loop = asyncio.new_event_loop()
        asyncio.set_event_loop(loop)
        loop_q.put(loop)
        loop.run_until_complete(coro)

    thread = Thread(target=run_in_thread, daemon=True)
    thread.start()

    return loop_q.get()


class IDOM(PaneBase):
    """
    The `IDOM` pane renders any IDOM component both in the notebook and in a
    deployed server.
    
    IDOM defines an API for defining and controlling interactive HTML
    components directly from Python.
    
    Note that in the notebook the IDOM support for loading external modules
    relies on Panel’s Jupyter serverextension.
    
    Reference: https://panel.holoviz.org/reference/panes/IDOM.html

    :Example:

    >>> IDOM(ClickCount, width=300)
    """

    priority = None

    _updates = True

    _unpack = True

    _bokeh_model = _BkIDOM

    def __init__(self, object=None, **params):
        from idom import __version__ as idom_version
        if Version(_IDOM_MIN_VER) > Version(idom_version) >= Version(_IDOM_MAX_VER):
            raise RuntimeError(
                f"Expected idom>={_IDOM_MIN_VER},<{_IDOM_MAX_VER}, but found {idom_version}"
            )
        super().__init__(object, **params)
        self._idom_loop = None
        self._idom_model = {}
        self.param.watch(self._update_layout, 'object')

    def _update_layout(self, *args):
        self._idom_model = {}
        if self._idom_loop is None:
            return
        self._setup()

    def _setup(self):
        if self.object is None:
            return
        from idom.core.component import Component
        from idom.core.layout import Layout
        if isinstance(self.object, Layout):
            self._idom_layout = self.object
        elif isinstance(self.object, Component):
            self._idom_layout = Layout(self.object)
        else:
            self._idom_layout = Layout(self.object())
        self._idom_loop = _spawn_threaded_event_loop(self._idom_layout_render_loop())

    def _get_model(self, doc, root=None, parent=None, comm=None):
        from idom.core.layout import LayoutUpdate
        from idom.config import IDOM_CLIENT_IMPORT_SOURCE_URL

        # let the client determine import source location
        IDOM_CLIENT_IMPORT_SOURCE_URL.set("./")

        if comm:
            url = '/panel_dist/idom/build'
        else:
            url = '/'+LOCAL_DIST+'idom/build'

        if self._idom_loop is None:
            self._setup()

        update = LayoutUpdate.create_from({}, self._idom_model)
        props = self._init_params()
        model = self._bokeh_model(
            event=[update.path, update.changes], importSourceUrl=url, **props
        )
        if root is None:
            root = model
        self._link_props(model, ['msg'], doc, root, comm)

        if root is None:
            root = model
        self._models[root.ref['id']] = (model, parent)
        return model

    def _cleanup(self, root):
        super()._cleanup(root)
        if not self._models:
            # Clean up loop when no views are shown
            try:
                self._idom_loop.stop()
            finally:
                self._idom_loop = None
                self._idom_layout = None

    def _process_property_change(self, msg):
        if msg['msg'] is None:
            return {}
        from idom.core.layout import LayoutEvent
        dispatch = self._idom_layout.dispatch(LayoutEvent(**msg['msg']))
        asyncio.run_coroutine_threadsafe(dispatch, loop=self._idom_loop)
        for ref, (m, _) in self._models.items():
            m.msg = None
            push_on_root(ref)
        return {}

    async def _idom_layout_render_loop(self):
        async with self._idom_layout:
            while True:
                update = await self._idom_layout.render()
                self._idom_model = update.apply_to(self._idom_model)
                for ref, (model, _) in self._models.items():
                    doc = state._views[ref][2]
                    if doc.session_context:
                        doc.add_next_tick_callback(partial(model.update, event=update))
                    else:
                        model.event = update
                        push_on_root(ref)

    @classmethod
    def applies(self, object):
        if object is None:
            return None
        elif 'idom' in sys.modules:
            from idom.core.component import Component
            from idom.core.layout import Layout
            if isinstance(object, (Component, Layout)):
                return 0.8
            elif callable(object):
                return None
        return False

    @classmethod
    def install(cls, packages, ignore_installed=False, fallback=None):
        """
        Installs specified packages into application directory.

        Arguments
        ---------
        packages: list or tuple
          The packages to install from npm
        ignored_installed: boolean
          Whether to ignore if the package was previously installed.
        fallback: str or idom.component
          The fallback to display while the component is loading
        """
        import idom
        from idom.config import IDOM_CLIENT_BUILD_DIR
        idom_dist_dir = DIST_DIR / "idom"
        idom_build_dir = idom_dist_dir / "build"
        if not idom_build_dir.is_dir():
            idom_build_dir.mkdir()
            shutil.copyfile(idom_dist_dir / 'package.json', idom_build_dir / 'package.json')
        if IDOM_CLIENT_BUILD_DIR.get() != idom_build_dir:
            IDOM_CLIENT_BUILD_DIR.set(idom_build_dir)
            # just in case packages were already installed but the build hasn't been
            # copied over to DIST_DIR yet.
            ignore_installed = True
        return idom.install(packages, ignore_installed, fallback)

    @classmethod
    def use_param(cls, parameter):
        """
        Links parameter to some IDOM state value and returns the linked
        value.

        Arguments
        ---------
        parameter: param.Parameter
          The parameter to link to a idom state value.

        Returns
        -------
        An idom state value which is updated when the parameter changes.
        """
        import idom
        from ..depends import param_value_if_widget
        parameter = param_value_if_widget(parameter)
        initial = getattr(parameter.owner, parameter.name)
        value, set_value = idom.hooks.use_state(initial)
        def update(event):
            set_value(event.new)
        parameter.owner.param.watch(update, parameter.name)
        return value
