import os.path
from copy import deepcopy
from traitlets import Unicode, Integer, Dict, Bool
from traitlets.config import Configurable

from jupyterhub.spawner import _quote_safe

def _get_voila_template(args, spawner):

    voila_template = getattr(spawner, 'voila_template', '')

    if voila_template != '':
            args.append('='.join(('{--}template', voila_template)))

    return args

def _get_streamlit_debug(args, spawner):
    try:
        if spawner.debug:
            args.insert(args.index('streamlit')+1, '{--}log_level=debug')
    except ValueError:
        pass
    return args


class VariableMixin(Configurable):

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        # Merge extra_presentation_launchers config into a copy of builtin_presentation_launchers
        self.merged_presentation_launchers = deepcopy(self.builtin_presentation_launchers)
        for frameworkname, launcher in self.extra_presentation_launchers.items():
            if frameworkname not in self.merged_presentation_launchers:
                self.merged_presentation_launchers[frameworkname] = {}
            for k,v in launcher.items():
                self.merged_presentation_launchers[frameworkname][k] = v

    builtin_presentation_launchers = {
        'voila': {
            #'cmd': ['python3', '-m', 'jhsingle_native_proxy.main'],  # This is the default cmd anyway
            'args': ['--destport=0', 'python3', '{-}m','voila', '{presentation_path}',
                '{--}port={port}',
                '{--}no-browser',
                '{--}Voila.base_url={base_url}/',
                '{--}Voila.server_url=/'],
            'extra_args_fn': _get_voila_template
        },
        'streamlit': {
            'args': ['--destport=0', 'streamlit', 'run', '{presentation_path}', 
                '{--}server.port={port}',
                '{--}server.headless=True',
                '{--}server.enableCORS=False'],
            'debug_args': [], # The default is {--}debug, we don't want that
            'extra_args_fn': _get_streamlit_debug # But --log_level=debug has to come earlier in the cmdline
        },
        'plotlydash': {
            'args': ['--destport=0', 'python3', '{-}m','plotlydash_tornado_cmd.main', '{presentation_path}',
                '{--}port={port}'],
            'env': {'DASH_REQUESTS_PATHNAME_PREFIX': '{base_url}/'}
        },
        'bokeh': {
            'args': ['--destport=0', 'python3', '{-}m','bokeh_root_cmd.main', '{presentation_path}',
                '{--}port={port}',
                '{--}allow-websocket-origin={origin_host}',
                '--ready-check-path=/ready-check']
        },
        'rshiny': {
            'args': ['--destport=0', 'python3', '{-}m','rshiny_server_cmd.main', '{presentation_path}',
                '{--}port={port}']
        }

    }

    extra_presentation_launchers = Dict(
        {},
        help="""
        Configuration dict containing details of any custom frameworks that should be made available to Dashboard creators.
        Any new keys added here also need to be added to the c.CDSDashboardsConfig.presentation_types list.
        See cdsdashboards/hubextension/spawners/variablemixin.py in the https://github.com/ideonate/cdsdashboards source code
        for details of the builtin_presentation_launchers dict which shows some examples. This extra_presentation_launchers 
        config takes the same format.
        Any keys in extra_presentation_launchersthat also belong to builtin_presentation_launchers will be merged into the 
        builtin config, e.g. {'streamlit':{'env':{'STREAMLIT_ENV_VAR':'TEST'}}} will overwrite only the env section of the 
        builting streamlit launcher.
        """
    ).tag(config=True)

    voila_template = Unicode(
        'materialstream',
        help="""
        --template argument to pass to Voila. Default is materialstream. Specify blank (empty string) to not pass any template to Voila command line.
        """,
    ).tag(config=True)

    proxy_request_timeout = Integer(
        0,
        help="""
        Request timeout in seconds that jhsingle-native-proxy should allow when proxying to the underlying process (e.g. Voila).
        The default of 0 means that no --request-timeout flag will be passed to jhsingle-native-proxy so it will use its own default.
        """,
    ).tag(config=True)

    proxy_force_alive = Bool(
        True,
        help="""
        Whether or not jhsingle-native-proxy should fake activity on its subprocess, always reporting to the hub that activity has happened.
        The default of True means that no flag will be passed to jhsingle-native-proxy so it will use its own default (expected to be --force-alive).
        If False is specified, --no-force-alive will be passed to jhsingle-native-proxy.
        """,
    ).tag(config=True)

    proxy_last_activity_interval = Integer(
        300,
        help="""
        Frequency in seconds that jhsingle-native-proxy should send any recent activity timestamp to the hub.
        If the default of 300 is specified, no --last-activity-interval flag will be passed to jhsingle-native-proxy so it will use its default.
        Otherwise the specified value will be passed to --last-activity-interval.
        """,
    ).tag(config=True)

    async def start(self):
        """
        Copy trait values from user_options into the trait attrs of the spawner object
        """

        if self.user_options:
            trait_names = set(self.trait_names()) - {'user_options'}
            for k in trait_names.intersection(self.user_options.keys()):
                setattr(self, k, self.user_options[k])

        # Any update for cmd needs to be set here (args and env have their own overridden functions)
        presentation_type = self._get_presentation_type()
        if presentation_type != '':
            launcher = self.merged_presentation_launchers[presentation_type]
            if 'cmd' in launcher:
                self.cmd = launcher['cmd']

        return await super().start()

    def get_args(self):
        """Return the arguments to be passed after self.cmd

        Doesn't expect shell expansion to happen.

        Also adds self.args at the end in case specified by the config.
        """

        presentation_type = self._get_presentation_type()
        
        if presentation_type == '':
            return super().get_args()

        launcher = self.merged_presentation_launchers[presentation_type]

        presentation_path = self.user_options.get('presentation_path', '')

        args = []

        # jhsingle-native-proxy --destport $destport --authtype oauth voila `pwd` {--}port={port} {--}no-browser {--}Voila.base_url={base_url}/ {--}Voila.server_url=/ --port $port

        notebook_dir = '.'
        if self.notebook_dir:
            notebook_dir = self.format_string(self.notebook_dir)

        if presentation_path != '' and not '..' in presentation_path:
            # Should have been validated when dashboard created, but .. is particularly dangerous
            if presentation_path.startswith("/"):
                presentation_path = presentation_path[1:]
            notebook_dir = os.path.join(notebook_dir, presentation_path)

        if 'args' in launcher:
            args.extend(launcher['args'])

        args.append('--presentation-path={}'.format(_quote_safe(notebook_dir)))

        if self.ip:
            args.append('--ip=%s' % _quote_safe(self.ip))

        if self.port:
            args.append('--port=%i' % self.port)

        if self.debug:
            if 'debug_args' in launcher:
                args.extend(launcher['debug_args'])
            else:
                args.append('{--}debug')
            args.append('--debug') # For jhsingle-native-proxy itself

        proxy_request_timeout = getattr(self, 'proxy_request_timeout', 0)
        if proxy_request_timeout:
            args.append('--request-timeout={}'.format(proxy_request_timeout))

        proxy_force_alive = getattr(self, 'proxy_force_alive', True)
        if proxy_force_alive == False:
            args.append('--no-force-alive')

        proxy_last_activity_interval = getattr(self, 'proxy_last_activity_interval', 300)
        if proxy_last_activity_interval != 300:
            args.append('--last-activity-interval={}'.format(proxy_last_activity_interval))

        args.extend(self.args)

        if 'extra_args_fn' in launcher and callable(launcher['extra_args_fn']): # Last chance for launcher config to change everything and anything
            args = launcher['extra_args_fn'](args, self)

        return args

    def _get_presentation_type(self):
        """
        Returns the presentation_type (e.g. '' for standard spawner, 'voila', 'streamlit' for named presentation frameworks).
        Throws an exception if the presentation_type doesn't have a launcher configuration in either extra_presentation_launchers 
        or builtin_presentation_launchers.
        """
        if self.user_options and 'presentation_type' in self.user_options:
            presentation_type = self.user_options['presentation_type']
            if presentation_type not in self.merged_presentation_launchers:
                raise Exception('presentation type {} has not been registered with the spawner'.format(presentation_type))
            return presentation_type
        return ''

    def get_env(self):
        env = super().get_env()

        presentation_type = self._get_presentation_type()

        if presentation_type != '':

            launcher = self.merged_presentation_launchers[presentation_type]

            if 'env' in launcher:
                presentation_dirname = '.'
                presentation_path = ''
                if self.user_options and 'presentation_path' in self.user_options:
                    presentation_path = self.user_options['presentation_path']
                    presentation_dirname = os.path.dirname(presentation_path)

                self.log.info('presentation_dirname: {}'.format(presentation_dirname))

                for k,v in launcher['env'].items():
                    env[k] = v.format(
                        base_url=self.server.base_url,
                        presentation_dirname=presentation_dirname,
                        presentation_path=presentation_path,
                        username=self.user.name
                    )
        return env


class MetaVariableMixin(type(Configurable)):
    """
    Use this metaclass to ensure VariableMixin occurs earlier in the MRO, so all traits are accessible at the right time.
    """
    
    def mro(cls):
        mro = super().mro()
        # Take VariableMixin (normally item 4) and put it at item 1
        try:
            vm_index = mro.index(VariableMixin)
            if vm_index > 1 and vm_index < len(mro)-1:
                mro = [mro[0], mro[vm_index]]+ mro[1:vm_index] + mro[vm_index+1:]
        except ValueError:
            pass
        
        return mro
