# Copyright Iris contributors
#
# This file is part of Iris and is released under the LGPL license.
# See COPYING and COPYING.LESSER in the root of the repository for full
# licensing details.

# This is from Iris framework https://github.com/...
"""
Definitions of coordinate systems.

"""
import importlib
import inspect
import warnings
from abc import ABCMeta, abstractmethod
from typing import Optional

import cartopy.crs as ccrs
import numpy as np
import xarray as xr

from ..utils.serialization import maybe_convert_to_json_serializable

TOL = 1e-5
PREFIX = "crs_"

def add_prefix(txt: str) -> str:
    if txt.startswith(PREFIX):
        return txt
    return f"{PREFIX}{txt}"

def trim_prefix(txt: str) -> str:
    if not txt.startswith(PREFIX):
        return txt
    return txt.removeprefix(PREFIX)

def _arg_default(value, default, cast_as=float):
    """Apply a default value and type for an optional kwarg."""
    if value is None:
        value = default
    value = cast_as(value)
    return value


def _1or2_parallels(arg):
    """Accept 1 or 2 inputs as a tuple of 1 or 2 floats."""
    try:
        values_tuple = tuple(arg)
    except TypeError:
        values_tuple = (arg,)
    values_tuple = tuple([float(x) for x in values_tuple])
    nvals = len(values_tuple)
    if nvals not in (1, 2):
        emsg = "Allows only 1 or 2 parallels or secant latitudes : got {!r}"
        raise ValueError(emsg.format(arg))
    return values_tuple


def _float_or_None(arg):
    """Cast as float, except for allowing None as a distinct valid value."""
    if arg is not None:
        arg = float(arg)
    return arg


class CoordSystem(metaclass=ABCMeta):
    """
    Abstract base class for coordinate systems.

    """

    grid_mapping_name = None

    def __eq__(self, other):
        return (
            issubclass(self.__class__, other.__class__)
            or issubclass(other.__class__, self.__class__)
        ) and self.__dict__ == other.__dict__

    def __ne__(self, other):
        # Must supply __ne__, Python does not defer to __eq__ for
        # negative equality.
        return not (self == other)

    def as_crs_attributes(self):
        if hasattr(self, "_pretty_attrs"):
            return dict(self._pretty_attrs())
        return {k: v for k, v in self.__dict__.items() if v is not None}

    def xml_element(self, doc, attrs=None):
        """Default behaviour for coord systems."""
        # attrs - optional list of (k,v) items, used for alternate output

        xml_element_name = type(self).__name__
        # lower case the first char
        first_char = xml_element_name[0]
        xml_element_name = xml_element_name.replace(
            first_char, first_char.lower(), 1
        )

        coord_system_xml_element = doc.createElement(xml_element_name)

        if attrs is None:
            attrs = self.__dict__.items()
        attrs = sorted(attrs, key=lambda attr: attr[0])

        for name, value in attrs:
            if isinstance(value, float):
                value_str = "{:.16}".format(value)
            elif isinstance(value, np.float32):
                value_str = "{:.8}".format(value)
            else:
                value_str = "{}".format(value)
            coord_system_xml_element.setAttribute(name, value_str)

        return coord_system_xml_element

    @staticmethod
    def _ellipsoid_to_globe(ellipsoid, globe_default):
        if ellipsoid is not None:
            globe = ellipsoid.as_cartopy_globe()
        else:
            globe = globe_default

        return globe

    def to_dict(self):
        return {
            "name": self.grid_mapping_name,
            **maybe_convert_to_json_serializable(self.__dict__),
        }

    @abstractmethod
    def as_cartopy_crs(self):
        """
        Return a cartopy CRS representing our native coordinate
        system.

        """
        pass

    @abstractmethod
    def as_cartopy_projection(self):
        """
        Return a cartopy projection representing our native map.

        This will be the same as the :func:`~CoordSystem.as_cartopy_crs` for
        map projections but for spherical coord systems (which are not map
        projections) we use a map projection, such as PlateCarree.

        """
        pass


class GeogCS(CoordSystem):
    """
    A geographic (ellipsoidal) coordinate system, defined by the shape of
    the Earth and a prime meridian.

    """

    grid_mapping_name = "latitude_longitude"

    def __init__(
        self,
        semi_major_axis=None,
        semi_minor_axis=None,
        inverse_flattening=None,
        longitude_of_prime_meridian=None,
        *args,
        **kwargs
    ):
        """
        Creates a new GeogCS.

        Kwargs:

        * semi_major_axis, semi_minor_axis:
            Axes of ellipsoid, in metres.  At least one must be given
            (see note below).

        * inverse_flattening:
            Can be omitted if both axes given (see note below).
            Defaults to 0.0 .

        * longitude_of_prime_meridian:
            Specifies the prime meridian on the ellipsoid, in degrees.
            Defaults to 0.0 .

        If just semi_major_axis is set, with no semi_minor_axis or
        inverse_flattening, then a perfect sphere is created from the given
        radius.

        If just two of semi_major_axis, semi_minor_axis, and
        inverse_flattening are given the missing element is calculated from the
        formula:
        :math:`flattening = (major - minor) / major`

        Currently, Iris will not allow over-specification (all three ellipsoid
        parameters).

        Examples::

            cs = GeogCS(6371229)
            pp_cs = GeogCS(iris.fileformats.pp.EARTH_RADIUS)
            airy1830 = GeogCS(semi_major_axis=6377563.396,
                              semi_minor_axis=6356256.909)
            airy1830 = GeogCS(semi_major_axis=6377563.396,
                              inverse_flattening=299.3249646)
            custom_cs = GeogCS(6400000, 6300000)

        """
        # No ellipsoid specified? (0 0 0)
        if (
            (semi_major_axis is None)
            and (semi_minor_axis is None)
            and (inverse_flattening is None)
        ):
            raise ValueError("No ellipsoid specified")

        # Perfect sphere (semi_major_axis only)? (1 0 0)
        elif semi_major_axis is not None and (
            semi_minor_axis is None and inverse_flattening is None
        ):
            semi_minor_axis = semi_major_axis
            inverse_flattening = 0.0

        # Calculate semi_major_axis? (0 1 1)
        elif semi_minor_axis is not None and inverse_flattening is not None:
            semi_major_axis_ = -semi_minor_axis / (
                (1.0 - inverse_flattening) / inverse_flattening
            )
            if (
                semi_major_axis is not None
                and abs(semi_major_axis_ - semi_major_axis) > TOL
            ):
                raise ValueError("Ellipsoid is overspecified")
            semi_major_axis = semi_major_axis_

        # Calculate semi_minor_axis? (1 0 1)
        elif semi_major_axis is not None and inverse_flattening is not None:
            semi_minor_axis_ = semi_major_axis - (
                (1.0 / inverse_flattening) * semi_major_axis
            )
            if (
                semi_minor_axis is not None
                and abs(semi_minor_axis_ - semi_minor_axis) > TOL
            ):
                raise ValueError("Ellipsoid is overspecified")
            semi_minor_axis = semi_minor_axis_

        # Calculate inverse_flattening? (1 1 0)
        elif inverse_flattening is None and (
            semi_major_axis is not None and semi_minor_axis is not None
        ):
            if semi_major_axis == semi_minor_axis:
                inverse_flattening = 0.0
            else:
                inverse_flattening = 1.0 / (
                    (semi_major_axis - semi_minor_axis) / semi_major_axis
                )

        # We didn't get enough to specify an ellipse.
        else:
            raise ValueError("Insufficient ellipsoid specification")

        #: Major radius of the ellipsoid in metres.
        self.semi_major_axis = float(semi_major_axis)

        #: Minor radius of the ellipsoid in metres.
        self.semi_minor_axis = float(semi_minor_axis)

        #: :math:`1/f` where :math:`f = (a-b)/a`.
        self.inverse_flattening = float(inverse_flattening)

        #: Describes 'zero' on the ellipsoid in degrees.
        self.longitude_of_prime_meridian = _arg_default(
            longitude_of_prime_meridian, 0
        )

    def _pretty_attrs(self):
        attrs = [("semi_major_axis", self.semi_major_axis)]
        if self.semi_major_axis != self.semi_minor_axis:
            attrs.append(("semi_minor_axis", self.semi_minor_axis))
        if self.longitude_of_prime_meridian != 0.0:
            attrs.append(
                (
                    "longitude_of_prime_meridian",
                    self.longitude_of_prime_meridian,
                )
            )
        return attrs

    def __repr__(self):
        attrs = self._pretty_attrs()
        # Special case for 1 pretty attr
        if len(attrs) == 1 and attrs[0][0] == "semi_major_axis":
            return "GeogCS(%r)" % self.semi_major_axis
        else:
            return "GeogCS(%s)" % ", ".join(
                ["%s=%r" % (k, v) for k, v in attrs]
            )

    def __str__(self):
        attrs = self._pretty_attrs()
        # Special case for 1 pretty attr
        if len(attrs) == 1 and attrs[0][0] == "semi_major_axis":
            return "GeogCS({:.16})".format(self.semi_major_axis)
        else:
            text_attrs = []
            for k, v in attrs:
                if isinstance(v, float):
                    text_attrs.append("{}={:.16}".format(k, v))
                elif isinstance(v, np.float32):
                    text_attrs.append("{}={:.8}".format(k, v))
                else:
                    text_attrs.append("{}={}".format(k, v))
            return "GeogCS({})".format(", ".join(text_attrs))

    def xml_element(self, doc):
        # Special output for spheres
        attrs = self._pretty_attrs()
        if len(attrs) == 1 and attrs[0][0] == "semi_major_axis":
            attrs = [("earth_radius", self.semi_major_axis)]

        return CoordSystem.xml_element(self, doc, attrs)

    def as_cartopy_crs(self):
        return ccrs.Geodetic(self.as_cartopy_globe())

    def as_cartopy_projection(self):
        return ccrs.PlateCarree()

    def as_cartopy_globe(self):
        # Explicitly set `ellipse` to None as a workaround for
        # Cartopy setting WGS84 as the default.
        return ccrs.Globe(
            semimajor_axis=self.semi_major_axis,
            semiminor_axis=self.semi_minor_axis,
            ellipse=None,
        )


class RegularLatLon(GeogCS):
    grid_mapping_name = "latitude_longitude"

    def __init__(self, *args, **kwargs):
        if "semi_major_axis" not in kwargs:
            super().__init__(semi_major_axis=6371229, *args, **kwargs)
        else:
            super().__init__(*args, **kwargs)


class RotatedGeogCS(CoordSystem):
    """
    A coordinate system with rotated pole, on an optional :class:`GeogCS`.

    """

    grid_mapping_name = "rotated_latitude_longitude"

    def __init__(
        self,
        grid_north_pole_latitude,
        grid_north_pole_longitude,
        north_pole_grid_longitude=None,
        ellipsoid=None,
        *args,
        **kwargs
    ):
        """
        Constructs a coordinate system with rotated pole, on an
        optional :class:`GeogCS`.

        Args:

        * grid_north_pole_latitude:
            The true latitude of the rotated pole in degrees.

        * grid_north_pole_longitude:
            The true longitude of the rotated pole in degrees.

        Kwargs:

        * north_pole_grid_longitude:
            Longitude of true north pole in rotated grid, in degrees.
            Defaults to 0.0 .

        * ellipsoid (:class:`GeogCS`):
            If given, defines the ellipsoid.

        Examples::

            rotated_cs = RotatedGeogCS(30, 30)
            another_cs = RotatedGeogCS(30, 30,
                                       ellipsoid=GeogCS(6400000, 6300000))

        """
        #: The true latitude of the rotated pole in degrees.
        self.grid_north_pole_latitude = float(grid_north_pole_latitude)

        #: The true longitude of the rotated pole in degrees.
        self.grid_north_pole_longitude = float(grid_north_pole_longitude)

        #: Longitude of true north pole in rotated grid in degrees.
        self.north_pole_grid_longitude = _arg_default(
            north_pole_grid_longitude, 0
        )

        #: Ellipsoid definition (:class:`GeogCS` or None).
        self.ellipsoid = ellipsoid

    def _pretty_attrs(self):
        attrs = [
            ("grid_north_pole_latitude", self.grid_north_pole_latitude),
            ("grid_north_pole_longitude", self.grid_north_pole_longitude),
        ]
        if self.north_pole_grid_longitude != 0.0:
            attrs.append(
                ("north_pole_grid_longitude", self.north_pole_grid_longitude)
            )
        if self.ellipsoid is not None:
            attrs.append(("ellipsoid", self.ellipsoid))
        return attrs

    def as_crs_attributes(self):
        return dict(self._pretty_attrs())

    def __repr__(self):
        attrs = self._pretty_attrs()
        result = "RotatedGeogCS(%s)" % ", ".join(
            ["%s=%r" % (k, v) for k, v in attrs]
        )
        # Extra prettiness
        result = result.replace("grid_north_pole_latitude=", "")
        result = result.replace("grid_north_pole_longitude=", "")
        return result

    def __str__(self):
        attrs = self._pretty_attrs()
        text_attrs = []
        for k, v in attrs:
            if isinstance(v, float):
                text_attrs.append("{}={:.16}".format(k, v))
            elif isinstance(v, np.float32):
                text_attrs.append("{}={:.8}".format(k, v))
            else:
                text_attrs.append("{}={}".format(k, v))
        result = "RotatedGeogCS({})".format(", ".join(text_attrs))
        # Extra prettiness
        result = result.replace("grid_north_pole_latitude=", "")
        result = result.replace("grid_north_pole_longitude=", "")
        return result

    def xml_element(self, doc):
        return CoordSystem.xml_element(self, doc, self._pretty_attrs())

    def _ccrs_kwargs(self):
        globe = self._ellipsoid_to_globe(self.ellipsoid, None)
        cartopy_kwargs = {
            "central_rotated_longitude": self.north_pole_grid_longitude,
            "pole_longitude": self.grid_north_pole_longitude,
            "pole_latitude": self.grid_north_pole_latitude,
            "globe": globe,
        }

        return cartopy_kwargs

    def as_cartopy_crs(self):
        return ccrs.RotatedGeodetic(**self._ccrs_kwargs())

    def as_cartopy_projection(self):
        return ccrs.RotatedPole(**self._ccrs_kwargs())


class TransverseMercator(CoordSystem):
    """
    A cylindrical map projection, with XY coordinates measured in metres.

    """

    grid_mapping_name = "transverse_mercator"

    def __init__(
        self,
        latitude_of_projection_origin,
        longitude_of_central_meridian,
        false_easting=None,
        false_northing=None,
        scale_factor_at_central_meridian=None,
        ellipsoid=None,
        *args,
        **kwargs
    ):
        """
        Constructs a TransverseMercator object.

        Args:

        * latitude_of_projection_origin:
                True latitude of planar origin in degrees.

        * longitude_of_central_meridian:
                True longitude of planar origin in degrees.

        Kwargs:

        * false_easting:
                X offset from planar origin in metres.
                Defaults to 0.0 .

        * false_northing:
                Y offset from planar origin in metres.
                Defaults to 0.0 .

        * scale_factor_at_central_meridian:
                Reduces the cylinder to slice through the ellipsoid
                (secant form). Used to provide TWO longitudes of zero
                distortion in the area of interest.
                Defaults to 1.0 .

        * ellipsoid (:class:`GeogCS`):
            If given, defines the ellipsoid.

        Example::

            airy1830 = GeogCS(6377563.396, 6356256.909)
            osgb = TransverseMercator(49, -2, 400000, -100000, 0.9996012717,
                                      ellipsoid=airy1830)

        """
        #: True latitude of planar origin in degrees.
        self.latitude_of_projection_origin = float(
            latitude_of_projection_origin
        )

        #: True longitude of planar origin in degrees.
        self.longitude_of_central_meridian = float(
            longitude_of_central_meridian
        )

        #: X offset from planar origin in metres.
        self.false_easting = _arg_default(false_easting, 0)

        #: Y offset from planar origin in metres.
        self.false_northing = _arg_default(false_northing, 0)

        #: Scale factor at the centre longitude.
        self.scale_factor_at_central_meridian = _arg_default(
            scale_factor_at_central_meridian, 1.0
        )

        #: Ellipsoid definition (:class:`GeogCS` or None).
        self.ellipsoid = ellipsoid

    def __repr__(self):
        return (
            "TransverseMercator(latitude_of_projection_origin={!r}, "
            "longitude_of_central_meridian={!r}, false_easting={!r}, "
            "false_northing={!r}, scale_factor_at_central_meridian={!r}, "
            "ellipsoid={!r})".format(
                self.latitude_of_projection_origin,
                self.longitude_of_central_meridian,
                self.false_easting,
                self.false_northing,
                self.scale_factor_at_central_meridian,
                self.ellipsoid,
            )
        )

    def as_cartopy_crs(self):
        globe = self._ellipsoid_to_globe(self.ellipsoid, None)

        return ccrs.TransverseMercator(
            central_longitude=self.longitude_of_central_meridian,
            central_latitude=self.latitude_of_projection_origin,
            false_easting=self.false_easting,
            false_northing=self.false_northing,
            scale_factor=self.scale_factor_at_central_meridian,
            globe=globe,
        )

    def as_cartopy_projection(self):
        return self.as_cartopy_crs()


# TODO: has the same grid_mapping_name. Problem with constructing a CRS!
# class OSGB(TransverseMercator):
#     """A Specific transverse mercator projection on a specific ellipsoid."""

#     def __init__(self):
#         TransverseMercator.__init__(
#             self,
#             49,
#             -2,
#             400000,
#             -100000,
#             0.9996012717,
#             GeogCS(6377563.396, 6356256.909),
#         )

#     def as_cartopy_crs(self):
#         return ccrs.OSGB()

#     def as_cartopy_projection(self):
#         return ccrs.OSGB()

class WebMercator(CoordSystem):
    """A Specific transverse mercator projection on a specific ellipsoid."""

    def as_cartopy_crs(self):
        return ccrs.epsg('3857')

    def as_cartopy_projection(self):
        return ccrs.epsg('3857')

class Orthographic(CoordSystem):
    """
    An orthographic map projection.

    """

    grid_mapping_name = "orthographic"

    def __init__(
        self,
        latitude_of_projection_origin,
        longitude_of_projection_origin,
        false_easting=None,
        false_northing=None,
        ellipsoid=None,
        *args,
        **kwargs
    ):
        """
        Constructs an Orthographic coord system.

        Args:

        * latitude_of_projection_origin:
            True latitude of planar origin in degrees.

        * longitude_of_projection_origin:
            True longitude of planar origin in degrees.

        Kwargs:

        * false_easting:
            X offset from planar origin in metres. Defaults to 0.0 .

        * false_northing:
            Y offset from planar origin in metres. Defaults to 0.0 .

        * ellipsoid (:class:`GeogCS`):
            If given, defines the ellipsoid.

        """
        #: True latitude of planar origin in degrees.
        self.latitude_of_projection_origin = float(
            latitude_of_projection_origin
        )

        #: True longitude of planar origin in degrees.
        self.longitude_of_projection_origin = float(
            longitude_of_projection_origin
        )

        #: X offset from planar origin in metres.
        self.false_easting = _arg_default(false_easting, 0)

        #: Y offset from planar origin in metres.
        self.false_northing = _arg_default(false_northing, 0)

        #: Ellipsoid definition (:class:`GeogCS` or None).
        self.ellipsoid = ellipsoid

    def __repr__(self):
        return (
            "Orthographic(latitude_of_projection_origin={!r}, "
            "longitude_of_projection_origin={!r}, "
            "false_easting={!r}, false_northing={!r}, "
            "ellipsoid={!r})".format(
                self.latitude_of_projection_origin,
                self.longitude_of_projection_origin,
                self.false_easting,
                self.false_northing,
                self.ellipsoid,
            )
        )

    def as_cartopy_crs(self):
        globe = self._ellipsoid_to_globe(self.ellipsoid, ccrs.Globe())

        warnings.warn(
            "Discarding false_easting and false_northing that are "
            "not used by Cartopy."
        )

        return ccrs.Orthographic(
            central_longitude=self.longitude_of_projection_origin,
            central_latitude=self.latitude_of_projection_origin,
            globe=globe,
        )

    def as_cartopy_projection(self):
        return self.as_cartopy_crs()


class VerticalPerspective(CoordSystem):
    """
    A vertical/near-side perspective satellite image map projection.

    """

    grid_mapping_name = "vertical_perspective"

    def __init__(
        self,
        latitude_of_projection_origin,
        longitude_of_projection_origin,
        perspective_point_height,
        false_easting=None,
        false_northing=None,
        ellipsoid=None,
        *args,
        **kwargs
    ):
        """
        Constructs a Vertical Perspective coord system.

        Args:

        * latitude_of_projection_origin:
            True latitude of planar origin in degrees.

        * longitude_of_projection_origin:
            True longitude of planar origin in degrees.

        * perspective_point_height:
            Altitude of satellite in metres above the surface of the
            ellipsoid.

        Kwargs:

        * false_easting:
            X offset from planar origin in metres. Defaults to 0.0 .

        * false_northing:
            Y offset from planar origin in metres. Defaults to 0.0 .

        * ellipsoid (:class:`GeogCS`):
            If given, defines the ellipsoid.

        """
        #: True latitude of planar origin in degrees.
        self.latitude_of_projection_origin = float(
            latitude_of_projection_origin
        )

        #: True longitude of planar origin in degrees.
        self.longitude_of_projection_origin = float(
            longitude_of_projection_origin
        )

        #: Altitude of satellite in metres.
        self.perspective_point_height = float(perspective_point_height)
        # TODO: test if may be cast to float for proj.4

        #: X offset from planar origin in metres.
        self.false_easting = _arg_default(false_easting, 0)

        #: Y offset from planar origin in metres.
        self.false_northing = _arg_default(false_northing, 0)

        #: Ellipsoid definition (:class:`GeogCS` or None).
        self.ellipsoid = ellipsoid

    def __repr__(self):
        return (
            "Vertical Perspective(latitude_of_projection_origin={!r}, "
            "longitude_of_projection_origin={!r}, "
            "perspective_point_height={!r}, "
            "false_easting={!r}, false_northing={!r}, "
            "ellipsoid={!r})".format(
                self.latitude_of_projection_origin,
                self.longitude_of_projection_origin,
                self.perspective_point_height,
                self.false_easting,
                self.false_northing,
                self.ellipsoid,
            )
        )

    def as_cartopy_crs(self):
        globe = self._ellipsoid_to_globe(self.ellipsoid, ccrs.Globe())

        return ccrs.NearsidePerspective(
            central_latitude=self.latitude_of_projection_origin,
            central_longitude=self.longitude_of_projection_origin,
            satellite_height=self.perspective_point_height,
            false_easting=self.false_easting,
            false_northing=self.false_northing,
            globe=globe,
        )

    def as_cartopy_projection(self):
        return self.as_cartopy_crs()


class Geostationary(CoordSystem):
    """
    A geostationary satellite image map projection.

    """

    grid_mapping_name = "geostationary"

    def __init__(
        self,
        latitude_of_projection_origin,
        longitude_of_projection_origin,
        perspective_point_height,
        sweep_angle_axis,
        false_easting=None,
        false_northing=None,
        ellipsoid=None,
        *args,
        **kwargs
    ):
        """
        Constructs a Geostationary coord system.

        Args:

        * latitude_of_projection_origin:
            True latitude of planar origin in degrees.

        * longitude_of_projection_origin:
            True longitude of planar origin in degrees.

        * perspective_point_height:
            Altitude of satellite in metres above the surface of the ellipsoid.

        * sweep_angle_axis (string):
            The axis along which the satellite instrument sweeps - 'x' or 'y'.

        Kwargs:

        * false_easting:
            X offset from planar origin in metres. Defaults to 0.0 .

        * false_northing:
            Y offset from planar origin in metres. Defaults to 0.0 .

        * ellipsoid (:class:`GeogCS`):
            If given, defines the ellipsoid.

        """
        #: True latitude of planar origin in degrees.
        self.latitude_of_projection_origin = float(
            latitude_of_projection_origin
        )
        if self.latitude_of_projection_origin != 0.0:
            raise ValueError(
                "Non-zero latitude of projection currently not"
                " supported by Cartopy."
            )

        #: True longitude of planar origin in degrees.
        self.longitude_of_projection_origin = float(
            longitude_of_projection_origin
        )

        #: Altitude of satellite in metres.
        self.perspective_point_height = float(perspective_point_height)
        # TODO: test if may be cast to float for proj.4

        #: X offset from planar origin in metres.
        self.false_easting = _arg_default(false_easting, 0)

        #: Y offset from planar origin in metres.
        self.false_northing = _arg_default(false_northing, 0)

        #: The sweep angle axis (string 'x' or 'y').
        self.sweep_angle_axis = sweep_angle_axis
        if self.sweep_angle_axis not in ("x", "y"):
            raise ValueError('Invalid sweep_angle_axis - must be "x" or "y"')

        #: Ellipsoid definition (:class:`GeogCS` or None).
        self.ellipsoid = ellipsoid

    def __repr__(self):
        return (
            "Geostationary(latitude_of_projection_origin={!r}, "
            "longitude_of_projection_origin={!r}, "
            "perspective_point_height={!r}, false_easting={!r}, "
            "false_northing={!r}, sweep_angle_axis={!r}, "
            "ellipsoid={!r}".format(
                self.latitude_of_projection_origin,
                self.longitude_of_projection_origin,
                self.perspective_point_height,
                self.false_easting,
                self.false_northing,
                self.sweep_angle_axis,
                self.ellipsoid,
            )
        )

    def as_cartopy_crs(self):
        globe = self._ellipsoid_to_globe(self.ellipsoid, ccrs.Globe())

        return ccrs.Geostationary(
            central_longitude=self.longitude_of_projection_origin,
            satellite_height=self.perspective_point_height,
            false_easting=self.false_easting,
            false_northing=self.false_northing,
            globe=globe,
            sweep_axis=self.sweep_angle_axis,
        )

    def as_cartopy_projection(self):
        return self.as_cartopy_crs()


class Stereographic(CoordSystem):
    """
    A stereographic map projection.

    """

    grid_mapping_name = "stereographic"

    def __init__(
        self,
        central_lat,
        central_lon,
        false_easting=None,
        false_northing=None,
        true_scale_lat=None,
        ellipsoid=None,
        *args,
        **kwargs
    ):
        """
        Constructs a Stereographic coord system.

        Args:

        * central_lat:
            The latitude of the pole.

        * central_lon:
            The central longitude, which aligns with the y axis.

        Kwargs:

        * false_easting:
            X offset from planar origin in metres. Defaults to 0.0 .

        * false_northing:
            Y offset from planar origin in metres. Defaults to 0.0 .

        * true_scale_lat:
            Latitude of true scale.

        * ellipsoid (:class:`GeogCS`):
            If given, defines the ellipsoid.

        """

        #: True latitude of planar origin in degrees.
        self.central_lat = float(central_lat)

        #: True longitude of planar origin in degrees.
        self.central_lon = float(central_lon)

        #: X offset from planar origin in metres.
        self.false_easting = _arg_default(false_easting, 0)

        #: Y offset from planar origin in metres.
        self.false_northing = _arg_default(false_northing, 0)

        #: Latitude of true scale.
        self.true_scale_lat = _arg_default(
            true_scale_lat, None, cast_as=_float_or_None
        )
        # N.B. the way we use this parameter, we need it to default to None,
        # and *not* to 0.0 .

        #: Ellipsoid definition (:class:`GeogCS` or None).
        self.ellipsoid = ellipsoid

    def __repr__(self):
        return (
            "Stereographic(central_lat={!r}, central_lon={!r}, "
            "false_easting={!r}, false_northing={!r}, "
            "true_scale_lat={!r}, "
            "ellipsoid={!r})".format(
                self.central_lat,
                self.central_lon,
                self.false_easting,
                self.false_northing,
                self.true_scale_lat,
                self.ellipsoid,
            )
        )

    def as_cartopy_crs(self):
        globe = self._ellipsoid_to_globe(self.ellipsoid, ccrs.Globe())

        return ccrs.Stereographic(
            self.central_lat,
            self.central_lon,
            self.false_easting,
            self.false_northing,
            self.true_scale_lat,
            globe=globe,
        )

    def as_cartopy_projection(self):
        return self.as_cartopy_crs()


class LambertConformal(CoordSystem):
    """
    A coordinate system in the Lambert Conformal conic projection.

    """

    grid_mapping_name = "lambert_conformal_conic"

    def __init__(
        self,
        central_lat=None,
        central_lon=None,
        false_easting=None,
        false_northing=None,
        secant_latitudes=None,
        ellipsoid=None,
        *args,
        **kwargs
    ):
        """
        Constructs a LambertConformal coord system.

        Kwargs:

        * central_lat:
                The latitude of "unitary scale".  Defaults to 39.0 .

        * central_lon:
                The central longitude.  Defaults to -96.0 .

        * false_easting:
                X offset from planar origin in metres.  Defaults to 0.0 .

        * false_northing:
                Y offset from planar origin in metres.  Defaults to 0.0 .

        * secant_latitudes (number or iterable of 1 or 2 numbers):
                Latitudes of secant intersection.  One or two.
                Defaults to (33.0, 45.0).

        * ellipsoid (:class:`GeogCS`):
            If given, defines the ellipsoid.

        .. note:

            Default arguments are for the familiar USA map:
            central_lon=-96.0, central_lat=39.0,
            false_easting=0.0, false_northing=0.0,
            secant_latitudes=(33, 45)

        """

        #: True latitude of planar origin in degrees.
        self.central_lat = _arg_default(central_lat, 39.0)

        #: True longitude of planar origin in degrees.
        self.central_lon = _arg_default(central_lon, -96.0)

        #: X offset from planar origin in metres.
        self.false_easting = _arg_default(false_easting, 0)

        #: Y offset from planar origin in metres.
        self.false_northing = _arg_default(false_northing, 0)

        #: The standard parallels of the cone (tuple of 1 or 2 floats).
        self.secant_latitudes = _arg_default(
            secant_latitudes, (33, 45), cast_as=_1or2_parallels
        )

        #: Ellipsoid definition (:class:`GeogCS` or None).
        self.ellipsoid = ellipsoid

    def __repr__(self):
        return (
            "LambertConformal(central_lat={!r}, central_lon={!r}, "
            "false_easting={!r}, false_northing={!r}, "
            "secant_latitudes={!r}, ellipsoid={!r})".format(
                self.central_lat,
                self.central_lon,
                self.false_easting,
                self.false_northing,
                self.secant_latitudes,
                self.ellipsoid,
            )
        )

    def as_cartopy_crs(self):
        # We're either north or south polar. Set a cutoff accordingly.
        if self.secant_latitudes is not None:
            lats = self.secant_latitudes
            max_lat = lats[0]
            if len(lats) == 2:
                max_lat = lats[0] if abs(lats[0]) > abs(lats[1]) else lats[1]
            cutoff = -30 if max_lat > 0 else 30
        else:
            cutoff = None

        globe = self._ellipsoid_to_globe(self.ellipsoid, ccrs.Globe())

        return ccrs.LambertConformal(
            central_longitude=self.central_lon,
            central_latitude=self.central_lat,
            false_easting=self.false_easting,
            false_northing=self.false_northing,
            globe=globe,
            cutoff=cutoff,
            standard_parallels=self.secant_latitudes,
        )

    def as_cartopy_projection(self):
        return self.as_cartopy_crs()


class Mercator(CoordSystem):
    """
    A coordinate system in the Mercator projection.

    """

    grid_mapping_name = "mercator"

    def __init__(
        self,
        longitude_of_projection_origin=None,
        ellipsoid=None,
        standard_parallel=None,
        *args,
        **kwargs
    ):
        """
        Constructs a Mercator coord system.

        Kwargs:

        * longitude_of_projection_origin:
            True longitude of planar origin in degrees. Defaults to 0.0 .

        * ellipsoid (:class:`GeogCS`):
            If given, defines the ellipsoid.

        * standard_parallel:
            The latitude where the scale is 1. Defaults to 0.0 .

        """
        #: True longitude of planar origin in degrees.
        self.longitude_of_projection_origin = _arg_default(
            longitude_of_projection_origin, 0
        )

        #: Ellipsoid definition (:class:`GeogCS` or None).
        self.ellipsoid = ellipsoid

        #: The latitude where the scale is 1.
        self.standard_parallel = _arg_default(standard_parallel, 0)

    def __repr__(self):
        res = (
            "Mercator(longitude_of_projection_origin="
            "{self.longitude_of_projection_origin!r}, "
            "ellipsoid={self.ellipsoid!r}, "
            "standard_parallel={self.standard_parallel!r})"
        )
        return res.format(self=self)

    def as_cartopy_crs(self):
        globe = self._ellipsoid_to_globe(self.ellipsoid, ccrs.Globe())

        return ccrs.Mercator(
            central_longitude=self.longitude_of_projection_origin,
            globe=globe,
            latitude_true_scale=self.standard_parallel,
        )

    def as_cartopy_projection(self):
        return self.as_cartopy_crs()


class LambertAzimuthalEqualArea(CoordSystem):
    """
    A coordinate system in the Lambert Azimuthal Equal Area projection.

    """

    grid_mapping_name = "lambert_azimuthal_equal_area"

    def __init__(
        self,
        latitude_of_projection_origin=None,
        longitude_of_projection_origin=None,
        false_easting=None,
        false_northing=None,
        ellipsoid=None,
        *args,
        **kwargs
    ):
        """
        Constructs a Lambert Azimuthal Equal Area coord system.

        Kwargs:

        * latitude_of_projection_origin:
            True latitude of planar origin in degrees. Defaults to 0.0 .

        * longitude_of_projection_origin:
            True longitude of planar origin in degrees. Defaults to 0.0 .

        * false_easting:
                X offset from planar origin in metres. Defaults to 0.0 .

        * false_northing:
                Y offset from planar origin in metres. Defaults to 0.0 .

        * ellipsoid (:class:`GeogCS`):
            If given, defines the ellipsoid.

        """
        #: True latitude of planar origin in degrees.
        self.latitude_of_projection_origin = _arg_default(
            latitude_of_projection_origin, 0
        )

        #: True longitude of planar origin in degrees.
        self.longitude_of_projection_origin = _arg_default(
            longitude_of_projection_origin, 0
        )

        #: X offset from planar origin in metres.
        self.false_easting = _arg_default(false_easting, 0)

        #: Y offset from planar origin in metres.
        self.false_northing = _arg_default(false_northing, 0)

        #: Ellipsoid definition (:class:`GeogCS` or None).
        self.ellipsoid = ellipsoid

    def __repr__(self):
        return (
            "LambertAzimuthalEqualArea(latitude_of_projection_origin={!r},"
            " longitude_of_projection_origin={!r}, false_easting={!r},"
            " false_northing={!r}, ellipsoid={!r})"
        ).format(
            self.latitude_of_projection_origin,
            self.longitude_of_projection_origin,
            self.false_easting,
            self.false_northing,
            self.ellipsoid,
        )

    def as_cartopy_crs(self):
        globe = self._ellipsoid_to_globe(self.ellipsoid, ccrs.Globe())

        return ccrs.LambertAzimuthalEqualArea(
            central_longitude=self.longitude_of_projection_origin,
            central_latitude=self.latitude_of_projection_origin,
            false_easting=self.false_easting,
            false_northing=self.false_northing,
            globe=globe,
        )

    def as_cartopy_projection(self):
        return self.as_cartopy_crs()


class AlbersEqualArea(CoordSystem):
    """
    A coordinate system in the Albers Conical Equal Area projection.

    """

    grid_mapping_name = "albers_conical_equal_area"

    def __init__(
        self,
        latitude_of_projection_origin=None,
        longitude_of_central_meridian=None,
        false_easting=None,
        false_northing=None,
        standard_parallels=None,
        ellipsoid=None,
        *args,
        **kwargs
    ):
        """
        Constructs a Albers Conical Equal Area coord system.

        Kwargs:

        * latitude_of_projection_origin:
            True latitude of planar origin in degrees. Defaults to 0.0 .

        * longitude_of_central_meridian:
            True longitude of planar central meridian in degrees.
            Defaults to 0.0 .

        * false_easting:
            X offset from planar origin in metres. Defaults to 0.0 .

        * false_northing:
            Y offset from planar origin in metres. Defaults to 0.0 .

        * standard_parallels (number or iterable of 1 or 2 numbers):
            The one or two latitudes of correct scale.
            Defaults to (20.0, 50.0).

        * ellipsoid (:class:`GeogCS`):
            If given, defines the ellipsoid.

        """
        #: True latitude of planar origin in degrees.
        self.latitude_of_projection_origin = _arg_default(
            latitude_of_projection_origin, 0
        )

        #: True longitude of planar central meridian in degrees.
        self.longitude_of_central_meridian = _arg_default(
            longitude_of_central_meridian, 0
        )

        #: X offset from planar origin in metres.
        self.false_easting = _arg_default(false_easting, 0)

        #: Y offset from planar origin in metres.
        self.false_northing = _arg_default(false_northing, 0)

        #: The one or two latitudes of correct scale (tuple of 1 or 2 floats).
        self.standard_parallels = _arg_default(
            standard_parallels, (20, 50), cast_as=_1or2_parallels
        )

        #: Ellipsoid definition (:class:`GeogCS` or None).
        self.ellipsoid = ellipsoid

    def __repr__(self):
        return (
            "AlbersEqualArea(latitude_of_projection_origin={!r},"
            " longitude_of_central_meridian={!r}, false_easting={!r},"
            " false_northing={!r}, standard_parallels={!r},"
            " ellipsoid={!r})"
        ).format(
            self.latitude_of_projection_origin,
            self.longitude_of_central_meridian,
            self.false_easting,
            self.false_northing,
            self.standard_parallels,
            self.ellipsoid,
        )

    def as_cartopy_crs(self):
        globe = self._ellipsoid_to_globe(self.ellipsoid, ccrs.Globe())

        return ccrs.AlbersEqualArea(
            central_longitude=self.longitude_of_central_meridian,
            central_latitude=self.latitude_of_projection_origin,
            false_easting=self.false_easting,
            false_northing=self.false_northing,
            standard_parallels=self.standard_parallels,
            globe=globe,
        )

    def as_cartopy_projection(self):
        return self.as_cartopy_crs()


class CurvilinearGrid(CoordSystem):
    grid_mapping_name = "curvilinear_grid"

    def __init__(self, *args, **kwargs):
        # TODO: more logic
        pass

    def as_cartopy_crs(self):
        raise NotImplementedError()

    def as_cartopy_projection(self):
        raise NotImplementedError()


def parse_crs(
    da: xr.DataArray,
) -> Optional["CoordSystem"]:
    """
    Creates an instance of CoordSystem subclass, one from those defined in this file where grid_mapping_name suits the one requested.

    Parameters
    ----------
    da : xarray.DataArray
        Xarray structure with, at least attribute `grid_mapping_name` and other attributes if required

    Returns:
    ----------
    coord_sys : CoordSystem
        Proper subclass of CoordSystem class

    Examples:
    ----------
    >>> ds = get_coord_system(
    ...         grid_mapping_name="rotated_latitude_longitude",
    ...         grid_north_pole_latitude=32.5,
    ...         grid_north_pole_longitude=170
    ...     )
    """
    grid_mapping_name = trim_prefix(da.attrs.get("grid_mapping_name"))
    if grid_mapping_name == "latitude_longitude":
        return RegularLatLon(**da.attrs)
    for _, cls in inspect.getmembers(
        importlib.import_module(__name__), inspect.isclass
    ):
        if not hasattr(cls, "grid_mapping_name"):
            continue
        if cls.grid_mapping_name == grid_mapping_name:
            return cls(**da.attrs)
