from typing import Optional
from collections import UserDict
import requests
import os
try:
    import orjson as json
except ImportError:
    try:
        import rapidjson as json
    except ImportError:
        try:
            import simplejson as json
        except ImportError:
            import json
import geojson
from validator_collection import validators, checkers
from highcharts_maps import errors, utility_functions
from highcharts_maps.decorators import class_sensitive
from highcharts_maps.metaclasses import HighchartsMeta
from highcharts_maps.utility_classes.topojson import Topology
from highcharts_maps.utility_classes.javascript_functions import CallbackFunction
from highcharts_maps.utility_classes.fetch_configuration import FetchConfiguration
[docs]class MapData(HighchartsMeta):
    """The :term:`map geometry` data which defines the areas and features of the map
    itself."""
    def __init__(self, **kwargs):
        self._force_geojson = None
        self._topology = None
        self.force_geojson = kwargs.get('force_geojsons', None)
        self.topology = kwargs.get('topology', None)
    @property
    def force_geojson(self) -> Optional[bool]:
        """If ``True``, will serialize as :term:`GeoJSON`. If ``False``, will serialize
        as :term:`TopoJSON` which sends less data over the wire. Defaults to ``False``.
        :rtype: :class:`bool <python:bool>`
        """
        return self._force_geojson
    @force_geojson.setter
    def force_geojson(self, value):
        if value is None:
            self._force_geojson = None
        else:
            self._force_geojson = bool(value)
    @property
    def topology(self) -> Optional[Topology]:
        """The :term:`topology` that defines the map areas that should be rendered in the
        map.
        :rtype: :class:`Topology <highcharts_maps.utility_classes.topojson.Topology>`
        """
        return self._topology
    @topology.setter
    def topology(self, value):
        is_file = checkers.is_file(value)
        if not is_file and isinstance(value, (str, bytes)):
            try:
                value_as_path = os.path.abspath(value)
                is_file = os.path.isfile(value_as_path)
                if is_file:
                    value = value_as_path
            except TypeError:
                is_file = False
        if not value:
            self._topology = None
        elif is_file:
            with open(value, 'r') as as_file:
                try:
                    as_dict = json.load(as_file)
                except AttributeError:
                    as_str = as_file.read()
                    as_dict = json.loads(as_str)
            if 'data' in as_dict.get('objects', {}):
                self._topology = Topology(as_dict, object_name = 'data')
            elif 'default' in as_dict.get('objects', {}):
                self._topology = Topology(as_dict, object_name = 'default')
            else:
                self._topology = Topology(as_dict)
        elif checkers.is_url(value):
            request = requests.get(value)
            request.raise_for_status()
            as_dict = json.loads(request.content)
            if 'data' in as_dict.get('objects', {}):
                self._topology = Topology(as_dict, object_name = 'data')
            elif 'default' in as_dict.get('objects', {}):
                self._topology = Topology(as_dict, object_name = 'default')
            else:
                self._topology = Topology(as_dict)
        elif isinstance(value, Topology):
            self._topology = value
        elif checkers.is_type(value, 'GeoDataFrame'):
            self._topology = Topology(value, prequantize = False)
        elif isinstance(value, (str, bytes)):
            if '"data"' in value:
                self._topology = Topology(value, object_name = 'data')
            elif '"default"' in value:
                self._topology = Topology(value, object_name = 'default')
            else:
                try:
                    as_dict = json.load(value)
                except AttributeError:
                    as_dict = json.loads(value)
            if 'data' in as_dict.get('objects', {}):
                self._topology = Topology(as_dict, object_name = 'data')
            elif 'default' in as_dict.get('objects', {}):
                self._topology = Topology(as_dict, object_name = 'default')
            else:
                self._topology = Topology(as_dict)
        elif isinstance(value, (dict, UserDict)):
            if 'data' in value.get('objects', {}):
                self._topology = Topology(value, object_name = 'data')
            elif 'default' in value.get('objects', {}):
                self._topology = Topology(value, object_name = 'default')
            else:
                self._topology = Topology(value)
        elif checkers.is_iterable(value, forbid_literals = (str, bytes, dict)):
            data = []
            object_names = []
            is_GeoDataFrame = False
            for count, item in enumerate(value):
                data.append(item)
                object_names.append(f'obj_{count}')
                if checkers.is_type(item, 'GeoDataFrame'):
                    is_GeoDataFrame = True
            if is_GeoDataFrame:
                self._topology = Topology(data = data,
                                          object_name = object_names,
                                          prequantize = False)
            else:
                self._topology = Topology(data = data,
                                          object_name = object_names)
        else:
            try:
                self._topology = Topology(value)
            except (json.JSONDecodeError, ValueError, TypeError):
                raise errors.HighchartsValueError(f'Unable to deserialize a topology from'
                                                  f' the value supplied: {value}')
    @classmethod
    def _get_kwargs_from_dict(cls, as_dict):
        if ('forceGeoJSON' in as_dict
            or 'force_geojson' in as_dict
            or 'topology' in as_dict):
            kwargs = {
                'force_geojson': as_dict.get('force_geojson',
                                             as_dict.get('forceGeoJSON', False)),
                'topology': as_dict.get('topology', None),
            }
        else:
            kwargs = {
                'topology': as_dict
            }
        return kwargs
[docs]    @classmethod
    def from_dict(cls,
                  as_dict: dict,
                  allow_snake_case: bool = True):
        """Construct an instance of the class from a :class:`dict <python:dict>` object.
        :param as_dict: A :class:`dict <python:dict>` representation of the object.
        :type as_dict: :class:`dict <python:dict>`
        :param allow_snake_case: If ``True``, interprets ``snake_case`` keys as equivalent
          to ``camelCase`` keys. Defaults to ``True``.
        :type allow_snake_case: :class:`bool <python:bool>`
        :returns: A Python object representation of ``as_dict``.
        :rtype: :class:`HighchartsMeta`
        """
        as_dict = validators.dict(as_dict, allow_empty = True) or {}
        if ('forceGeoJSON' in as_dict or 'force_geojson' in as_dict
            or ('topology' in as_dict and len(as_dict) == 1)):
            clean_as_dict = {}
            for key in as_dict:
                if allow_snake_case:
                    clean_key = utility_functions.to_camelCase(key)
                else:
                    clean_key = key
                clean_as_dict[clean_key] = as_dict[key]
            kwargs = cls._get_kwargs_from_dict(clean_as_dict)
        else:
            kwargs = {
                'topology': as_dict
            }
        return cls(**kwargs) 
    def _to_untrimmed_dict(self, in_cls = None) -> dict:
        untrimmed = {
            'forceGeoJSON': self.force_geojson,
            'topology': self.topology
        }
        return untrimmed
[docs]    def to_json(self,
                filename = None,
                encoding = 'utf-8'):
        """Generate a JSON string/byte string representation of the object compatible with
        the Highcharts JavaScript library.
        .. note::
          This method will either return a standard :class:`str <python:str>` or a
          :class:`bytes <python:bytes>` object depending on the JSON serialization library
          you are using. For example, if your environment has
          `orjson <https://github.com/ijl/orjson>`_, the result will be a
          :class:`bytes <python:bytes>` representation of the string.
        :param filename: The name of a file to which the JSON string should be persisted.
          Defaults to :obj:`None <python:None>`
        :type filename: Path-like
        :param encoding: The character encoding to apply to the resulting object. Defaults
          to ``'utf-8'``.
        :type encoding: :class:`str <python:str>`
        :returns: A JSON representation of the object compatible with the Highcharts
          library.
        :rtype: :class:`str <python:str>` or :class:`bytes <python:bytes>`
        """
        if filename:
            filename = validators.path(filename)
        if not self.force_geojson:
            as_json = self.topology.to_json()
        else:
            as_json = self.topology.to_geojson()
        if filename:
            if isinstance(as_json, bytes):
                write_type = 'wb'
            else:
                write_type = 'w'
            with open(filename, write_type, encoding = encoding) as file_:
                file_.write(as_json)
        return as_json 
[docs]    @classmethod
    def from_json(cls,
                  as_json_or_file: str | bytes,
                  allow_snake_case: bool = True):
        """Construct an instance of the class from a JSON string.
        :param as_json_or_file: The JSON string for the object or the filename of a file
          that contains the JSON string.
        :type as_jsonor_file: :class:`str <python:str>` or :class:`bytes <python:bytes>`
        :param allow_snake_case: If ``True``, interprets ``snake_case`` keys as equivalent
          to ``camelCase`` keys. Defaults to ``True``.
        :type allow_snake_case: :class:`bool <python:bool>`
        :returns: A Python objcet representation of ``as_json``.
        :rtype: :class:`MapData`
        """
        is_file = checkers.is_file(as_json_or_file)
        if is_file:
            with open(as_json_or_file, 'r') as file_:
                as_str = file_.read()
        else:
            as_str = as_json_or_file
        as_dict = json.loads(as_str)
        return cls.from_dict(as_dict,
                             allow_snake_case = allow_snake_case) 
[docs]    def to_js_literal(self,
                      filename = None,
                      encoding = 'utf-8') -> Optional[str]:
        """Return the object represented as a :class:`str <python:str>` containing the
        JavaScript object literal.
        :param filename: The name of a file to which the JavaScript object literal should
          be persisted. Defaults to :obj:`None <python:None>`
        :type filename: Path-like
        :param encoding: The character encoding to apply to the resulting object. Defaults
          to ``'utf-8'``.
        :type encoding: :class:`str <python:str>`
        :rtype: :class:`str <python:str>` or :obj:`None <python:None>`
        """
        if filename:
            filename = validators.path(filename)
        as_json = self.to_json(encoding = encoding)
        if filename:
            if isinstance(as_json, bytes):
                write_type = 'wb'
            else:
                write_type = 'w'
            with open(filename, write_type, encoding = encoding) as file_:
                file_.write(as_json)
        return as_json 
[docs]    def to_geojson(self,
                   filename = None,
                   encoding = 'utf-8'):
        """Generate a :term:`GeoJSON` string/byte string representation of the object.
        .. note::
          This method will either return a standard :class:`str <python:str>` or a
          :class:`bytes <python:bytes>` object depending on the JSON serialization library
          you are using. For example, if your environment has
          `orjson <https://github.com/ijl/orjson>`_, the result will be a
          :class:`bytes <python:bytes>` representation of the string.
        :param filename: The name of a file to which the JSON string should be persisted.
          Defaults to :obj:`None <python:None>`
        :type filename: Path-like
        :param encoding: The character encoding to apply to the resulting object. Defaults
          to ``'utf-8'``.
        :type encoding: :class:`str <python:str>`
        :returns: A :term:`GeoJSON` representation of the object
        :rtype: :class:`str <python:str>` or :class:`bytes <python:bytes>`
        """
        if filename:
            filename = validators.path(filename)
        as_geojson = self.topology.to_geojson()
        if filename:
            if isinstance(as_geojson, bytes):
                write_type = 'wb'
            else:
                write_type = 'w'
            with open(filename, write_type, encoding = encoding) as file_:
                file_.write(as_geojson)
        return as_geojson 
[docs]    @classmethod
    def from_geojson(cls,
                     as_geojson_or_file: str | bytes,
                     allow_snake_case: bool = True):
        """Construct an instance of the class from a JSON string.
        :param as_geojson_or_file: The :term:`GeoJSON` string for the object or the
          filename of a file that contains the GeoJSON string.
        :type as_geojson_or_file: :class:`str <python:str>` or
          :class:`bytes <python:bytes>`
        :param allow_snake_case: If ``True``, interprets ``snake_case`` keys as equivalent
          to ``camelCase`` keys. Defaults to ``True``.
        :type allow_snake_case: :class:`bool <python:bool>`
        :returns: A Python objcet representation of ``as_geojson_or_file``.
        :rtype: :class:`MapData`
        """
        return cls.from_json(as_geojson_or_file, allow_snake_case = allow_snake_case) 
[docs]    def to_topojson(self,
                    filename = None,
                    encoding = 'utf-8'):
        """Generate a :term:`TopoJSON` string/byte string representation of the object.
        .. note::
          This method will either return a standard :class:`str <python:str>` or a
          :class:`bytes <python:bytes>` object depending on the JSON serialization library
          you are using. For example, if your environment has
          `orjson <https://github.com/ijl/orjson>`_, the result will be a
          :class:`bytes <python:bytes>` representation of the string.
        :param filename: The name of a file to which the JSON string should be persisted.
          Defaults to :obj:`None <python:None>`
        :type filename: Path-like
        :param encoding: The character encoding to apply to the resulting object. Defaults
          to ``'utf-8'``.
        :type encoding: :class:`str <python:str>`
        :returns: A :term:`TopoJSON` representation of the object
        :rtype: :class:`str <python:str>` or :class:`bytes <python:bytes>`
        """
        if filename:
            filename = validators.path(filename)
        as_topojson = self.topology.to_json()
        if filename:
            if isinstance(as_topojson, bytes):
                write_type = 'wb'
            else:
                write_type = 'w'
            with open(filename, write_type, encoding = encoding) as file_:
                file_.write(as_topojson)
        return as_topojson 
[docs]    @classmethod
    def from_topojson(cls,
                      as_topojson_or_file: str | bytes,
                      allow_snake_case: bool = True):
        """Construct an instance of the class from a :term:`TopoJSON` string.
        :param as_topojson_or_file: The :term:`TopoJSON` string for the object or the
          filename of a file that contains the TopoJSON string.
        :type as_topojson_or_file: :class:`str <python:str>` or
          :class:`bytes <python:bytes>`
        :param allow_snake_case: If ``True``, interprets ``snake_case`` keys as equivalent
          to ``camelCase`` keys. Defaults to ``True``.
        :type allow_snake_case: :class:`bool <python:bool>`
        :returns: A Python objcet representation of ``as_topojson_or_file``.
        :rtype: :class:`MapData`
        """
        return cls.from_json(as_topojson_or_file, allow_snake_case = allow_snake_case) 
[docs]    def to_geodataframe(self, object_name = None):
        """Generate a :class:`geopandas.GeoDataFrame <geopandas:GeoDataFrame>` instance
        of the :term:`map geometry`.
        :param object_name: If the map data contains multiple objects, you can generate
          serialize a specific object by specifying its name or index. Defaults to
          :obj:`None <python:None>`, which behaves as an index of 0.
        :type object_name: :class:`str <python:str>` or :class:`int <python:int>` or
          :obj:`None <python:None>`
        :rtype: :class:`geopandas.GeoDataFrame <geopandas:GeoDataFrame>`
        """
        return self.topology.to_gdf(object_name = object_name) 
[docs]    @classmethod
    def from_geodataframe(cls, as_gdf, prequantize = False, **kwargs):
        """Create a :class:`MapData` instance from a
        :class:`geopandas.GeoDataFrame <geopandas:GeoDataFrame>`.
        :param as_gdf: The :class:`geopandas.GeoDataFrame <geopandas:GeoDataFrame>`
          containing the :term:`map geometry`.
        :type as_gdf: :class:`geopandas.GeoDataFrame <geopandas:GeoDataFrame>`
        :param prequantize: If ``True``, will perform the TopoJSON optimizations
          ("quantizing the topology") before generating the :class:`Topology` instance.
          Defaults to ``False``.
        :type prequantize: :class:`bool <python:bool>`
        
        :param kwargs: additional keyword arguments which are passed to the
          :class:`Topology` constructor
        :type kwargs: :class:`dict <python:dict>`
        :rtype: :class:`MapData <highcharts_maps.options.series.data.map_data.MapData>`
        """
        topology = Topology(as_gdf, prequantize = prequantize, **kwargs)
        return cls(topology = topology) 
[docs]    @classmethod
    def from_shapefile(cls, shp_filename):
        """Create a :class:`MapData` instance from an :term:`ESRI Shapefile <shapefile>`.
        :param shp_filename: The full filename of an :term:`ESRI Shapefile <shapefile>`
          to load.
          .. note::
            :term:`ESRI Shapefiles <shapefile>` are actually composed of three files each,
            with one file receiving the ``.shp`` extension, one with a ``.dbf`` extension,
            and one (optional) file with a ``.shx`` extension.
            **Highcharts Maps for Python** will resolve all three files given a single
            base filename. Thus:
              ``/my-shapefiles-folder/my_shapefile.shp``
            will successfully load data from the three files:
              ``/my-shapefiles-folder/my_shapefile.shp``
              ``/my-shapefiles-folder/my_shapefile.dbf``
              ``/my-shapefiles-folder/my_shapefile.shx``
          .. tip::
            **Highcharts for Python** will also correctly load and unpack
            :term:`shapefiles <shapefile>` that are grouped together within a ZIP file.
        :type shp_filename: :class:`str <python:str>` or
          :class:`bytes <python:bytes>`
        :rtype: :class:`MapData <highcharts_maps.options.series.data.map_data.MapData>`
        """
        try:
            import shapefile
        except ImportError:
            raise errors.HighchartsDependencyError('MapSeriesBase.from_shapefile() '
                                                   'requires PyShp be installed. '
                                                   'However, it was not found in the '
                                                   'runtime environment.')
        shp_filename = validators.file_exists(shp_filename)
        data = shapefile.Reader(shp_filename)
        topology = Topology(data)
        return cls(topology = topology)  
[docs]class AsyncMapData(HighchartsMeta):
    """Configuration of :term:`map geometry` which
    `Highcharts Maps <https://www.highcharts.com/products/maps>`__ should fetch
    *asynchronously* using client-side JavaScript.
    .. note::
      When serialized to a JS literal will execute an async (JavaScript) ``fetch()`` call
      to download the map data from
      :meth:`.url <highcharts_maps.options.series.data.map_data.AsyncMapData.url>`.
    """
    def __init__(self, **kwargs):
        self._url = None
        self._selector = None
        self._fetch_config = None
        self._fetch_counter = None
        self.url = kwargs.get('url', None)
        self.selector = kwargs.get('selector', None)
        self.fetch_config = kwargs.get('fetch_config', None)
        self.fetch_counter = kwargs.get('fetch_counter', None)
    @property
    def url(self) -> Optional[str]:
        """The URL that the (JavaScript) ``fetch()`` function will be requesting, which
        should return either :term:`TopoJSON` or :term:`GeoJSON` data. Defaults to
        :obj:`None <python:None>`.
        .. note::
          This property will *overwrite* any URL configured within
          :meth:`.fetch_config <highcharts_maps.options.series.data.map_data.AsyncMapData.fetch_config>`.
        :rtype: :class:`str <python:str>` or :obj:`None <python:None>`
        """
        return self._url
    @url.setter
    def url(self, value):
        self._url = validators.url(value, allow_empty = True)
    @property
    def selector(self) -> Optional[CallbackFunction]:
        """An optional (JavaScript) function which receives the :term:`map geometry`
        downloaded from
        :meth:`.url <highcharts_maps.options.series.data.map_data.AsyncMapData.url>`, can
        perform some (arbitrary - it's up to you!) operation on that data, and returns a
        new set of :term:`map geometries <map geometry>` which will be visualized by
        `Highcharts for Maps <https://www.highcharts.com/products/maps/>`__. Defaults to
        :obj:`None <python:None>`.
        .. note::
          The ``selector`` function *must* accept a single argument: ``topology`` and
          *must* return a single value (which will be assigned to the JavaScript variable
          named ``topology``).
        :rtype: :class:`CallbackFunction <highcharts_maps.utility_classes.javascript_functions.CallbackFunction>`
          or :obj:`None <python:None>`
        """
        return self._selector
    @selector.setter
    @class_sensitive(CallbackFunction)
    def selector(self, value):
        self._selector = value
    @property
    def fetch_config(self) -> Optional[FetchConfiguration]:
        """Optional configuration settings to use when executing the (JavaScript)
        asynchronous ``fetch()`` call to download the :term:`map geometry`. Defaults to
        :obj:`None <python:None>`.
        .. note::
          The :meth:`.url <highcharts_maps.options.series.data.map_data.AsyncMapData.url>`.
          setting will override any URL set in the
          :class:`FetchConfiguration <highcharts_maps.utility_classes.fetch_configuration.FetchConfiguration>`.
        :rtype: :class:`FetchConfiguration <highcharts_maps.utility_classes.fetch_configuration.FetchConfiguration>`
          or :obj:`None <python:None>`
        """
        return self._fetch_config
    @fetch_config.setter
    @class_sensitive(FetchConfiguration)
    def fetch_config(self, value):
        self._fetch_config = value
        if not self._fetch_config and self.url:
            self._fetch_config = FetchConfiguration(url = self.url)
        elif self.url:
            self.fetch_config.url = self.url
    @property
    def fetch_counter(self) -> int:
        """The number to append to the ``topology`` variable name when serializing the
        map data. Defaults to ``0``.
        :rtype: :class:`int <python:int>`
        """
        return self._fetch_counter
    @fetch_counter.setter
    def fetch_counter(self, value):
        self._fetch_counter = validators.integer(value, allow_empty = True, minimum = 0)
    @classmethod
    def _get_kwargs_from_dict(cls, as_dict):
        kwargs = {
            'url': as_dict.get('url', None),
            'selector': as_dict.get('selector', None),
            'fetch_config': as_dict.get('fetchConfig', as_dict.get('fetch_config', None)),
            'fetch_counter': as_dict.get('fetchCounter',
                                         as_dict.get('fetch_counter', None)),
        }
        return kwargs
    def _to_untrimmed_dict(self, in_cls = None) -> dict:
        untrimmed = {
            'url': self.url,
            'selector': self.selector,
            'fetchConfig': self.fetch_config,
            'fetchCounter': self.fetch_counter,
        }
        return untrimmed
[docs]    def to_js_literal(self,
                      filename = None,
                      encoding = 'utf-8') -> Optional[str]:
        """Return the object represented as a :class:`str <python:str>` containing the
        JavaScript object literal.
        :param filename: The name of a file to which the JavaScript object literal should
          be persisted. Defaults to :obj:`None <python:None>`
        :type filename: Path-like
        :param encoding: The character encoding to apply to the resulting object. Defaults
          to ``'utf-8'``.
        :type encoding: :class:`str <python:str>`
        :rtype: :class:`str <python:str>` or :obj:`None <python:None>`
        """
        if filename:
            filename = validators.path(filename)
        if self.fetch_config:
            self.fetch_config.url = self.url
            fetch_config = self.fetch_config
        else:
            fetch_config = FetchConfiguration(self.url)
            
        if self.selector:
            function = f"""const selector = {str(self.selector)};\n"""
            fetch = f"""const topology = await {str(fetch_config)}.then(response => selector(response.json()));"""
        else:
            function = ''
            fetch = f"""const topology = await {str(fetch_config)}.then(response => response.json());"""
        if self.fetch_counter and self.fetch_counter > 0:
            fetch = fetch.replace('const topology', f'const topology{self.fetch_counter}')
        as_str = f'{function}{fetch}'
        if filename:
            with open(filename, 'w', encoding = encoding) as file_:
                file_.write(as_str)
        return as_str