"""
Defines Layout classes which may be used to arrange panes and widgets
in flexible ways to build complex dashboards.
"""
from collections import defaultdict, namedtuple

import param

from bokeh.models import Column as BkColumn, Row as BkRow

from ..io.model import hold
from ..io.state import state
from ..reactive import Reactive
from ..util import param_name, param_reprs

_row = namedtuple("row", ["children"])
_col = namedtuple("col", ["children"])


class Panel(Reactive):
    """
    Abstract baseclass for a layout of Viewables.
    """

    # Used internally to optimize updates
    _batch_update = False

    # Bokeh model used to render this Panel
    _bokeh_model = None

    # Properties that should sync JS -> Python
    _linked_props = []

    # Parameters which require the preprocessors to be re-run
    _preprocess_params = []

    # Parameter -> Bokeh property renaming
    _rename = {'objects': 'children'}

    __abstract = True

    def __repr__(self, depth=0, max_depth=10):
        if depth > max_depth:
            return '...'
        spacer = '\n' + ('    ' * (depth+1))
        cls = type(self).__name__
        params = param_reprs(self, ['objects'])
        objs = ['[%d] %s' % (i, obj.__repr__(depth+1)) for i, obj in enumerate(self)]
        if not params and not objs:
            return super().__repr__(depth+1)
        elif not params:
            template = '{cls}{spacer}{objs}'
        elif not objs:
            template = '{cls}({params})'
        else:
            template = '{cls}({params}){spacer}{objs}'
        return template.format(
            cls=cls, params=', '.join(params),
            objs=('%s' % spacer).join(objs), spacer=spacer)

    #----------------------------------------------------------------
    # Callback API
    #----------------------------------------------------------------

    def _update_model(self, events, msg, root, model, doc, comm=None):
        msg = dict(msg)
        inverse = {v: k for k, v in self._rename.items() if v is not None}
        preprocess = any(inverse.get(k, k) in self._preprocess_params for k in msg)
        if self._rename['objects'] in msg:
            old = events['objects'].old
            msg[self._rename['objects']] = self._get_objects(model, old, doc, root, comm)

        with hold(doc):
            update = Panel._batch_update
            Panel._batch_update = True
            try:
                super()._update_model(events, msg, root, model, doc, comm)
                if update:
                    return
                from ..io import state
                ref = root.ref['id']
                if ref in state._views and preprocess:
                    state._views[ref][0]._preprocess(root)
            finally:
                Panel._batch_update = update

    #----------------------------------------------------------------
    # Model API
    #----------------------------------------------------------------

    def _get_objects(self, model, old_objects, doc, root, comm=None):
        """
        Returns new child models for the layout while reusing unchanged
        models and cleaning up any dropped objects.
        """
        from ..pane.base import panel, RerenderError
        new_models = []
        for i, pane in enumerate(self.objects):
            pane = panel(pane)
            self.objects[i] = pane

        for obj in old_objects:
            if obj not in self.objects:
                obj._cleanup(root)

        current_objects = list(self.objects)
        for i, pane in enumerate(self.objects):
            if pane in old_objects:
                child, _ = pane._models[root.ref['id']]
            else:
                try:
                    child = pane._get_model(doc, root, model, comm)
                except RerenderError:
                    return self._get_objects(model, current_objects[:i], doc, root, comm)
            new_models.append(child)
        return new_models

    def _get_model(self, doc, root=None, parent=None, comm=None):
        model = self._bokeh_model()
        if root is None:
            root = model
        objects = self._get_objects(model, [], doc, root, comm)
        props = dict(self._init_params(), objects=objects)
        model.update(**self._process_param_change(props))
        self._models[root.ref['id']] = (model, parent)
        self._link_props(model, self._linked_props, doc, root, comm)
        return model

    #----------------------------------------------------------------
    # Public API
    #----------------------------------------------------------------

    def select(self, selector=None):
        """
        Iterates over the Viewable and any potential children in the
        applying the Selector.

        Arguments
        ---------
        selector: type or callable or None
          The selector allows selecting a subset of Viewables by
          declaring a type or callable function to filter by.

        Returns
        -------
        viewables: list(Viewable)
        """
        objects = super().select(selector)
        for obj in self:
            objects += obj.select(selector)
        return objects


class ListLike(param.Parameterized):

    objects = param.List(default=[], doc="""
        The list of child objects that make up the layout.""")

    _preprocess_params = ['objects']

    def __getitem__(self, index):
        return self.objects[index]

    def __len__(self):
        return len(self.objects)

    def __iter__(self):
        for obj in self.objects:
            yield obj

    def __iadd__(self, other):
        self.extend(other)
        return self

    def __add__(self, other):
        if isinstance(other, ListLike):
            other = other.objects
        if not isinstance(other, list):
            stype = type(self).__name__
            otype = type(other).__name__
            raise ValueError("Cannot add items of type %s and %s, can only "
                             "combine %s.objects with list or ListLike object."
                             % (stype, otype, stype))
        return self.clone(*(self.objects+other))

    def __radd__(self, other):
        if isinstance(other, ListLike):
            other = other.objects
        if not isinstance(other, list):
            stype = type(self).__name__
            otype = type(other).__name__
            raise ValueError("Cannot add items of type %s and %s, can only "
                             "combine %s.objects with list or ListLike object."
                             % (otype, stype, stype))
        return self.clone(*(other+self.objects))

    def __contains__(self, obj):
        return obj in self.objects

    def __setitem__(self, index, panes):
        from ..pane import panel
        new_objects = list(self)
        if not isinstance(index, slice):
            start, end = index, index+1
            if start > len(self.objects):
                raise IndexError('Index %d out of bounds on %s '
                                 'containing %d objects.' %
                                 (end, type(self).__name__, len(self.objects)))
            panes = [panes]
        else:
            start = index.start or 0
            end = len(self) if index.stop is None else index.stop
            if index.start is None and index.stop is None:
                if not isinstance(panes, list):
                    raise IndexError('Expected a list of objects to '
                                     'replace the objects in the %s, '
                                     'got a %s type.' %
                                     (type(self).__name__, type(panes).__name__))
                expected = len(panes)
                new_objects = [None]*expected
                end = expected
            elif end > len(self.objects):
                raise IndexError('Index %d out of bounds on %s '
                                 'containing %d objects.' %
                                 (end, type(self).__name__, len(self.objects)))
            else:
                expected = end-start
            if not isinstance(panes, list) or len(panes) != expected:
                raise IndexError('Expected a list of %d objects to set '
                                 'on the %s to match the supplied slice.' %
                                 (expected, type(self).__name__))
        for i, pane in zip(range(start, end), panes):
            new_objects[i] = panel(pane)

        self.objects = new_objects

    def clone(self, *objects, **params):
        """
        Makes a copy of the layout sharing the same parameters.

        Arguments
        ---------
        objects: Objects to add to the cloned layout.
        params: Keyword arguments override the parameters on the clone.

        Returns
        -------
        Cloned layout object
        """
        if not objects:
            if 'objects' in params:
                objects = params.pop('objects')
            else:
                objects = self.objects
        elif 'objects' in params:
            raise ValueError("A %s's objects should be supplied either "
                             "as arguments or as a keyword, not both."
                             % type(self).__name__)
        p = dict(self.param.values(), **params)
        del p['objects']
        return type(self)(*objects, **p)

    def append(self, obj):
        """
        Appends an object to the layout.

        Arguments
        ---------
        obj (object): Panel component to add to the layout.
        """
        from ..pane import panel
        new_objects = list(self)
        new_objects.append(panel(obj))
        self.objects = new_objects

    def clear(self):
        """
        Clears the objects on this layout.
        """
        self.objects = []

    def extend(self, objects):
        """
        Extends the objects on this layout with a list.

        Arguments
        ---------
        objects (list): List of panel components to add to the layout.
        """
        from ..pane import panel
        new_objects = list(self)
        new_objects.extend(list(map(panel, objects)))
        self.objects = new_objects

    def insert(self, index, obj):
        """
        Inserts an object in the layout at the specified index.

        Arguments
        ---------
        index (int): Index at which to insert the object.
        object (object): Panel components to insert in the layout.
        """
        from ..pane import panel
        new_objects = list(self)
        new_objects.insert(index, panel(obj))
        self.objects = new_objects

    def pop(self, index):
        """
        Pops an item from the layout by index.

        Arguments
        ---------
        index (int): The index of the item to pop from the layout.
        """
        new_objects = list(self)
        if index in new_objects:
            index = new_objects.index(index)
        obj = new_objects.pop(index)
        self.objects = new_objects
        return obj

    def remove(self, obj):
        """
        Removes an object from the layout.

        Arguments
        ---------
        obj (object): The object to remove from the layout.
        """
        new_objects = list(self)
        new_objects.remove(obj)
        self.objects = new_objects

    def reverse(self):
        """
        Reverses the objects in the layout.
        """
        new_objects = list(self)
        new_objects.reverse()
        self.objects = new_objects


class NamedListLike(param.Parameterized):

    objects = param.List(default=[], doc="""
        The list of child objects that make up the layout.""")

    _preprocess_params = ['objects']

    def __init__(self, *items, **params):
        if 'objects' in params:
            if items:
                raise ValueError('%s objects should be supplied either '
                                 'as positional arguments or as a keyword, '
                                 'not both.' % type(self).__name__)
            items = params.pop('objects')
        params['objects'], self._names = self._to_objects_and_names(items)
        super().__init__(**params)
        self._panels = defaultdict(dict)
        self.param.watch(self._update_names, 'objects')
        # ALERT: Ensure that name update happens first, should be
        #        replaced by watch precedence support in param
        self._param_watchers['objects']['value'].reverse()

    def _to_object_and_name(self, item):
        from ..pane import panel
        if isinstance(item, tuple):
            name, item = item
        else:
            name = getattr(item, 'name', None)
        pane = panel(item, name=name)
        name = param_name(pane.name) if name is None else name
        return pane, name

    def _to_objects_and_names(self, items):
        objects, names = [], []
        for item in items:
            pane, name = self._to_object_and_name(item)
            objects.append(pane)
            names.append(name)
        return objects, names

    def _update_names(self, event):
        if len(event.new) == len(self._names):
            return
        names = []
        for obj in event.new:
            if obj in event.old:
                index = event.old.index(obj)
                name = self._names[index]
            else:
                name = obj.name
            names.append(name)
        self._names = names

    def _update_active(self, *events):
        pass

    #----------------------------------------------------------------
    # Public API
    #----------------------------------------------------------------

    def __getitem__(self, index):
        return self.objects[index]

    def __len__(self):
        return len(self.objects)

    def __iter__(self):
        for obj in self.objects:
            yield obj

    def __iadd__(self, other):
        self.extend(other)
        return self

    def __add__(self, other):
        if isinstance(other, NamedListPanel):
            other = list(zip(other._names, other.objects))
        elif isinstance(other, ListLike):
            other = other.objects
        if not isinstance(other, list):
            stype = type(self).__name__
            otype = type(other).__name__
            raise ValueError("Cannot add items of type %s and %s, can only "
                             "combine %s.objects with list or ListLike object."
                             % (stype, otype, stype))
        objects = list(zip(self._names, self.objects))
        return self.clone(*(objects+other))

    def __radd__(self, other):
        if isinstance(other, NamedListPanel):
            other = list(zip(other._names, other.objects))
        elif isinstance(other, ListLike):
            other = other.objects
        if not isinstance(other, list):
            stype = type(self).__name__
            otype = type(other).__name__
            raise ValueError("Cannot add items of type %s and %s, can only "
                             "combine %s.objects with list or ListLike object."
                             % (otype, stype, stype))
        objects = list(zip(self._names, self.objects))
        return self.clone(*(other+objects))

    def __setitem__(self, index, panes):
        new_objects = list(self)
        if not isinstance(index, slice):
            if index > len(self.objects):
                raise IndexError('Index %d out of bounds on %s '
                                 'containing %d objects.' %
                                 (index, type(self).__name__, len(self.objects)))
            start, end = index, index+1
            panes = [panes]
        else:
            start = index.start or 0
            end = len(self.objects) if index.stop is None else index.stop
            if index.start is None and index.stop is None:
                if not isinstance(panes, list):
                    raise IndexError('Expected a list of objects to '
                                     'replace the objects in the %s, '
                                     'got a %s type.' %
                                     (type(self).__name__, type(panes).__name__))
                expected = len(panes)
                new_objects = [None]*expected
                self._names = [None]*len(panes)
                end = expected
            else:
                expected = end-start
                if end > len(self.objects):
                    raise IndexError('Index %d out of bounds on %s '
                                     'containing %d objects.' %
                                     (end, type(self).__name__, len(self.objects)))
            if not isinstance(panes, list) or len(panes) != expected:
                raise IndexError('Expected a list of %d objects to set '
                                 'on the %s to match the supplied slice.' %
                                 (expected, type(self).__name__))
        for i, pane in zip(range(start, end), panes):
            new_objects[i], self._names[i] = self._to_object_and_name(pane)
        self.objects = new_objects

    def clone(self, *objects, **params):
        """
        Makes a copy of the Tabs sharing the same parameters.

        Arguments
        ---------
        objects: Objects to add to the cloned Tabs object.
        params: Keyword arguments override the parameters on the clone.

        Returns
        -------
        Cloned Tabs object
        """
        if not objects:
            if 'objects' in params:
                objects = params.pop('objects')
            else:
                objects = zip(self._names, self.objects)
        elif 'objects' in params:
            raise ValueError('Tabs objects should be supplied either '
                             'as positional arguments or as a keyword, '
                             'not both.')
        p = dict(self.param.values(), **params)
        del p['objects']
        return type(self)(*objects, **params)

    def append(self, pane):
        """
        Appends an object to the tabs.

        Arguments
        ---------
        obj (object): Panel component to add as a tab.
        """
        new_object, new_name = self._to_object_and_name(pane)
        new_objects = list(self)
        new_objects.append(new_object)
        self._names.append(new_name)
        self.objects = new_objects

    def clear(self):
        """
        Clears the tabs.
        """
        self._names = []
        self.objects = []

    def extend(self, panes):
        """
        Extends the the tabs with a list.

        Arguments
        ---------
        objects (list): List of panel components to add as tabs.
        """
        new_objects, new_names = self._to_objects_and_names(panes)
        objects = list(self)
        objects.extend(new_objects)
        self._names.extend(new_names)
        self.objects = objects

    def insert(self, index, pane):
        """
        Inserts an object in the tabs at the specified index.

        Arguments
        ---------
        index (int): Index at which to insert the object.
        object (object): Panel components to insert as tabs.
        """
        new_object, new_name = self._to_object_and_name(pane)
        new_objects = list(self.objects)
        new_objects.insert(index, new_object)
        self._names.insert(index, new_name)
        self.objects = new_objects

    def pop(self, index):
        """
        Pops an item from the tabs by index.

        Arguments
        ---------
        index (int): The index of the item to pop from the tabs.
        """
        new_objects = list(self)
        if index in new_objects:
            index = new_objects.index(index)
        new_objects.pop(index)
        self._names.pop(index)
        self.objects = new_objects

    def remove(self, pane):
        """
        Removes an object from the tabs.

        Arguments
        ---------
        obj (object): The object to remove from the tabs.
        """
        new_objects = list(self)
        if pane in new_objects:
            index = new_objects.index(pane)
        new_objects.remove(pane)
        self._names.pop(index)
        self.objects = new_objects

    def reverse(self):
        """
        Reverses the tabs.
        """
        new_objects = list(self)
        new_objects.reverse()
        self._names.reverse()
        self.objects = new_objects


class ListPanel(ListLike, Panel):
    """
    An abstract baseclass for Panel objects with list-like children.
    """

    margin = param.Parameter(default=0, doc="""
        Allows to create additional space around the component. May
        be specified as a two-tuple of the form (vertical, horizontal)
        or a four-tuple (top, right, bottom, left).""")

    scroll = param.Boolean(default=False, doc="""
        Whether to add scrollbars if the content overflows the size
        of the container.""")

    _source_transforms = {'scroll': None}

    __abstract = True

    def __init__(self, *objects, **params):
        from ..pane import panel
        if objects:
            if 'objects' in params:
                raise ValueError("A %s's objects should be supplied either "
                                 "as positional arguments or as a keyword, "
                                 "not both." % type(self).__name__)
            params['objects'] = [panel(pane) for pane in objects]
        elif 'objects' in params:
            params['objects'] = [panel(pane) for pane in params['objects']]
        super(Panel, self).__init__(**params)

    def _process_param_change(self, params):
        scroll = params.pop('scroll', None)
        css_classes = self.css_classes or []
        if scroll:
            params['css_classes'] = css_classes + ['scrollable']
        elif scroll == False:
            params['css_classes'] = css_classes
        return super()._process_param_change(params)

    def _cleanup(self, root):
        if root.ref['id'] in state._fake_roots:
            state._fake_roots.remove(root.ref['id'])
        super()._cleanup(root)
        for p in self.objects:
            p._cleanup(root)


class NamedListPanel(NamedListLike, Panel):

    active = param.Integer(default=0, bounds=(0, None), doc="""
        Index of the currently displayed objects.""")

    margin = param.Parameter(default=0, doc="""
        Allows to create additional space around the component. May
        be specified as a two-tuple of the form (vertical, horizontal)
        or a four-tuple (top, right, bottom, left).""")

    scroll = param.Boolean(default=False, doc="""
        Whether to add scrollbars if the content overflows the size
        of the container.""")

    _source_transforms = {'scroll': None}

    __abstract = True

    def _process_param_change(self, params):
        scroll = params.pop('scroll', None)
        css_classes = self.css_classes or []
        if scroll:
            params['css_classes'] = css_classes + ['scrollable']
        elif scroll == False:
            params['css_classes'] = css_classes
        return super()._process_param_change(params)

    def _cleanup(self, root):
        if root.ref['id'] in state._fake_roots:
            state._fake_roots.remove(root.ref['id'])
        super()._cleanup(root)
        for p in self.objects:
            p._cleanup(root)


class Row(ListPanel):
    """
    Horizontal layout of Viewables.
    """

    col_sizing = param.Parameter()

    _bokeh_model = BkRow

    _rename = dict(ListPanel._rename, col_sizing='cols')


class Column(ListPanel):
    """
    Vertical layout of Viewables.
    """

    row_sizing = param.Parameter()

    _bokeh_model = BkColumn

    _rename = dict(ListPanel._rename, row_sizing='rows')


class WidgetBox(ListPanel):
    """
    Vertical layout of widgets.
    """

    css_classes = param.List(default=['panel-widget-box'], doc="""
        CSS classes to apply to the layout.""")

    disabled = param.Boolean(default=False, doc="""
        Whether the widget is disabled.""")

    horizontal = param.Boolean(default=False, doc="""
        Whether to lay out the widgets in a Row layout as opposed
        to a Column layout.""")

    margin = param.Parameter(default=5, doc="""
        Allows to create additional space around the component. May
        be specified as a two-tuple of the form (vertical, horizontal)
        or a four-tuple (top, right, bottom, left).""")

    _source_transforms = {'disabled': None, 'horizontal': None}

    _rename = {'objects': 'children', 'horizontal': None}

    @property
    def _bokeh_model(self):
        return BkRow if self.horizontal else BkColumn

    @param.depends('disabled', 'objects', watch=True)
    def _disable_widgets(self):
        for obj in self:
            if hasattr(obj, 'disabled'):
                obj.disabled = self.disabled

    def __init__(self, *objects, **params):
        super().__init__(*objects, **params)
        if self.disabled:
            self._disable_widgets()
