from __future__ import annotations

import json
import webbrowser
from typing_extensions import Union

from jinja2 import Template
from pydantic import ConfigDict, Field, field_validator

from ._core import MapLibreBaseModel
from ._templates import html_template, js_template
from ._utils import get_temp_filename, read_internal_file
from .basemaps import (
    BasemapStyle,
    Carto,
    MapTiler,
    OpenFreeMap,
    construct_carto_basemap_url,
    construct_maptiler_basemap_url,
    construct_openfreemap_basemap_url,
)
from .controls import Control, ControlPosition, Marker
from .layer import Layer
from .plugins import MapboxDrawOptions
from .sources import SimpleFeatures, Source
from .projection import ProjectionType
from .sky import Sky
from .light import Light
from .terrain import Terrain

try:
    from geopandas import GeoDataFrame
except ImportError:
    GeoDataFrame = None

try:
    import pydeck
except ImportError:
    pydeck = None


def parse_deck_layers(layers: list[dict | "pydeck.Layer"]) -> list[dict]:
    for i, layer in enumerate(layers):
        if pydeck is not None and isinstance(layer, pydeck.Layer):
            layers[i] = json.loads(layer.to_json())

    return layers


class MapOptions(MapLibreBaseModel):
    """Map options

    Note:
        See [MapOptions](https://maplibre.org/maplibre-gl-js/docs/API/type-aliases/MapOptions) for details.
    """

    model_config = ConfigDict(validate_assignment=True, extra="forbid", use_enum_values=False)

    antialias: bool = None
    attribution_control: bool = Field(None, serialization_alias="attributionControl")
    bearing: Union[int, float] = None
    bearing_snap: int = Field(None, serialization_alias="bearingSnap")
    bounds: tuple = None
    box_zoom: bool = Field(None, serialization_alias="boxZoom")
    center: tuple[float, float] = None
    click_tolerance: int = Field(None, serialization_alias="clickTolerance")
    custom_attribution: bool = Field(None, serialization_alias="customAttribution")
    double_click_zoom: bool = Field(None, serialization_alias="doubleClickZoom")
    fade_duration: int = Field(None, serialization_alias="fadeDuration")
    fit_bounds_options: dict = Field(None, serialization_alias="fitBoundsOptions")
    hash: Union[bool, str] = None
    interactive: bool = None
    keyword: bool = None
    max_bounds: tuple = Field(None, serialization_alias="maxBounds")
    max_pitch: int = Field(None, serialization_alias="maxPitch")
    max_zoom: int = Field(None, serialization_alias="maxZoom")
    min_pitch: int = Field(None, serialization_alias="minPitch")
    min_zoom: int = Field(None, serialization_alias="minZoom")
    pitch: Union[int, float] = None
    scroll_zoom: bool = Field(None, serialization_alias="scrollZoom")
    style: Union[str, Carto, MapTiler, OpenFreeMap, dict, BasemapStyle] = construct_carto_basemap_url(Carto.DARK_MATTER)
    zoom: Union[int, float] = None

    @field_validator("style")
    def validate_style(cls, v):
        if isinstance(v, Carto):
            return construct_carto_basemap_url(v)

        if isinstance(v, MapTiler):
            return construct_maptiler_basemap_url(v)

        if isinstance(v, OpenFreeMap):
            return construct_openfreemap_basemap_url(v)

        if isinstance(v, BasemapStyle):
            return v.to_dict()

        return v


class Map(object):
    """Map

    Args:
        map_options (MapOptions): Map options.
        sources (dict): Sources to be added to the map. Keys are source IDs.
        layers (list): Layers to be added to the map.
        controls (list): Controls to be added to the map.
        **kwargs: Keyword arguments that are appended to the `MapOptions` object.

    Examples:
        >>> from maplibre.map import Map, MapOptions
        >>> map_options = MapOptions(center=(9.5, 51.31667), zoom=8)
        >>> m = Map(map_options)
        >>> dict(m)
        {'mapOptions': {'center': (9.5, 51.31667), 'style': 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json', 'zoom': 8}, 'calls': []}
    """

    MESSAGE = "not implemented yet"

    def __init__(
        self,
        map_options: MapOptions = MapOptions(),
        sources: dict = None,
        layers: list = None,
        controls: list = None,
        **kwargs,
    ):
        self.map_options = (
            map_options.to_dict() | kwargs
        )  # MapOptions(**kwargs).to_dict() # need to fix MapWidget, because height is passed as kwarg
        self._message_queue = []
        self.add_layers(layers, sources)
        if controls:
            for control in controls:
                self.add_control(control)

    def __iter__(self):
        for k, v in self.to_dict().items():
            yield k, v

    def to_dict(self) -> dict:
        return {"mapOptions": self.map_options, "calls": self._message_queue}

    @property
    def base_layers(self) -> list:
        style = self.map_options["style"]
        if isinstance(style, str) and style.startswith("http"):
            style = BasemapStyle.from_url(style)
        else:
            style = BasemapStyle(**style)

        return style.layers

    """
    @property
    def sources(self) -> list:
        return [item["data"] for item in self._calls if item["name"] == "addSource"]

    @property
    def layers(self) -> list:
        return [item["data"] for item in self._calls if item["name"] == "addLayer"]
    """

    # TODO: Rename to add_map_call
    """
    def add_call_(self, func_name: str, params: list) -> None:
        self._message_queue.append(
            {"name": "applyFunc", "data": {"funcName": func_name, "params": params}}
        )
    """

    def add_call(self, method_name: str, *args) -> None:
        """Add a method call that is executed on the map instance

        Args:
            method_name (str): The name of the map method to be executed.
            *args (any): The arguments to be passed to the map method.
        """
        # TODO: Pass as dict? {"name": method_name, "args": args}
        call = [method_name, args]
        self._message_queue.append(call)

    def add_control(
        self,
        control: Control,
        position: str | ControlPosition = None,
    ) -> None:
        """Add a control to the map

        Args:
            control (Control): The control to be added to the map.
            position (str | ControlPosition): The position of the control.
        """
        position = position or control.position
        self.add_call(
            "addControl",
            control.type,
            control.to_dict(),
            ControlPosition(position).value,
        )

    def add_source(self, id: str, source: Source | dict | GeoDataFrame) -> None:
        """Add a source to the map

        Args:
            id (str): The unique ID of the source.
            source (Source | dict | GeoDataFrame): The source to be added to the map.
        """
        if GeoDataFrame is not None and isinstance(source, GeoDataFrame):
            source = SimpleFeatures(source).to_source()

        if isinstance(source, Source):
            source = source.to_dict()

        self.add_call("addSource", id, source)

    def add_layer(self, layer: Layer | dict, before_id: str = None) -> None:
        """Add a layer to the map

        Args:
            layer (Layer | dict): The Layer to be added to the map.
            before_id (str): The ID of an existing layer to insert the new layer before,
                resulting in the new layer appearing visually beneath the existing layer.
                If `None`, the new layer will appear above all other layers.
        """
        if isinstance(layer, Layer):
            layer = layer.to_dict()

        self.add_call("addLayer", layer, before_id)

    def add_layers(self, layers: list = None, sources: dict = None):
        layers = layers or []
        sources = sources or dict()
        for source_id, source in sources.items():
            self.add_source(source_id, source)

        for layer in layers:
            self.add_layer(layer)

    def add_marker(self, marker: Marker) -> None:
        """Add a marker to the map

        Args:
            marker (Marker): The marker to be added to the map.
        """
        self.add_call("addMarker", marker.to_dict())

    def add_popup(self, layer_id: str, prop: str = None, template: str = None) -> None:
        """Add a popup to the map

        Args:
            layer_id (str): The layer to which the popup is added.
            prop (str): The property of the source to be displayed. If `None`, all properties are displayed.
            template (str): A mustache template. If supplied, `prop` is ignored.
        """
        self.add_call("addPopup", layer_id, prop, template)

    def add_tooltip(self, layer_id: str, prop: str = None, template: str = None) -> None:
        """Add a tooltip to the map

        Args:
            layer_id (str): The layer to which the tooltip is added.
            prop (str): The property of the source to be displayed. If `None`, all properties are displayed.
            template (str): A mustache template. If supplied, `prop` is ignored.

        Examples:
            >>> m = Map()
            >>> # ...
            >>> m.add_tooltip("test-layer", template="Name: {{ name }}")
        """
        self.add_call("addTooltip", layer_id, prop, template)

    def set_filter(self, layer_id: str, filter_: list):
        """Update the filter of a layer

        Args:
            layer_id (str): The name of the layer to be updated.
            filter_ (list): The filter expression that is applied to the source of the layer.
        """
        self.add_call("setFilter", layer_id, filter_)

    def set_paint_property(self, layer_id: str, prop: str, value: any) -> None:
        """Update the paint property of a layer

        Args:
            layer_id (str): The name of the layer to be updated.
            prop (str): The name of the paint property to be updated.
            value (any): The new value of the paint property.
        """
        self.add_call("setPaintProperty", layer_id, prop, value)

    def set_layout_property(self, layer_id: str, prop: str, value: any) -> None:
        """Update a layout property of a layer

        Args:
            layer_id (str): The name of the layer to be updated.
            prop (str): The name of the layout property to be updated.
            value (any): The new value of the layout property.
        """
        self.add_call("setLayoutProperty", layer_id, prop, value)

    def set_data(self, source_id: str, data: dict | GeoDataFrame) -> None:
        """Update the data of a GeoJSON source

        Args:
            source_id (str): The name of the source to be updated.
            data (dict): The data of the source.
        """
        if isinstance(data, GeoDataFrame):
            data = SimpleFeatures(data).to_source().data

        self.add_call("setSourceData", source_id, data)

    def set_visibility(self, layer_id: str, visible: bool = True) -> None:
        """Update the visibility of a layer

        Args:
            layer_id (str): The name of the layer to be updated.
            visible (bool): Whether the layer is visible or not.
        """
        value = "visible" if visible else "none"
        self.add_call("setLayoutProperty", layer_id, "visibility", value)

    def fit_bounds(
        self,
        bounds: tuple | list = None,
        data: GeoDataFrame = None,
        animate=False,
        **kwargs,
    ) -> None:
        """Pan and zoom the map to contain its visible area within the specified geographical bounds"""
        kwargs["animate"] = animate
        if data is not None:
            bounds = tuple(data.total_bounds)

        self.add_call("fitBounds", bounds, kwargs)

    def set_projection(self, type: str | ProjectionType | list = ProjectionType.GLOBE) -> None:
        """Set the projection of the map"""
        self.add_call("setProjection", dict(type=type))

    def set_terrain(self, source: str, exaggeration: int | float = 1) -> None:
        """Load a 3d terrain mesh based on a 'raster-dem' source"""
        self.add_call("setTerrain", Terrain(source=source, exaggeration=exaggeration).to_dict())

    def set_sky(self, sky: Sky | dict) -> None:
        """Set sky"""
        if isinstance(sky, Sky):
            sky = sky.to_dict()

        self.add_call("setSky", sky)

    def set_light(self, light: Light | dict) -> None:
        """Set light"""
        if isinstance(light, Light):
            light = light.to_dict()

        self.add_call("setLight", light)

    def to_html(self, title: str = "My Awesome Map", **kwargs) -> str:
        """Render to html

        Args:
            title (str): The Title of the HTML document.
            **kwargs (Any): Additional keyword arguments that are passed to the template.
                Currently, `style` is the only supported keyword argument.

        Examples:
            >>> from maplibre import Map
            >>> m = Map()
            >>> with open("/tmp/map.html", "w") as f:
            ...     f.write(m.to_html(style="height: 800px;") # doctest: +SKIP
        """
        js_lib = read_internal_file("srcjs", "pywidget.js")
        js_snippet = Template(js_template).render(data=json.dumps(self.to_dict()))
        css = read_internal_file("srcjs", "pywidget.css")
        headers = [f"<style>{css}</style>"]

        # Deck.GL headers
        add_deckgl_headers = "addDeckOverlay" in [item[0] for item in self._message_queue]
        # TODO: Set version in constants
        deckgl_headers = (
            [
                # '<script src="https://unpkg.com/h3-js"></script>',
                '<script src="https://unpkg.com/h3-js@4.1.0/dist/h3-js.umd.js"></script>',
                '<script src="https://unpkg.com/deck.gl@9.0.16/dist.min.js"></script>',
                '<script src="https://unpkg.com/@deck.gl/json@9.0.16/dist.min.js"></script>',
            ]
            if add_deckgl_headers
            else []
        )

        # Mapbox Draw headers
        add_mapbox_draw_headers = "addMapboxDraw" in [item[0] for item in self._message_queue]
        # TODO: Set version in constants
        mapbox_draw_headers = (
            [
                # "<script src='https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-draw/v1.4.3/mapbox-gl-draw.js'></script>",
                # "<link rel='stylesheet' href='https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-draw/v1.4.3/mapbox-gl-draw.css' type='text/css' />",
                "<script src='https://www.unpkg.com/@mapbox/mapbox-gl-draw@1.4.3/dist/mapbox-gl-draw.js'></script>",
                "<link rel='stylesheet' href='https://www.unpkg.com/@mapbox/mapbox-gl-draw@1.4.3/dist/mapbox-gl-draw.css' type='text/css' />",
            ]
            if add_mapbox_draw_headers
            else []
        )

        output = Template(html_template).render(
            js="\n".join([js_lib, js_snippet]),
            title=title,
            headers=headers + deckgl_headers + mapbox_draw_headers,
            **kwargs,
        )
        return output

    def save(self, filename: str = None, preview=True, **kwargs):
        """Save the map to an HTML file"""
        return save_map(self, filename, preview, **kwargs)

    # -------------------------
    # Plugins
    # -------------------------
    def add_deck_layers(self, layers: list[dict | "pydeck.Layer"], tooltip: str | dict = None) -> None:
        """Add Deck.GL layers to the layer stack

        Args:
            layers (list[dict | "pydeck.Layer"]): A list of dictionaries containing the Deck.GL layers to be added.
            tooltip (str | dict): Either a single mustache template string applied to all layers
                or a dictionary where keys are layer ids and values are mustache template strings.
        """
        layers = parse_deck_layers(layers)
        self.add_call("addDeckOverlay", layers, tooltip)

    def set_deck_layers(self, layers: list[dict | "pydeck.Layer"], tooltip: str | dict = None) -> None:
        """Update Deck.GL layers

        Args:
            layers (list[dict | "pydeck.Layer"]): A list of dictionaries containing the Deck.GL layers to be updated.
                New layers will be added. Missing layers will be removed.
            tooltip (str | dict): Must be set to keep tooltip even if it did not change.
        """
        layers = parse_deck_layers(layers)
        self.add_call("setDeckLayers", layers, tooltip)

    def add_mapbox_draw(
        self,
        options: dict | MapboxDrawOptions = None,
        position: str | ControlPosition = ControlPosition.TOP_LEFT,
        geojson: dict = None,
    ) -> None:
        """Add MapboxDraw controls to the map

        Note:
            See [MapboxDraw API Reference](https://github.com/mapbox/mapbox-gl-draw/blob/main/docs/API.md)
                for available options.

        Args:
            options (dict | MapboxDrawOptions): MapboxDraw options.
            position (str | ControlPosition): The position of the MapboxDraw controls.
            geojson (dict): A GeoJSON Feature, FeatureCollection or Geometry to be added to the draw layer.
        """
        if isinstance(options, MapboxDrawOptions):
            options = options.to_dict()

        self.add_call("addMapboxDraw", options or {}, ControlPosition(position).value, geojson)


def save_map(m: Map, filename: str = None, preview=True, **kwargs) -> str:
    if not filename:
        filename = get_temp_filename()

    with open(filename, "w") as f:
        f.write(m.to_html(**kwargs))

    if preview:
        webbrowser.open(filename)

    return filename
