"""
Module implementing geometry classes
"""
import warnings
from abc import ABC, abstractmethod
from math import ceil

import shapely.ops
import shapely.geometry
import shapely.wkt

from .constants import CRS
from .exceptions import SHDeprecationWarning
from .geo_utils import transform_point


class BaseGeometry(ABC):
    """ Base geometry class
    """
    def __init__(self, crs):
        """
        :param crs: Coordinate reference system of the geometry
        :type crs: constants.CRS
        """
        self._crs = CRS(crs)

    def _repr_svg_(self):
        """ Using shapely's svg geometry visualization for Jupyter notebooks
        """
        # pylint: disable=protected-access
        return self.geometry._repr_svg_()

    @property
    def crs(self):
        """ Returns the coordinate reference system (CRS)

        :return: Coordinate reference system Enum
        :rtype: constants.CRS
        """
        return self._crs

    def get_crs(self):
        """ Returns the coordinate reference system (CRS)

        :return: Coordinate reference system Enum
        :rtype: constants.CRS
        """
        message = 'This method will be removed in version 3.0, use property {}.crs ' \
                  'instead'.format(self.__class__.__name__)
        warnings.warn(message, category=SHDeprecationWarning)
        return self.crs

    @property
    @abstractmethod
    def geometry(self):
        """ An abstract property - ever subclass must implement geometry property
        """
        raise NotImplementedError

    def get_geometry(self):
        """ Returns shapely geometry

        :return: A polygon or multipolygon
        :rtype: shapely.geometry.Polygon or shapely.geometry.MultiPolygon
        """
        message = 'This method will be removed in version 3.0, use property {}.geometry ' \
                  'instead'.format(self.__class__.__name__)
        warnings.warn(message, category=SHDeprecationWarning)
        return self.geometry

    @property
    def geojson(self):
        """ Returns representation in a GeoJSON format. Use json.dump for writing it to file.

        :return: A dictionary in GeoJSON format
        :rtype: dict
        """
        return {
            **self._crs_to_geojson(),
            **shapely.geometry.mapping(self.geometry)
        }

    def get_geojson(self):
        """ Returns representation in a GeoJSON format. Use json.dump for writing it to file.

        :return: A dictionary in GeoJSON format
        :rtype: dict
        """
        return self.geojson

    def _crs_to_geojson(self):
        """ Helper method which generates part of GeoJSON format related to CRS
        """
        return {
            'crs': {
                'type': 'name',
                'properties': {'name': 'urn:ogc:def:crs:EPSG::{}'.format(self.crs.value)}
            }
        }

    @property
    def wkt(self):
        """ Transforms geometry object into `Well-known text` format

        :return: string in WKT format
        :rtype: str
        """
        return self.geometry.wkt


class BBox(BaseGeometry):
    """ Class representing a bounding box in a given CRS.

    Throughout the sentinelhub package this class serves as the canonical representation of a bounding
    box. It can initialize itself from multiple representations:

        1) ``((min_x,min_y),(max_x,max_y))``,
        2) ``(min_x,min_y,max_x,max_y)``,
        3) ``[min_x,min_y,max_x,max_y]``,
        4) ``[[min_x, min_y],[max_x,max_y]]``,
        5) ``[(min_x, min_y),(max_x,max_y)]``,
        6) ``([min_x, min_y],[max_x,max_y])``,
        7) ``'min_x,min_y,max_x,max_y'``,
        8) ``{'min_x':min_x, 'max_x':max_x, 'min_y':min_y, 'max_y':max_y}``,
        9) ``bbox``, where ``bbox`` is an instance of ``BBox``.

    Note that BBox coordinate system depends on ``crs`` parameter:
        - In case of ``constants.CRS.WGS84`` axis x represents longitude and axis y represents latitude
        - In case of ``constants.CRS.POP_WEB`` axis x represents easting and axis y represents northing
        - In case of ``constants.CRS.UTM_*`` axis x represents easting and axis y represents northing
    """
    def __init__(self, bbox, crs):
        """
        :param bbox: A bbox in any valid representation
        :param crs: Coordinate reference system of the bounding box
        :type crs: constants.CRS
        """
        x_fst, y_fst, x_snd, y_snd = BBox._to_tuple(bbox)
        self.min_x = min(x_fst, x_snd)
        self.max_x = max(x_fst, x_snd)
        self.min_y = min(y_fst, y_snd)
        self.max_y = max(y_fst, y_snd)

        super().__init__(crs)

    def __iter__(self):
        """ This method enables iteration over coordinates of bounding box
        """
        return iter(self.lower_left + self.upper_right)

    def __repr__(self):
        """ Class representation
        """
        return '{}((({}, {}), ({}, {})), crs={})'.format(self.__class__.__name__, *self, self.crs)

    def __str__(self, reverse=False):
        """ Transforms bounding box into a string of coordinates

        :param reverse: `True` if x and y coordinates should be switched and `False` otherwise
        :type reverse: bool
        :return: String of coordinates
        :rtype: str
        """
        if reverse:
            return '{},{},{},{}'.format(self.min_y, self.min_x, self.max_y, self.max_x)
        return '{},{},{},{}'.format(self.min_x, self.min_y, self.max_x, self.max_y)

    def __eq__(self, other):
        """ Method for comparing two bounding boxes

        :param other: Another bounding box object
        :type other: BBox
        :return: `True` if bounding boxes have the same coordinates and the same CRS and `False otherwise
        :rtype: bool
        """
        return list(self) == list(other) and self.crs == other.crs

    @property
    def lower_left(self):
        """ Returns the lower left vertex of the bounding box

        :return: min_x, min_y
        :rtype: (float, float)
        """
        return self.min_x, self.min_y

    def get_lower_left(self):
        """ Returns the lower left vertex of the bounding box

        :return: min_x, min_y
        :rtype: (float, float)
        """
        message = 'This method will be removed in version 3.0, use property BBox.lower_left instead'
        warnings.warn(message, category=SHDeprecationWarning)
        return self.lower_left

    @property
    def upper_right(self):
        """ Returns the upper right vertex of the bounding box

        :return: max_x, max_y
        :rtype: (float, float)
        """
        return self.max_x, self.max_y

    def get_upper_right(self):
        """ Returns the upper right vertex of the bounding box

        :return: max_x, max_y
        :rtype: (float, float)
        """
        message = 'This method will be removed in version 3.0, use property BBox.upper_right instead'
        warnings.warn(message, category=SHDeprecationWarning)
        return self.upper_right

    @property
    def middle(self):
        """ Returns the middle point of the bounding box

        :return: middle point
        :rtype: (float, float)
        """
        return (self.min_x + self.max_x) / 2, (self.min_y + self.max_y) / 2

    def get_middle(self):
        """ Returns the middle point of the bounding box

        :return: middle point
        :rtype: (float, float)
        """
        message = 'This method will be removed in version 3.0, use property BBox.middle instead'
        warnings.warn(message, category=SHDeprecationWarning)
        return self.middle

    def reverse(self):
        """ Returns a new BBox object where x and y coordinates are switched

        :return: New BBox object with switched coordinates
        :rtype: BBox
        """
        return BBox((self.min_y, self.min_x, self.max_y, self.max_x), crs=self.crs)

    def transform(self, crs):
        """ Transforms BBox from current CRS to target CRS

        :param crs: target CRS
        :type crs: constants.CRS
        :return: Bounding box in target CRS
        :rtype: BBox
        """
        new_crs = CRS(crs)
        return BBox((transform_point(self.lower_left, self.crs, new_crs),
                     transform_point(self.upper_right, self.crs, new_crs)), crs=new_crs)

    def buffer(self, buffer):
        """ Changes both BBox dimensions (width and height) by a percentage of size of each dimension. If number is
        negative, the size will decrease. Returns a new instance of BBox object.

        :param buffer: A percentage of BBox size change
        :type buffer: float
        :return: A new bounding box of buffered size
        :rtype: BBox
        """
        if buffer < -1:
            raise ValueError('Cannot reduce the bounding box to nothing, buffer must be >= -1.0')
        ratio = 1 + buffer
        mid_x, mid_y = self.middle
        return BBox((mid_x - (mid_x - self.min_x) * ratio, mid_y - (mid_y - self.min_y) * ratio,
                     mid_x + (self.max_x - mid_x) * ratio, mid_y + (self.max_y - mid_y) * ratio), self.crs)

    def get_polygon(self, reverse=False):
        """ Returns a tuple of coordinates of 5 points describing a polygon. Points are listed in clockwise order, first
        point is the same as the last.

        :param reverse: `True` if x and y coordinates should be switched and `False` otherwise
        :type reverse: bool
        :return: `((x_1, y_1), ... , (x_5, y_5))`
        :rtype: tuple(tuple(float))
        """
        bbox = self.reverse() if reverse else self
        polygon = ((bbox.min_x, bbox.min_y),
                   (bbox.min_x, bbox.max_y),
                   (bbox.max_x, bbox.max_y),
                   (bbox.max_x, bbox.min_y),
                   (bbox.min_x, bbox.min_y))
        return polygon

    @property
    def geometry(self):
        """ Returns polygon geometry in shapely format

        :return: A polygon in shapely format
        :rtype: shapely.geometry.polygon.Polygon
        """
        return shapely.geometry.Polygon(self.get_polygon())

    def get_partition(self, num_x=None, num_y=None, size_x=None, size_y=None):
        """ Partitions bounding box into smaller bounding boxes of the same size.

        If `num_x` and `num_y` are specified, the total number of BBoxes is know but not the size. If `size_x` and
        `size_y` are provided, the BBox size is fixed but the number of BBoxes is not known in advance. In the latter
        case, the generated bounding boxes might cover an area larger than the parent BBox.

        :param num_x: Number of parts BBox will be horizontally divided into.
        :type num_x: int or None
        :param num_y: Number of parts BBox will be vertically divided into.
        :type num_y: int or None
        :param size_x: Physical dimension of BBox along easting coordinate
        :type size_x: float or None
        :param size_y: Physical dimension of BBox along northing coordinate
        :type size_y: float or None
        :return: Two-dimensional list of smaller bounding boxes. Their location is
        :rtype: list(list(BBox))
        """
        if (num_x is not None and num_y is not None) and (size_x is None and size_y is None):
            size_x, size_y = (self.max_x - self.min_x) / num_x, (self.max_y - self.min_y) / num_y
        elif (size_x is not None and size_y is not None) and (num_x is None and num_y is None):
            num_x, num_y = ceil((self.max_x - self.min_x) / size_x), ceil((self.max_y - self.min_y) / size_y)
        else:
            raise ValueError('Not supported partition. Either (num_x, num_y) or (size_x, size_y) must be specified')

        return [[BBox([self.min_x + i * size_x, self.min_y + j * size_y,
                       self.min_x + (i + 1) * size_x, self.min_y + (j + 1) * size_y],
                      crs=self.crs) for j in range(num_y)] for i in range(num_x)]

    def get_transform_vector(self, resx, resy):
        """ Given resolution it returns a transformation vector

        :param resx: Resolution in x direction
        :type resx: float or int
        :param resy: Resolution in y direction
        :type resy: float or int
        :return: A tuple with 6 numbers representing transformation vector
        :rtype: tuple(float)
        """
        return self.min_x, self._parse_resolution(resx), 0, self.max_y, 0, -self._parse_resolution(resy)

    @staticmethod
    def _parse_resolution(res):
        """ Helper method for parsing given resolution. It will also try to parse a string into float

        :return: A float value of resolution
        :rtype: float
        """
        if isinstance(res, str):
            return float(res.strip('m'))
        if isinstance(res, (int, float)):
            return float(res)

        raise TypeError('Resolution should be a float, got resolution of type {}'.format(type(res)))

    @staticmethod
    def _to_tuple(bbox):
        """ Converts the input bbox representation (see the constructor docstring for a list of valid representations)
        into a flat tuple

        :param bbox: A bbox in one of 7 forms listed in the class description.
        :return: A flat tuple of size
        :raises: TypeError
        """
        if isinstance(bbox, (list, tuple)):
            return BBox._tuple_from_list_or_tuple(bbox)
        if isinstance(bbox, str):
            return BBox._tuple_from_str(bbox)
        if isinstance(bbox, dict):
            return BBox._tuple_from_dict(bbox)
        if isinstance(bbox, BBox):
            return BBox._tuple_from_bbox(bbox)
        if isinstance(bbox, shapely.geometry.base.BaseGeometry):
            return bbox.bounds
        raise TypeError('Invalid bbox representation')

    @staticmethod
    def _tuple_from_list_or_tuple(bbox):
        """ Converts a list or tuple representation of a bbox into a flat tuple representation.

        :param bbox: a list or tuple with 4 coordinates that is either flat or nested
        :return: tuple (min_x,min_y,max_x,max_y)
        :raises: TypeError
        """
        if len(bbox) == 4:
            return tuple(map(float, bbox))
        if len(bbox) == 2 and all([isinstance(point, (list, tuple)) for point in bbox]):
            return BBox._tuple_from_list_or_tuple(bbox[0] + bbox[1])
        raise TypeError('Expected a valid list or tuple representation of a bbox')

    @staticmethod
    def _tuple_from_str(bbox):
        """ Parses a string of numbers separated by any combination of commas and spaces

        :param bbox: e.g. str of the form `min_x ,min_y  max_x, max_y`
        :return: tuple (min_x,min_y,max_x,max_y)
        """
        return tuple([float(s) for s in bbox.replace(',', ' ').split() if s])

    @staticmethod
    def _tuple_from_dict(bbox):
        """ Converts a dictionary representation of a bbox into a flat tuple representation

        :param bbox: a dict with keys "min_x, "min_y", "max_x", and "max_y"
        :return: tuple (min_x,min_y,max_x,max_y)
        :raises: KeyError
        """
        return bbox['min_x'], bbox['min_y'], bbox['max_x'], bbox['max_y']

    @staticmethod
    def _tuple_from_bbox(bbox):
        """ Converts a BBox instance into a tuple

        :param bbox: An instance of the BBox type
        :return: tuple (min_x, min_y, max_x, max_y)
        """
        return bbox.lower_left + bbox.upper_right


class Geometry(BaseGeometry):
    """ A class that combines shapely geometry with coordinate reference system. It currently supports polygons and
    multipolygons.

    It can be initialize with any of the following geometry representations:
    - `shapely.geometry.Polygon` or `shapely.geometry.MultiPolygon`
    - A GeoJSON dictionary with (multi)polygon coordinates
    - A WKT string with (multi)polygon coordinates
    """
    def __init__(self, geometry, crs):
        """
        :param geometry: A polygon or multipolygon in any valid representation
        :type geometry: shapely.geometry.Polygon or shapely.geometry.MultiPolygon or dict or str
        :param crs: Coordinate reference system of the geometry
        :type crs: constants.CRS
        """
        self._geometry = self._parse_geometry(geometry)

        super().__init__(crs)

    def __repr__(self):
        """ Method for class representation
        """
        return '{}({}, crs={})'.format(self.__class__.__name__, self.wkt, self.crs)

    def __eq__(self, other):
        """ Method for comparing two Geometry classes

        :param other: Another Geometry object
        :type other: Geometry
        :return: `True` if geometry objects have the same geometry and CRS and `False` otherwise
        :rtype: bool
        """
        return self.geometry == other.geometry and self.crs == other.crs

    def reverse(self):
        """ Returns a new Geometry object where x and y coordinates are switched

        :return: New Geometry object with switched coordinates
        :rtype: Geometry
        """
        return Geometry(shapely.ops.transform(lambda x, y: (y, x), self.geometry), crs=self.crs)

    def transform(self, crs):
        """ Transforms Geometry from current CRS to target CRS

        :param crs: target CRS
        :type crs: constants.CRS
        :return: Geometry in target CRS
        :rtype: Geometry
        """
        new_crs = CRS(crs)

        geometry = self.geometry
        if new_crs is not self.crs:
            transform_function = self.crs.get_transform_function(new_crs)
            geometry = shapely.ops.transform(transform_function, geometry)

        return Geometry(geometry, crs=new_crs)

    @property
    def geometry(self):
        """ Returns shapely object representing geometry in this class

        :return: A polygon or a multipolygon in shapely format
        :rtype: shapely.geometry.Polygon or shapely.geometry.MultiPolygon
        """
        return self._geometry

    @property
    def bbox(self):
        """ Returns BBox object representing bounding box around the geometry

        :return: A bounding box, with same CRS
        :rtype: BBox
        """
        return BBox(self.geometry, self.crs)

    @staticmethod
    def _parse_geometry(geometry):
        """ Parses given geometry into shapely object

        :param geometry:
        :return: Shapely polygon or multipolygon
        :rtype: shapely.geometry.Polygon or shapely.geometry.MultiPolygon
        :raises TypeError
        """
        if isinstance(geometry, str):
            geometry = shapely.wkt.loads(geometry)
        elif isinstance(geometry, dict):
            geometry = shapely.geometry.shape(geometry)
        elif not isinstance(geometry, shapely.geometry.base.BaseGeometry):
            raise TypeError('Unsupported geometry representation')

        if not isinstance(geometry, (shapely.geometry.Polygon, shapely.geometry.MultiPolygon)):
            raise ValueError('Supported geometry types are polygon and multipolygon, got {}'.format(type(geometry)))

        return geometry


class BBoxCollection(BaseGeometry):
    """ A collection of bounding boxes
    """
    def __init__(self, bbox_list):
        """
        :param bbox_list: A list of BBox objects which have to be in the same CRS
        :type bbox_list: list(BBox)
        """
        self._bbox_list, crs = self._parse_bbox_list(bbox_list)
        self._geometry = self._get_geometry()

        super().__init__(crs)

    def __repr__(self):
        """ Method for class representation
        """
        return '{}({})'.format(self.__class__.__name__, ', '.join([repr(bbox) for bbox in self.bbox_list]))

    def __eq__(self, other):
        """ Method for comparing two BBoxCollection classes
        """
        return self.crs == other.crs and len(self.bbox_list) == len(other.bbox_list) and \
            all(bbox == other_bbox for bbox, other_bbox in zip(self, other))

    def __iter__(self):
        """ This method enables iteration over bounding boxes in collection
        """
        return iter(self.bbox_list)

    @property
    def bbox_list(self):
        """ Returns the list of bounding boxes from collection

        :return: The list of bounding boxes
        :rtype: list(BBox)
        """
        return self._bbox_list

    @property
    def geometry(self):
        """ Returns shapely object representing geometry

        :return: A multipolygon of bounding boxes
        :rtype: shapely.geometry.MultiPolygon
        """
        return self._geometry

    @property
    def bbox(self):
        """ Returns BBox object representing bounding box around the geometry

        :return: A bounding box, with same CRS
        :rtype: BBox
        """
        return BBox(self.geometry, self.crs)

    def reverse(self):
        """ Returns a new BBoxCollection object where all x and y coordinates are switched

        :return: New Geometry object with switched coordinates
        :rtype: BBoxCollection
        """
        return BBoxCollection([bbox.reverse() for bbox in self.bbox_list])

    def transform(self, crs):
        """ Transforms BBoxCollection from current CRS to target CRS

        :param crs: target CRS
        :type crs: constants.CRS
        :return: BBoxCollection in target CRS
        :rtype: BBoxCollection
        """
        return BBoxCollection([bbox.transform(crs) for bbox in self.bbox_list])

    def _get_geometry(self):
        """ Creates a multipolygon of bounding box polygons
        """
        return shapely.geometry.MultiPolygon([bbox.geometry for bbox in self.bbox_list])

    @staticmethod
    def _parse_bbox_list(bbox_list):
        """ Helper method for parsing a list of bounding boxes
        """
        if isinstance(bbox_list, BBoxCollection):
            return bbox_list.bbox_list, bbox_list.crs

        if not isinstance(bbox_list, list) or not bbox_list:
            raise ValueError('Expected non-empty list of BBox objects')

        for bbox in bbox_list:
            if not isinstance(bbox, BBox):
                raise ValueError('Elements in the list should be of type {}, got {}'.format(BBox.__name__, type(bbox)))

        crs = bbox_list[0].crs
        for bbox in bbox_list:
            if bbox.crs is not crs:
                raise ValueError('All bounding boxes should have the same CRS')

        return bbox_list, crs
