# 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 `HERE Map Widget for Jupyter <https://github.com/heremaps/here-map-widget-for-jupyter>`_.
"""  # noqa

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.heremap.utils import theme_here_basemap, to_style
from here.inspector.ipyleaflet.utils import _geojson_with_longkey, theme_basemap
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 here_map_widget import (
    FullscreenControl,
    GeoData,
    GeoJSON,
    LayersControl,
    Map,
    ScaleBar,
    ZoomControl,
)
from shapely.geometry import Point
from shapely.geometry.base import BaseGeometry
from xyzservices.lib import TileProvider


class HEREMapInspector(Inspector):
    """An inspector based on `HERE Map Widget for Jupyter <https://github.com/heremaps/here-map-widget-for-jupyter>`_."""  # noqa

    def __init__(self):
        """Instantiate an inspector object."""
        self.api_key = options.api_key
        if not self.api_key:
            raise ValueError(
                "HERE API key is mandatory for HEREMapInspector backend. "
                "Please set correct api_key in options."
            )
        self._features = []
        self._theme: Theme = options.default_theme
        self._custom_basemap: Optional[dict] = 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,
    ) -> "HEREMapInspector":
        """
        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 here-map-widget-for-jupyter 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 here-map-widget-for-jupyter 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 here-map-widget-for-jupyter 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,
    ) -> "HEREMapInspector":
        """
        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 here-map-widget-for-jupyter 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 here-map-widget-for-jupyter style dictionary.
        :return: ``self``, for further chaining
        """
        if tiles is not None:
            geometries = [_geojson_with_longkey(tile_id) for tile_id in tiles]
            features = [
                geojson.Feature(geometry=geo, properties=geo["properties"]) for geo in geometries
            ]
            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) -> "HEREMapInspector":
        """
        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) -> "HEREMapInspector":
        """
        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
    ) -> "HEREMapInspector":
        """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: a basemap, as defined in here-map-widget-for-jupyter
        :return: ``self`, for further chaining
        """
        self._custom_basemap = basemap
        return self

    def set_center(self, center: Point) -> "HEREMapInspector":
        """
        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) -> "HEREMapInspector":
        """
        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 ``heremap.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 here-map-widget-for-jupyter documentation.

        :return: The ``heremap.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(self.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 type(basemap) is dict:
                basemap = TileProvider(basemap)

            params = {
                "basemap": basemap,
                "api_key": self.api_key,
                "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(alignment="LEFT_TOP"),
                ZoomControl(alignment="RIGHT_TOP"),
                FullscreenControl(alignment="RIGHT_TOP"),
                ScaleBar(alignment="LEFT_BOTTOM"),
            ]
            params["zoom_control"] = False

            self._map = Map(layers=layers, **params)

        return self._map
