# Copyright (C) 2019-2022 HERE Global B.V. and its affiliate(s).
# All rights reserved.
#
# This software and other materials contain proprietary information
# controlled by HERE and are protected by applicable copyright legislation.
# Any use and utilization of this software and other materials and
# disclosure to any third parties is conditional upon having a separate
# agreement with HERE for the access, use, utilization or disclosure of this
# software. In the absence of such agreement, the use of the software is not
# allowed.

"""
An inspector for the HERE PySDK based on ipyleaflet.
"""

from itertools import count, cycle
from typing import Dict, List, Optional, Union

import geojson
import geopandas as gpd
from here.inspector.base import Inspector
from here.inspector.ipyleaflet.basemaps import basemap_to_tiles
from here.inspector.ipyleaflet.utils import (
    _geojson_with_longkey,
    theme_basemap,
    theme_here_basemap,
    to_style,
)
from here.inspector.styles import Color, Theme
from here.inspector.utils import (
    Features,
    Tiles,
    bounds_gdf,
    bounds_obj,
    center_bounds,
    is_geojson,
    merge_bounds,
    options,
    zoom_bounds,
)
from ipyleaflet import (
    FullScreenControl,
    GeoData,
    GeoJSON,
    LayersControl,
    Map,
    ScaleControl,
    ZoomControl,
)
from shapely.geometry import Point
from shapely.geometry.base import BaseGeometry
from xyzservices.lib import TileProvider


class IpyleafletInspector(Inspector):
    """An inspector based on ipyleaflet."""

    def __init__(self):
        """Instantiate an inspector object."""
        self._features = []
        self._theme: Theme = options.default_theme
        self._custom_basemap: Optional[Union[dict, TileProvider]] = None
        self._center: Optional[Point] = None
        self._zoom: Optional[int] = None
        self._colors: List[Color] = options.default_colors
        self._map: Optional[Map] = None

    def add_features(
        self,
        features: Optional[Features] = None,
        name=None,
        style: Optional[Union[Color, Dict]] = None,
        hover_style: Optional[Union[Color, Dict]] = None,
        point_style: Optional[Union[Color, Dict]] = None,
    ) -> "IpyleafletInspector":
        """
        Add to the inspector a collection of map features (attributed geometries) as new layer.

        The features layer can be named and styled.

        :param features: the attributed geometries to render. Supported types:
          - ``gpd.GeoSeries``, unattributed geometries
          - ``gpd.GeoDataFrame``, attributes geometries
          - ``BaseGeometry``, individual geometries
          - ``Iterable`` of `BaseGeometry`, it can be any container
                         but also a ``Generator`` of unattributed geometries
          - ``Iterable`` of pair of ``BaseGeometry`` and ``Dict``, it can be any container
                         but also a ``Generator`` of geometries paired with attributes
          - ``Dict``     a parsed GeoJSON ``FeatureCollection`` or ``Feature``
          - ``Iterable`` of parsed GeoJSON `Feature`, it can be any container
                         but also a ``Generator`` of parsed GeoJSON features
        :param name: an optional name to assign to the layer
        :param style: an optional style for the features. If not present, a default one is picked.
          It can be a generic inspector style, as defined in ``here.inspector.styles``,
          or directly an ipyleaflet style dictionary.
        :param hover_style:
          an optional hover style. If not present, a default one is picked, based on ``style``.
          It can be a generic inspector style, as defined in ``here.inspector.styles``,
          or directly an ipyleaflet style dictionary.
        :param point_style:
          an optional point style. If not present, a default one is picked, based on ``style``.
          It can be a generic inspector style, as defined in ``here.inspector.styles``,
          or directly an ipyleaflet style dictionary.
        :return: ``self``, for further chaining
        """

        fields = {
            "features": features,
            "name": str(name) if name else None,
            "style": style,
            "hover_style": hover_style,
            "point_style": point_style,
        }
        self._features.append(fields)
        return self

    def add_tiles(
        self,
        tiles: Optional[Tiles] = None,
        name=None,
        style: Optional[Union[Color, Dict]] = None,
        hover_style: Optional[Union[Color, Dict]] = None,
    ) -> "IpyleafletInspector":
        """
        Add to the inspector a tiling grid as new layer.

        The grid can be a complete grid or contain only some tiles.
        The grid layer can be named and styled.

        :param tiles: the identifier of the tiles to render. Supported types:
          - ``pd.Series`` of tile identifiers
          - ``Iterable``  of tile identifiers, it can be any container but also a ``Generator``
          - ``None``      add the complete grid of all the tiles defined by the tiling scheme
        :param name: an optional name to assign to the layer
        :param style:
          an optional style for the tiling grid. If not present, a default one is picked.
          It can be a generic inspector style, as defined in ``here.inspector.styles``,
          or directly an ipyleaflet style dictionary.
        :param hover_style:
          an optional hover style. If not present, a default one is picked, based on ``style``.
          It can be a generic inspector style, as defined in ``here.inspector.styles``,
          or directly an ipyleaflet style dictionary.
        :return: ``self``, for further chaining
        """
        # TODO: handle the None case
        if tiles is not None:
            features = [_geojson_with_longkey(tile_id) for tile_id in tiles]
            data = geojson.FeatureCollection(features=features)
            self.add_features(
                features=data,
                name=name,
                style=style,
                hover_style=hover_style,
            )
        return self

    def set_colors(self, colors: Optional[List[Color]] = None) -> "IpyleafletInspector":
        """
        Set the list of colors the inspector cycles through
        to render content when no color or style is specified.

        The default color list can be overridden in
        ``here.inspector.options.default_colors``.

        :param colors: a non-empty list of colors
        :return: ``self``, for further chaining
        """
        self._colors = colors or options.default_colors  # type: ignore
        return self

    def set_theme(self, theme: Optional[Theme] = None) -> "IpyleafletInspector":
        """
        Set the inspector theme.

        This determines the background map, if any, and colors.
        The default theme can be overridden in ``here.inspector.options.default_theme``.

        :param theme: one of the predefined inspector themes
        :return: ``self``, for further chaining
        """
        self._theme = theme or options.default_theme  # type: ignore
        return self

    def set_basemap(self, basemap: Optional[Union[dict, TileProvider]] = None) -> "Inspector":
        """Set a custom basemap to use as a background.

        This overrides the default base map of the theme, if any is defined.
        Setting it to ``None`` restores the default base map.

        :param basemap: Either a dictionary or an object of class:`xyzservices.lib.TileProvider`.
        :return: ``self`, for further chaining
        """
        self._custom_basemap = basemap
        return self

    def set_center(self, center: Point) -> "IpyleafletInspector":
        """
        Configure the location to show in the inspector when opened.

        If not set, an optimal starting location
        is calculated from the features and tiling grids.

        :param center: the center point of the map
        :return: ``self``, for further chaining
        """
        self._center = center
        return self

    def set_zoom(self, zoom: int) -> "IpyleafletInspector":
        """
        Configure the starting zoom level of the inspector.

        If not set, an optimal starting zoom level
        is calculated from the geodata and tiling grids.

        :param zoom: the zoom level, from 0 to 31
        :return: ``self``, for further chaining
        """
        self._zoom = zoom
        return self

    def show(self) -> Map:
        """
        Show the inspector in a Jupyter notebook with the features and tiles loaded so far.

        :return: The ``ipyleaflet.Map`` object created by the inspector
        """
        # in this specific case, we have nothing special to do to render
        # the map apart from returning the map object.
        return self.backend()

    def backend(self) -> Map:
        """Return the inspector's backend, in this case its ``Map`` object.

        The backend is populated with the features, tiling grids and settings
        specified so far. Users can further configure the ``Map``, but also UI
        components, according to the ipyleaflet documentation.

        :return: The ``ipyleaflet.Map`` object created by the inspector
        :raises ValueError: in case the backend can't be configured with the data provided
        """
        if not self._map:

            def make_style(s):
                return to_style(self._theme, s) if isinstance(s, Color) else s

            def make_point_style(s):
                if isinstance(s, Color):
                    ps = to_style(self._theme, s)
                    ps["radius"] = 2.5
                    return ps
                else:
                    return s

            def is_shape(x):
                return isinstance(x, BaseGeometry)

            def is_geojson_feature(x):
                return type(x) == geojson.feature.Feature or (type(x) == dict and is_geojson(x))

            # TODO: this function should be rethought and reworked

            colors = cycle(self._colors)
            unnamed_features = count(1)
            layers = []
            bounds = []
            names = "features name style hover_style point_style".split()
            for entry in self._features:
                params = {n: entry[n] for n in names if not entry[n] is None}

                # Pick a default color, if no style is provided
                main_style = params.get("style") or next(colors)
                point_style = params.get("point_style") or main_style

                # Set the styles
                params["style"] = make_style(main_style)
                if "hover_style" in params:
                    params["hover_style"] = make_style(params["hover_style"])
                params["point_style"] = make_point_style(point_style)

                # Set the name
                params["name"] = params.get("name") or f"Unnamed {next(unnamed_features)}"

                typ = type(params["features"])
                feats = params["features"]
                if typ in [dict, geojson.feature.Feature, geojson.feature.FeatureCollection]:
                    params["data"] = feats
                    del params["features"]
                    layers.append(GeoJSON(**params))
                    try:
                        bounds.append(bounds_obj(params["data"]))
                    except ValueError:
                        pass
                elif typ == list and feats and all(is_geojson_feature(x) for x in feats):
                    # we wrap the GeoJSON values into a temporary FeatureCollection
                    params["data"] = geojson.feature.FeatureCollection(feats)
                    del params["features"]
                    layers.append(GeoJSON(**params))
                    try:
                        bounds.append(bounds_obj(params["data"]))
                    except ValueError:
                        pass
                elif typ == gpd.GeoDataFrame:
                    params["geo_dataframe"] = feats
                    del params["features"]
                    layers.append(GeoData(**params))
                    try:
                        bounds.append(bounds_gdf(params["geo_dataframe"]))
                    except ValueError:
                        pass
                elif typ == gpd.GeoSeries:
                    # we wrap the GeoSeries into a temporary GeoDataFrame
                    params["geo_dataframe"] = gpd.GeoDataFrame(geometry=feats)
                    del params["features"]
                    layers.append(GeoData(**params))
                    try:
                        bounds.append(bounds_gdf(params["geo_dataframe"]))
                    except ValueError:
                        pass
                elif typ == list and feats and all(is_shape(x) for x in feats):
                    # we wrap the geometries into a temporary GeoDataFrame
                    params["geo_dataframe"] = gpd.GeoDataFrame(geometry=feats)
                    del params["features"]
                    layers.append(GeoData(**params))
                    bounds.append(bounds_gdf(params["geo_dataframe"]))
                elif issubclass(typ, BaseGeometry):
                    # we wrap the geometries into a temporary GeoDataFrame
                    params["geo_dataframe"] = gpd.GeoDataFrame(geometry=[feats])
                    del params["features"]
                    layers.append(GeoData(**params))
                    bounds.append(bounds_gdf(params["geo_dataframe"]))
                elif typ == list and not feats:
                    # pass an empty GeoDataFrame
                    params["geo_dataframe"] = gpd.GeoDataFrame(geometry=[])
                    del params["features"]
                    layers.append(GeoData(**params))
                else:
                    raise ValueError(
                        f'Unsupported type {typ} and/or content for layer {params["name"]}'
                    )

            mbounds = merge_bounds(bounds) if bounds else None

            def select_basemap(api_key: Optional[str], theme: Theme):
                if api_key:
                    basemap = theme_here_basemap[theme]
                    if basemap:
                        basemap["apiKey"] = api_key
                    return basemap
                else:
                    return theme_basemap[theme]

            basemap = self._custom_basemap or select_basemap(options.api_key, self._theme)
            min_zoom = basemap.get("min_zoom", 1) if basemap else 1
            if basemap and isinstance(basemap, TileProvider) and basemap.name.startswith("HEREv3"):
                basemap["apiKey"] = options.api_key
            if isinstance(basemap, TileProvider):
                basemap = basemap_to_tiles(basemap)
            params = {
                "basemap": basemap,
                "zoom": self._zoom or (zoom_bounds(mbounds, min_zoom) if mbounds else min_zoom),
                "center": (self._center.y, self._center.x)
                if self._center
                else center_bounds(mbounds)
                if mbounds
                else None,
            }

            params = {k: v for k, v in params.items() if v is not None}

            # Prepare some basic UI
            params["controls"] = [
                LayersControl(position="topleft"),
                ZoomControl(position="topright"),
                FullScreenControl(position="topright"),
                ScaleControl(position="bottomleft"),
            ]
            params["zoom_control"] = False

            if basemap:
                # This appends the layers and avoids to overwrite the base map
                self._map = Map(**params)
                for layer in layers:
                    self._map.add_layer(layer)
            else:
                # This removes the default base map because we don't want one
                self._map = Map(layers=layers, **params)

        return self._map
