"""
Classes for drawing maps.

"""

import warnings
from collections import OrderedDict
from typing import Dict, List, Optional, Sequence, Tuple, Type, Union

from branca.element import Element, Figure, Html, MacroElement
from jinja2 import Template

from folium.elements import ElementAddToElement, EventHandler
from folium.utilities import (
    JsCode,
    TypeBounds,
    TypeJsonValue,
    camelize,
    escape_backticks,
    parse_options,
    validate_location,
)


class Evented(MacroElement):
    """The base class for Layer and Map

    Adds the `on` method for event handling capabilities.

    See https://leafletjs.com/reference.html#evented for
    more in depth documentation. Please note that we have
    only added the `on(<Object> eventMap)` variant of this
    method using python keyword arguments.
    """

    def on(self, **event_map: JsCode):
        for event_type, handler in event_map.items():
            self.add_child(EventHandler(event_type, handler))


class Layer(Evented):
    """An abstract class for everything that is a Layer on the map.
    It will be used to define whether an object will be included in
    LayerControls.

    Parameters
    ----------
    name : string, default None
        The name of the Layer, as it will appear in LayerControls
    overlay : bool, default False
        Adds the layer as an optional overlay (True) or the base layer (False).
    control : bool, default True
        Whether the Layer will be included in LayerControls.
    show: bool, default True
        Whether the layer will be shown on opening.
    """

    def __init__(
        self,
        name: Optional[str] = None,
        overlay: bool = False,
        control: bool = True,
        show: bool = True,
    ):
        super().__init__()
        self.layer_name = name if name is not None else self.get_name()
        self.overlay = overlay
        self.control = control
        self.show = show

    def render(self, **kwargs):
        if self.show:
            self.add_child(
                ElementAddToElement(
                    element_name=self.get_name(),
                    element_parent_name=self._parent.get_name(),
                ),
                name=self.get_name() + "_add",
            )
        super().render(**kwargs)


class FeatureGroup(Layer):
    """
    Create a FeatureGroup layer ; you can put things in it and handle them
    as a single layer.  For example, you can add a LayerControl to
    tick/untick the whole group.

    Parameters
    ----------
    name : str, default None
        The name of the featureGroup layer.
        It will be displayed in the LayerControl.
        If None get_name() will be called to get the technical (ugly) name.
    overlay : bool, default True
        Whether your layer will be an overlay (ticked with a check box in
        LayerControls) or a base layer (ticked with a radio button).
    control: bool, default True
        Whether the layer will be included in LayerControls.
    show: bool, default True
        Whether the layer will be shown on opening.
    **kwargs
        Additional (possibly inherited) options. See
        https://leafletjs.com/reference.html#featuregroup

    """

    _template = Template(
        """
        {% macro script(this, kwargs) %}
            var {{ this.get_name() }} = L.featureGroup(
                {{ this.options|tojson }}
            );
        {% endmacro %}
        """
    )

    def __init__(
        self,
        name: Optional[str] = None,
        overlay: bool = True,
        control: bool = True,
        show: bool = True,
        **kwargs: TypeJsonValue,
    ):
        super().__init__(name=name, overlay=overlay, control=control, show=show)
        self._name = "FeatureGroup"
        self.tile_name = name if name is not None else self.get_name()
        self.options = parse_options(**kwargs)


class LayerControl(MacroElement):
    """
    Creates a LayerControl object to be added on a folium map.

    This object should be added to a Map object. Only Layer children
    of Map are included in the layer control.

    Note
    ----
    The LayerControl should be added last to the map.
    Otherwise, the LayerControl and/or the controlled layers may not appear.

    Parameters
    ----------
    position : str
          The position of the control (one of the map corners), can be
          'topleft', 'topright', 'bottomleft' or 'bottomright'
          default: 'topright'
    collapsed : bool, default True
          If true the control will be collapsed into an icon and expanded on
          mouse hover or touch.
    autoZIndex : bool, default True
          If true the control assigns zIndexes in increasing order to all of
          its layers so that the order is preserved when switching them on/off.
    draggable: bool, default False
          By default the layer control has a fixed position. Set this argument
          to True to allow dragging the control around.
    **kwargs
        Additional (possibly inherited) options. See
        https://leafletjs.com/reference.html#control-layers

    """

    _template = Template(
        """
        {% macro script(this,kwargs) %}
            var {{ this.get_name() }}_layers = {
                base_layers : {
                    {%- for key, val in this.base_layers.items() %}
                    {{ key|tojson }} : {{val}},
                    {%- endfor %}
                },
                overlays :  {
                    {%- for key, val in this.overlays.items() %}
                    {{ key|tojson }} : {{val}},
                    {%- endfor %}
                },
            };
            let {{ this.get_name() }} = L.control.layers(
                {{ this.get_name() }}_layers.base_layers,
                {{ this.get_name() }}_layers.overlays,
                {{ this.options|tojson }}
            ).addTo({{this._parent.get_name()}});

            {%- if this.draggable %}
            new L.Draggable({{ this.get_name() }}.getContainer()).enable();
            {%- endif %}

        {% endmacro %}
        """
    )

    def __init__(
        self,
        position: str = "topright",
        collapsed: bool = True,
        autoZIndex: bool = True,
        draggable: bool = False,
        **kwargs: TypeJsonValue,
    ):
        super().__init__()
        self._name = "LayerControl"
        self.options = parse_options(
            position=position, collapsed=collapsed, autoZIndex=autoZIndex, **kwargs
        )
        self.draggable = draggable
        self.base_layers: OrderedDict[str, str] = OrderedDict()
        self.overlays: OrderedDict[str, str] = OrderedDict()

    def reset(self) -> None:
        self.base_layers = OrderedDict()
        self.overlays = OrderedDict()

    def render(self, **kwargs) -> None:
        """Renders the HTML representation of the element."""
        self.reset()
        for item in self._parent._children.values():
            if not isinstance(item, Layer) or not item.control:
                continue
            key = item.layer_name
            if not item.overlay:
                self.base_layers[key] = item.get_name()
            else:
                self.overlays[key] = item.get_name()
        super().render()


class Icon(MacroElement):
    """
    Creates an Icon object that will be rendered
    using Leaflet.awesome-markers.

    Parameters
    ----------
    color : str, default 'blue'
        The color of the marker. You can use:

            ['red', 'blue', 'green', 'purple', 'orange', 'darkred',
             'lightred', 'beige', 'darkblue', 'darkgreen', 'cadetblue',
             'darkpurple', 'white', 'pink', 'lightblue', 'lightgreen',
             'gray', 'black', 'lightgray']

    icon_color : str, default 'white'
        The color of the drawing on the marker. You can use colors above,
        or an html color code.
    icon : str, default 'info-sign'
        The name of the marker sign.
        See Font-Awesome website to choose yours.
        Warning : depending on the icon you choose you may need to adapt
        the `prefix` as well.
    angle : int, default 0
        The icon will be rotated by this amount of degrees.
    prefix : str, default 'glyphicon'
        The prefix states the source of the icon. 'fa' for font-awesome or
        'glyphicon' for bootstrap 3.

    https://github.com/lvoogdt/Leaflet.awesome-markers

    """

    _template = Template(
        """
        {% macro script(this, kwargs) %}
            var {{ this.get_name() }} = L.AwesomeMarkers.icon(
                {{ this.options|tojson }}
            );
            {{ this._parent.get_name() }}.setIcon({{ this.get_name() }});
        {% endmacro %}
        """
    )
    color_options = {
        "red",
        "darkred",
        "lightred",
        "orange",
        "beige",
        "green",
        "darkgreen",
        "lightgreen",
        "blue",
        "darkblue",
        "cadetblue",
        "lightblue",
        "purple",
        "darkpurple",
        "pink",
        "white",
        "gray",
        "lightgray",
        "black",
    }

    def __init__(
        self,
        color: str = "blue",
        icon_color: str = "white",
        icon: str = "info-sign",
        angle: int = 0,
        prefix: str = "glyphicon",
        **kwargs: TypeJsonValue,
    ):
        super().__init__()
        self._name = "Icon"
        if color not in self.color_options:
            warnings.warn(
                f"color argument of Icon should be one of: {self.color_options}.",
                stacklevel=2,
            )
        self.options = parse_options(
            marker_color=color,
            icon_color=icon_color,
            icon=icon,
            prefix=prefix,
            extra_classes=f"fa-rotate-{angle}",
            **kwargs,
        )


class Marker(MacroElement):
    """
    Create a simple stock Leaflet marker on the map, with optional
    popup text or Vincent visualization.

    Parameters
    ----------
    location: tuple or list
        Latitude and Longitude of Marker (Northing, Easting)
    popup: string or folium.Popup, default None
        Label for the Marker; either an escaped HTML string to initialize
        folium.Popup or a folium.Popup instance.
    tooltip: str or folium.Tooltip, default None
        Display a text when hovering over the object.
    icon: Icon plugin
        the Icon plugin to use to render the marker.
    draggable: bool, default False
        Set to True to be able to drag the marker around the map.

    Returns
    -------
    Marker names and HTML in obj.template_vars

    Examples
    --------
    >>> Marker(location=[45.5, -122.3], popup="Portland, OR")
    >>> Marker(location=[45.5, -122.3], popup=Popup("Portland, OR"))
    # If the popup label has characters that need to be escaped in HTML
    >>> Marker(
    ...     location=[45.5, -122.3],
    ...     popup=Popup("Mom & Pop Arrow Shop >>", parse_html=True),
    ... )
    """

    _template = Template(
        """
        {% macro script(this, kwargs) %}
            var {{ this.get_name() }} = L.marker(
                {{ this.location|tojson }},
                {{ this.options|tojson }}
            ).addTo({{ this._parent.get_name() }});
        {% endmacro %}
        """
    )

    def __init__(
        self,
        location: Optional[Sequence[float]] = None,
        popup: Union["Popup", str, None] = None,
        tooltip: Union["Tooltip", str, None] = None,
        icon: Optional[Icon] = None,
        draggable: bool = False,
        **kwargs: TypeJsonValue,
    ):
        super().__init__()
        self._name = "Marker"
        self.location = validate_location(location) if location is not None else None
        self.options = parse_options(
            draggable=draggable or None, autoPan=draggable or None, **kwargs
        )
        if icon is not None:
            self.add_child(icon)
            self.icon = icon
        if popup is not None:
            self.add_child(popup if isinstance(popup, Popup) else Popup(str(popup)))
        if tooltip is not None:
            self.add_child(
                tooltip if isinstance(tooltip, Tooltip) else Tooltip(str(tooltip))
            )

    def _get_self_bounds(self) -> List[List[float]]:
        """Computes the bounds of the object itself.

        Because a marker has only single coordinates, we repeat them.
        """
        assert self.location is not None
        return [self.location, self.location]

    def render(self) -> None:
        if self.location is None:
            raise ValueError(
                f"{self._name} location must be assigned when added directly to map."
            )
        super().render()


class Popup(Element):
    """Create a Popup instance that can be linked to a Layer.

    Parameters
    ----------
    html: string or Element
        Content of the Popup.
    parse_html: bool, default False
        True if the popup is a template that needs to the rendered first.
    max_width: int for pixels or text for percentages, default '100%'
        The maximal width of the popup.
    show: bool, default False
        True renders the popup open on page load.
    sticky: bool, default False
        True prevents map and other popup clicks from closing.
    lazy: bool, default False
        True only loads the Popup content when clicking on the Marker.
    """

    _template = Template(
        """
        var {{this.get_name()}} = L.popup({{ this.options|tojson }});

        {% for name, element in this.html._children.items() %}
            {% if this.lazy %}
                {{ this._parent.get_name() }}.once('click', function() {
                    {{ this.get_name() }}.setContent($(`{{ element.render(**kwargs).replace('\\n',' ') }}`)[0]);
                });
            {% else %}
                var {{ name }} = $(`{{ element.render(**kwargs).replace('\\n',' ') }}`)[0];
                {{ this.get_name() }}.setContent({{ name }});
            {% endif %}
        {% endfor %}

        {{ this._parent.get_name() }}.bindPopup({{ this.get_name() }})
        {% if this.show %}.openPopup(){% endif %};

        {% for name, element in this.script._children.items() %}
            {{element.render()}}
        {% endfor %}
    """
    )  # noqa

    def __init__(
        self,
        html: Union[str, Element, None] = None,
        parse_html: bool = False,
        max_width: Union[str, int] = "100%",
        show: bool = False,
        sticky: bool = False,
        lazy: bool = False,
        **kwargs: TypeJsonValue,
    ):
        super().__init__()
        self._name = "Popup"
        self.header = Element()
        self.html = Element()
        self.script = Element()

        self.header._parent = self
        self.html._parent = self
        self.script._parent = self

        script = not parse_html

        if isinstance(html, Element):
            self.html.add_child(html)
        elif isinstance(html, str):
            html = escape_backticks(html)
            self.html.add_child(Html(html, script=script))

        self.show = show
        self.lazy = lazy
        self.options = parse_options(
            max_width=max_width,
            autoClose=False if show or sticky else None,
            closeOnClick=False if sticky else None,
            **kwargs,
        )

    def render(self, **kwargs) -> None:
        """Renders the HTML representation of the element."""
        for name, child in self._children.items():
            child.render(**kwargs)

        figure = self.get_root()
        assert isinstance(
            figure, Figure
        ), "You cannot render this Element if it is not in a Figure."

        figure.script.add_child(
            Element(self._template.render(this=self, kwargs=kwargs)),
            name=self.get_name(),
        )


class Tooltip(MacroElement):
    """
    Create a tooltip that shows text when hovering over its parent object.

    Parameters
    ----------
    text: str
        String to display as a tooltip on the object. If the argument is of a
        different type it will be converted to str.
    style: str, default None.
        HTML inline style properties like font and colors. Will be applied to
        a div with the text in it.
    sticky: bool, default True
        Whether the tooltip should follow the mouse.
    **kwargs
        These values will map directly to the Leaflet Options. More info
        available here: https://leafletjs.com/reference.html#tooltip

    """

    _template = Template(
        """
        {% macro script(this, kwargs) %}
            {{ this._parent.get_name() }}.bindTooltip(
                `<div{% if this.style %} style={{ this.style|tojson }}{% endif %}>
                     {{ this.text }}
                 </div>`,
                {{ this.options|tojson }}
            );
        {% endmacro %}
        """
    )
    valid_options: Dict[str, Tuple[Type, ...]] = {
        "pane": (str,),
        "offset": (tuple,),
        "direction": (str,),
        "permanent": (bool,),
        "sticky": (bool,),
        "interactive": (bool,),
        "opacity": (float, int),
        "attribution": (str,),
        "className": (str,),
    }

    def __init__(
        self,
        text: str,
        style: Optional[str] = None,
        sticky: bool = True,
        **kwargs: TypeJsonValue,
    ):
        super().__init__()
        self._name = "Tooltip"

        self.text = str(text)

        kwargs.update({"sticky": sticky})
        self.options = self.parse_options(kwargs)

        if style:
            assert isinstance(
                style, str
            ), "Pass a valid inline HTML style property string to style."
            # noqa outside of type checking.
            self.style = style

    def parse_options(
        self,
        kwargs: Dict[str, TypeJsonValue],
    ) -> Dict[str, TypeJsonValue]:
        """Validate the provided kwargs and return options as json string."""
        kwargs = {camelize(key): value for key, value in kwargs.items()}
        for key in kwargs.keys():
            assert (
                key in self.valid_options
            ), "The option {} is not in the available options: {}.".format(
                key, ", ".join(self.valid_options)
            )
            assert isinstance(
                kwargs[key], self.valid_options[key]
            ), f"The option {key} must be one of the following types: {self.valid_options[key]}."
        return kwargs


class FitBounds(MacroElement):
    """Fit the map to contain a bounding box with the
    maximum zoom level possible.

    Parameters
    ----------
    bounds: list of (latitude, longitude) points
        Bounding box specified as two points [southwest, northeast]
    padding_top_left: (x, y) point, default None
        Padding in the top left corner. Useful if some elements in
        the corner, such as controls, might obscure objects you're zooming
        to.
    padding_bottom_right: (x, y) point, default None
        Padding in the bottom right corner.
    padding: (x, y) point, default None
        Equivalent to setting both top left and bottom right padding to
        the same value.
    max_zoom: int, default None
        Maximum zoom to be used.
    """

    _template = Template(
        """
        {% macro script(this, kwargs) %}
            {{ this._parent.get_name() }}.fitBounds(
                {{ this.bounds|tojson }},
                {{ this.options|tojson }}
            );
        {% endmacro %}
        """
    )

    def __init__(
        self,
        bounds: TypeBounds,
        padding_top_left: Optional[Sequence[float]] = None,
        padding_bottom_right: Optional[Sequence[float]] = None,
        padding: Optional[Sequence[float]] = None,
        max_zoom: Optional[int] = None,
    ):
        super().__init__()
        self._name = "FitBounds"
        self.bounds = bounds
        self.options = parse_options(
            max_zoom=max_zoom,
            padding_top_left=padding_top_left,
            padding_bottom_right=padding_bottom_right,
            padding=padding,
        )


class FitOverlays(MacroElement):
    """Fit the bounds of the maps to the enabled overlays.

    Parameters
    ----------
    padding: int, default 0
        Amount of padding in pixels applied in the corners.
    max_zoom: int, optional
        The maximum possible zoom to use when fitting to the bounds.
    fly: bool, default False
        Use a smoother, longer animation.
    fit_on_map_load: bool, default True
        Apply the fit when initially loading the map.
    """

    _template = Template(
        """
        {% macro script(this, kwargs) %}
        function customFlyToBounds() {
            let bounds = L.latLngBounds([]);
            {{ this._parent.get_name() }}.eachLayer(function(layer) {
                if (typeof layer.getBounds === 'function') {
                    bounds.extend(layer.getBounds());
                }
            });
            if (bounds.isValid()) {
                {{ this._parent.get_name() }}.{{ this.method }}(bounds, {{ this.options|tojson }});
            }
        }
        {{ this._parent.get_name() }}.on('overlayadd', customFlyToBounds);
        {%- if this.fit_on_map_load %}
        customFlyToBounds();
        {%- endif %}
        {% endmacro %}
    """
    )

    def __init__(
        self,
        padding: int = 0,
        max_zoom: Optional[int] = None,
        fly: bool = False,
        fit_on_map_load: bool = True,
    ):
        super().__init__()
        self._name = "FitOverlays"
        self.method = "flyToBounds" if fly else "fitBounds"
        self.fit_on_map_load = fit_on_map_load
        self.options = parse_options(padding=(padding, padding), max_zoom=max_zoom)


class CustomPane(MacroElement):
    """
    Creates a custom pane to hold map elements.

    Behavior is as in https://leafletjs.com/examples/map-panes/

    Parameters
    ----------
    name: string
        Name of the custom pane. Other map elements can be added
        to the pane by specifying the 'pane' kwarg when constructing
        them.
    z_index: int or string, default 625
        The z-index that will be associated with the pane, and will
        determine which map elements lie over/under it. The default
        (625) corresponds to between markers and tooltips. Default
        panes and z-indexes can be found at
        https://leafletjs.com/reference.html#map-pane
    pointer_events: bool, default False
        Whether or not layers in the pane should interact with the
        cursor. Setting to False will prevent interfering with
        pointer events associated with lower layers.
    """

    _template = Template(
        """
        {% macro script(this, kwargs) %}
            var {{ this.get_name() }} = {{ this._parent.get_name() }}.createPane(
                {{ this.name|tojson }});
            {{ this.get_name() }}.style.zIndex = {{ this.z_index|tojson }};
            {% if not this.pointer_events %}
                {{ this.get_name() }}.style.pointerEvents = 'none';
            {% endif %}
        {% endmacro %}
        """
    )

    def __init__(
        self,
        name: str,
        z_index: Union[int, str] = 625,
        pointer_events: bool = False,
    ):
        super().__init__()
        self._name = "Pane"
        self.name = name
        self.z_index = z_index
        self.pointer_events = pointer_events
