import json
import re
import warnings
from collections import OrderedDict, namedtuple

from pyproj._compat cimport cstrdecode, cstrencode
from pyproj._datadir cimport (
    _clear_proj_error,
    pyproj_context_create,
    pyproj_context_destroy,
)

from pyproj.aoi import AreaOfUse
from pyproj.crs.datum import CustomEllipsoid
from pyproj.crs.enums import CoordinateOperationType, DatumType
from pyproj.enums import ProjVersion, WktVersion
from pyproj.exceptions import CRSError
from pyproj.geod import pj_ellps
from pyproj.utils import NumpyEncoder


# This is for looking up the ellipsoid parameters
# based on the long name
cdef dict _PJ_ELLPS_NAME_MAP = {
    ellps["description"]: ellps_id for ellps_id, ellps in pj_ellps.items()
}


cdef str decode_or_undefined(const char* instring):
    pystr = cstrdecode(instring)
    if pystr is None:
        return "undefined"
    return pystr


def is_wkt(str proj_string not None):
    """
    .. versionadded:: 2.0.0

    Check if the input projection string is in the Well-Known Text format.

    Parameters
    ----------
    proj_string: str
        The projection string.

    Returns
    -------
    bool: True if the string is in the Well-Known Text format
    """
    cdef bytes b_proj_string = cstrencode(proj_string)
    return proj_context_guess_wkt_dialect(NULL, b_proj_string) != PJ_GUESSED_NOT_WKT


def is_proj(str proj_string not None):
    """
    .. versionadded:: 2.2.2

    Check if the input projection string is in the PROJ format.

    Parameters
    ----------
    proj_string: str
        The projection string.

    Returns
    -------
    bool: True if the string is in the PROJ format
    """
    return not is_wkt(proj_string) and "=" in proj_string


cdef _to_wkt(
    PJ_CONTEXT* context,
    PJ* projobj,
    object version,
    bint pretty,
    bool output_axis_rule=None,
):
    """
    Convert a PJ object to a wkt string.

    Parameters
    ----------
    context: PJ_CONTEXT*
    projobj: PJ*
    wkt_out_type: PJ_WKT_TYPE
    pretty: bool
    output_axis_rule: bool or None

    Return
    ------
    str or None
    """
    # get the output WKT format
    supported_wkt_types = {
        WktVersion.WKT2_2015: PJ_WKT2_2015,
        WktVersion.WKT2_2015_SIMPLIFIED: PJ_WKT2_2015_SIMPLIFIED,
        WktVersion.WKT2_2018: PJ_WKT2_2019,
        WktVersion.WKT2_2018_SIMPLIFIED: PJ_WKT2_2019_SIMPLIFIED,
        WktVersion.WKT2_2019: PJ_WKT2_2019,
        WktVersion.WKT2_2019_SIMPLIFIED: PJ_WKT2_2019_SIMPLIFIED,
        WktVersion.WKT1_GDAL: PJ_WKT1_GDAL,
        WktVersion.WKT1_ESRI: PJ_WKT1_ESRI
    }
    cdef PJ_WKT_TYPE wkt_out_type
    wkt_out_type = supported_wkt_types[WktVersion.create(version)]

    cdef const char* options_wkt[3]
    cdef bytes multiline = b"MULTILINE=NO"
    if pretty:
        multiline = b"MULTILINE=YES"
    cdef bytes output_axis = b"OUTPUT_AXIS=AUTO"
    if output_axis_rule is False:
        output_axis = b"OUTPUT_AXIS=NO"
    elif output_axis_rule is True:
        output_axis = b"OUTPUT_AXIS=YES"
    options_wkt[0] = multiline
    options_wkt[1] = output_axis
    options_wkt[2] = NULL
    cdef const char* proj_string
    proj_string = proj_as_wkt(
        context,
        projobj,
        wkt_out_type,
        options_wkt,
    )
    _clear_proj_error()
    return cstrdecode(proj_string)


cdef _to_proj4(
    PJ_CONTEXT* context,
    PJ* projobj,
    object version,
    bint pretty,
):
    """
    Convert the projection to a PROJ string.

    Parameters
    ----------
    context: PJ_CONTEXT*
    projobj: PJ*
    version: pyproj.enums.ProjVersion
        The version of the PROJ string output.
    pretty: bool

    Returns
    -------
    str: The PROJ string.
    """
    # get the output PROJ string format
    supported_prj_types = {
        ProjVersion.PROJ_4: PJ_PROJ_4,
        ProjVersion.PROJ_5: PJ_PROJ_5,
    }
    cdef PJ_PROJ_STRING_TYPE proj_out_type
    proj_out_type = supported_prj_types[ProjVersion.create(version)]

    cdef const char* options[2]
    cdef bytes multiline = b"MULTILINE=NO"
    if pretty:
        multiline = b"MULTILINE=YES"
    options[0] = multiline
    options[1] = NULL

    # convert projection to string
    cdef const char* proj_string
    proj_string = proj_as_proj_string(
        context,
        projobj,
        proj_out_type,
        options,
    )
    _clear_proj_error()
    return cstrdecode(proj_string)


cdef tuple _get_concatenated_operations(
    PJ_CONTEXT* context, PJ* concatenated_operation
):
    """
    For a PJ* of type concatenated operation, get the operations
    """
    cdef int step_count = proj_concatoperation_get_step_count(
        context,
        concatenated_operation,
    )
    cdef PJ* operation = NULL
    cdef PJ_CONTEXT* sub_context = NULL
    cdef int iii = 0
    operations = []
    for iii in range(step_count):
        sub_context = pyproj_context_create()
        operation = proj_concatoperation_get_step(
            sub_context,
            concatenated_operation,
            iii,
        )
        operations.append(CoordinateOperation.create(sub_context, operation))
    _clear_proj_error()
    return tuple(operations)

cdef PJ * _from_name(
        PJ_CONTEXT* context,
        str name_string,
        str auth_name,
        PJ_TYPE pj_type,
    ):
        """
        Create an object from a name.

        Parameters
        ----------
        context: PJ_CONTEXT*
            The context to use to create the object.
        name_string: str
            Name of object to create.
        auth_name: str
            The authority name to refine search.
            If None, will search all authorities.
        pj_type: PJ_TYPE
            The type of PJ * to create.

        Returns
        -------
        PJ *
        """
        cdef PJ_TYPE[1] pj_types = [pj_type]
        cdef char* c_auth_name = NULL
        cdef bytes b_auth_name
        if auth_name is not None:
            b_auth_name = cstrencode(auth_name)
            c_auth_name = b_auth_name

        cdef PJ_OBJ_LIST *pj_list = proj_create_from_name(
            context,
            c_auth_name,
            cstrencode(name_string),
            <PJ_TYPE*>&pj_types,
            1,
            False,
            1,
            NULL,
        )
        if pj_list == NULL or proj_list_get_count(pj_list) <= 0:
            proj_list_destroy(pj_list)
            return NULL
        cdef PJ* datum_pj = proj_list_get(context, pj_list, 0)
        proj_list_destroy(pj_list)
        return datum_pj


def _load_proj_json(str in_proj_json):
    try:
        return json.loads(in_proj_json)
    except ValueError:
        raise CRSError("Invalid JSON")


cdef class Axis:
    """
    .. versionadded:: 2.0.0

    Coordinate System Axis

    Attributes
    ----------
    name: str
    abbrev: str
    direction: str
    unit_conversion_factor: float
    unit_name: str
    unit_auth_code: str
    unit_code: str

    """
    def __cinit__(self):
        self.name = "undefined"
        self.abbrev = "undefined"
        self.direction = "undefined"
        self.unit_conversion_factor = float("NaN")
        self.unit_name = "undefined"
        self.unit_auth_code = "undefined"
        self.unit_code = "undefined"

    def __str__(self):
        return f"{self.abbrev}[{self.direction}]: {self.name} ({self.unit_name})"

    def __repr__(self):
        return (
            f"Axis(name={self.name}, abbrev={self.abbrev}, "
            f"direction={self.direction}, unit_auth_code={self.unit_auth_code}, "
            f"unit_code={self.unit_code}, unit_name={self.unit_name})"
        )

    @staticmethod
    cdef Axis create(PJ_CONTEXT* context, PJ* projobj, int index):
        cdef:
            Axis axis_info = Axis()
            const char * name = NULL
            const char * abbrev = NULL
            const char * direction = NULL
            const char * unit_name = NULL
            const char * unit_auth_code = NULL
            const char * unit_code = NULL

        if not proj_cs_get_axis_info(
                context,
                projobj,
                index,
                &name,
                &abbrev,
                &direction,
                &axis_info.unit_conversion_factor,
                &unit_name,
                &unit_auth_code,
                &unit_code):
            return None
        axis_info.name = decode_or_undefined(name)
        axis_info.abbrev = decode_or_undefined(abbrev)
        axis_info.direction = decode_or_undefined(direction)
        axis_info.unit_name = decode_or_undefined(unit_name)
        axis_info.unit_auth_code = decode_or_undefined(unit_auth_code)
        axis_info.unit_code = decode_or_undefined(unit_code)
        return axis_info


cdef create_area_of_use(PJ_CONTEXT* context, PJ* projobj):
    cdef:
        double west = float("nan")
        double south = float("nan")
        double east = float("nan")
        double north = float("nan")
        const char * area_name = NULL

    if not proj_get_area_of_use(
            context,
            projobj,
            &west,
            &south,
            &east,
            &north,
            &area_name):
        return None
    return AreaOfUse(
        west=west,
        south=south,
        east=east,
        north=north,
        name=decode_or_undefined(area_name),
    )


cdef class Base:
    def __cinit__(self):
        self.projobj = NULL
        self.context = NULL
        self.name = "undefined"
        self._scope = None
        self._remarks = None

    def __dealloc__(self):
        """destroy projection definition"""
        if self.projobj != NULL:
            proj_destroy(self.projobj)
        if self.context != NULL:
            pyproj_context_destroy(self.context)

    cdef _set_base_info(self):
        """
        Set the name of the PJ
        """
        # get proj information
        cdef const char* proj_name = proj_get_name(self.projobj)
        self.name = decode_or_undefined(proj_name)
        cdef const char* scope = proj_get_scope(self.projobj)
        if scope != NULL and scope != "":
            self._scope = scope
        cdef const char* remarks = proj_get_remarks(self.projobj)
        if remarks != NULL and remarks != "":
            self._remarks = remarks

    @property
    def remarks(self):
        """
        .. versionadded:: 2.4.0

        Returns
        -------
        str:
            Remarks about object.
        """
        return self._remarks

    @property
    def scope(self):
        """
        .. versionadded:: 2.4.0

        Returns
        -------
        str:
            Scope of object.
        """
        return self._scope

    def to_wkt(self, version=WktVersion.WKT2_2019, pretty=False, output_axis_rule=None):
        """
        Convert the projection to a WKT string.

        Version options:
          - WKT2_2015
          - WKT2_2015_SIMPLIFIED
          - WKT2_2019
          - WKT2_2019_SIMPLIFIED
          - WKT1_GDAL
          - WKT1_ESRI

        .. versionadded:: 3.6.0 output_axis_rule

        Parameters
        ----------
        version: pyproj.enums.WktVersion, default=pyproj.enums.WktVersion.WKT2_2019
            The version of the WKT output.
        pretty: bool, default=False
            If True, it will set the output to be a multiline string.
        output_axis_rule: bool, optional, default=None
            If True, it will set the axis rule on any case. If false, never.
            None for AUTO, that depends on the CRS and version.

        Returns
        -------
        str
        """
        return _to_wkt(self.context, self.projobj, version, pretty=pretty, output_axis_rule=output_axis_rule)

    def to_json(self, bint pretty=False, int indentation=2):
        """
        .. versionadded:: 2.4.0

        Convert the object to a JSON string.

        Parameters
        ----------
        pretty: bool, default=False
            If True, it will set the output to be a multiline string.
        indentation: int, default=2
            If pretty is True, it will set the width of the indentation.

        Returns
        -------
        str
        """
        cdef const char* options[3]
        multiline = b"MULTILINE=NO"
        if pretty:
            multiline = b"MULTILINE=YES"
        indentation_width = cstrencode(f"INDENTATION_WIDTH={indentation:.0f}")
        options[0] = multiline
        options[1] = indentation_width
        options[2] = NULL

        cdef const char* proj_json_string = proj_as_projjson(
            self.context,
            self.projobj,
            options,
        )
        return cstrdecode(proj_json_string)

    def to_json_dict(self):
        """
        .. versionadded:: 2.4.0

        Convert the object to a JSON dictionary.

        Returns
        -------
        dict
        """
        return json.loads(self.to_json())

    def __str__(self):
        return self.name

    def __repr__(self):
        return self.to_wkt(pretty=True)

    def _is_exact_same(self, Base other):
        return proj_is_equivalent_to_with_ctx(
            self.context, self.projobj, other.projobj, PJ_COMP_STRICT) == 1

    def _is_equivalent(self, Base other):
        return proj_is_equivalent_to_with_ctx(
            self.context, self.projobj, other.projobj, PJ_COMP_EQUIVALENT) == 1

    def __eq__(self, other):
        if not isinstance(other, Base):
            return False
        return self._is_equivalent(other)

    def is_exact_same(self, other):
        """Compares projection objects to see if they are exactly the same."""
        if not isinstance(other, Base):
            return False
        return self._is_exact_same(other)


cdef class _CRSParts(Base):
    @classmethod
    def from_user_input(cls, user_input):
        """
        .. versionadded:: 2.5.0

        Create cls from user input:
          - PROJ JSON string
          - PROJ JSON dict
          - WKT string
          - An authority string
          - An EPSG integer code
          - An iterable of ("auth_name", "auth_code")
          - An object with a `to_json` method.

        Parameters
        ----------
        user_input: str, dict, int, Iterable[str, str]
            Input to create cls.

        Returns
        -------
        cls
        """
        if isinstance(user_input, str):
            prepared = cls.from_string(user_input)
        elif isinstance(user_input, dict):
            prepared = cls.from_json_dict(user_input)
        elif isinstance(user_input, int) and hasattr(cls, "from_epsg"):
            prepared = cls.from_epsg(user_input)
        elif (
            isinstance(user_input, (list, tuple))
            and len(user_input) == 2
            and hasattr(cls, "from_authority")
        ):
            prepared = cls.from_authority(*user_input)
        elif hasattr(user_input, "to_json"):
            prepared = cls.from_json(user_input.to_json())
        else:
            raise CRSError(f"Invalid {cls.__name__} input: {user_input!r}")
        return prepared

    def __eq__(self, other):
        try:
            other = self.from_user_input(other)
        except CRSError:
            return False
        return self._is_equivalent(other)


cdef dict _COORD_SYSTEM_TYPE_MAP = {
    PJ_CS_TYPE_UNKNOWN: "unknown",
    PJ_CS_TYPE_CARTESIAN: "cartesian",
    PJ_CS_TYPE_ELLIPSOIDAL: "ellipsoidal",
    PJ_CS_TYPE_VERTICAL: "vertical",
    PJ_CS_TYPE_SPHERICAL: "spherical",
    PJ_CS_TYPE_ORDINAL: "ordinal",
    PJ_CS_TYPE_PARAMETRIC: "parametric",
    PJ_CS_TYPE_DATETIMETEMPORAL: "datetimetemporal",
    PJ_CS_TYPE_TEMPORALCOUNT: "temporalcount",
    PJ_CS_TYPE_TEMPORALMEASURE: "temporalmeasure",
}

cdef class CoordinateSystem(_CRSParts):
    """
    .. versionadded:: 2.2.0

    Coordinate System for CRS

    Attributes
    ----------
    name: str
        The name of the coordinate system.

    """
    def __cinit__(self):
        self._axis_list = None

    def __init__(self):
        raise RuntimeError("CoordinateSystem is not initializable.")

    @staticmethod
    cdef CoordinateSystem create(PJ_CONTEXT* context, PJ* coord_system_pj):
        cdef CoordinateSystem coord_system = CoordinateSystem.__new__(CoordinateSystem)
        coord_system.context = context
        coord_system.projobj = coord_system_pj

        cdef PJ_COORDINATE_SYSTEM_TYPE cs_type = proj_cs_get_type(
            coord_system.context,
            coord_system.projobj,
        )
        coord_system.name = _COORD_SYSTEM_TYPE_MAP[cs_type]
        return coord_system

    @property
    def axis_list(self):
        """
        Returns
        -------
        list[Axis]:
            The Axis list for the coordinate system.
        """
        if self._axis_list is not None:
            return self._axis_list
        self._axis_list = []
        cdef int num_axes = 0
        num_axes = proj_cs_get_axis_count(
            self.context,
            self.projobj
        )
        for axis_idx from 0 <= axis_idx < num_axes:
            self._axis_list.append(
                Axis.create(
                    self.context,
                    self.projobj,
                    axis_idx
                )
            )
        return self._axis_list

    @staticmethod
    def from_string(str coordinate_system_string not None):
        """
        .. versionadded:: 2.5.0

        .. note:: Only works with PROJ JSON.

        Create a Coordinate System from a string.

        Parameters
        ----------
        coordinate_system_string: str
            Coordinate System string.

        Returns
        -------
        CoordinateSystem
        """
        cdef PJ_CONTEXT* context = pyproj_context_create()
        cdef PJ* coordinate_system_pj = proj_create(
            context,
            cstrencode(coordinate_system_string)
        )
        if coordinate_system_pj == NULL or proj_cs_get_type(
            context,
            coordinate_system_pj,
        ) == PJ_CS_TYPE_UNKNOWN:
            proj_destroy(coordinate_system_pj)
            pyproj_context_destroy(context)
            raise CRSError(
                "Invalid coordinate system string: "
                f"{coordinate_system_string}"
            )
        _clear_proj_error()
        return CoordinateSystem.create(context, coordinate_system_pj)

    @staticmethod
    def from_json_dict(dict coordinate_system_dict not None):
        """
        .. versionadded:: 2.5.0

        Create Coordinate System from a JSON dictionary.

        Parameters
        ----------
        coordinate_system_dict: str
            Coordinate System dictionary.

        Returns
        -------
        CoordinateSystem
        """
        return CoordinateSystem.from_string(
            json.dumps(coordinate_system_dict, cls=NumpyEncoder)
        )

    @staticmethod
    def from_json(str coordinate_system_json_str not None):
        """
        .. versionadded:: 2.5.0

        Create Coordinate System from a JSON string.

        Parameters
        ----------
        coordinate_system_json_str: str
            Coordinate System JSON string.

        Returns
        -------
        CoordinateSystem
        """
        return CoordinateSystem.from_json_dict(
            _load_proj_json(coordinate_system_json_str)
        )

    def to_cf(self, bint rotated_pole=False):
        """
        .. versionadded:: 3.0.0

        This converts a :obj:`pyproj.crs.CoordinateSystem` axis
        to a list of Climate and Forecast (CF) Version 1.8 dicts.

        Parameters
        ----------
        rotated_pole: bool, default=False
            If True, the geographic coordinates are on a rotated pole grid.
            This corresponds to the rotated_latitude_longitude grid_mapping_name.

        Returns
        -------
        list[dict]:
            CF-1.8 version of the CoordinateSystem.
        """
        axis_list = self.to_json_dict()["axis"]
        cf_params = []
        def get_linear_unit(axis):
            try:
                return f'{axis["unit"]["conversion_factor"]} metre'
            except TypeError:
                return axis["unit"]

        if self.name == "cartesian":
            for axis in axis_list:
                if axis["name"].lower() == "easting":
                    cf_axis = "X"
                else:
                    cf_axis = "Y"
                cf_params.append(dict(
                    axis=cf_axis,
                    long_name=axis["name"],
                    standard_name=f"projection_{cf_axis.lower()}_coordinate",
                    units=get_linear_unit(axis),
                ))
        elif self.name == "ellipsoidal":
            for axis in axis_list:
                if axis["abbreviation"].upper() in ("D", "H"):
                    cf_params.append(dict(
                        standard_name="height_above_reference_ellipsoid",
                        long_name=axis["name"],
                        units=axis["unit"],
                        positive=axis["direction"],
                        axis="Z",
                    ))
                else:
                    if "longitude" in axis["name"].lower():
                        cf_axis = "X"
                        name = "longitude"
                    else:
                        cf_axis = "Y"
                        name = "latitude"
                    if rotated_pole:
                        cf_params.append(dict(
                            standard_name=f"grid_{name}",
                            long_name=f"{name} in rotated pole grid",
                            units="degrees",
                            axis=cf_axis,
                        ))
                    else:
                        cf_params.append(dict(
                            standard_name=name,
                            long_name=f"{name} coordinate",
                            units=f'degrees_{axis["direction"]}',
                            axis=cf_axis,
                        ))
        elif self.name == "vertical":
            for axis in axis_list:
                cf_params.append(dict(
                    standard_name="height_above_reference_ellipsoid",
                    long_name=axis["name"],
                    units=get_linear_unit(axis),
                    positive=axis["direction"],
                    axis="Z",
                ))

        return cf_params


cdef class Ellipsoid(_CRSParts):
    """
    .. versionadded:: 2.0.0

    Ellipsoid for CRS

    Attributes
    ----------
    name: str
        The name of the ellipsoid.
    is_semi_minor_computed: int
        1 if True, 0 if False
    semi_major_metre: float
        The semi major axis in meters of the ellipsoid.
    semi_minor_metre: float
        The semi minor axis in meters of the ellipsoid.
    inverse_flattening: float
        The inverse flattening of the ellipsoid.

    """
    def __cinit__(self):
        # load in ellipsoid information if applicable
        self.semi_major_metre = float("NaN")
        self.semi_minor_metre = float("NaN")
        self.is_semi_minor_computed = False
        self.inverse_flattening = float("NaN")

    def __init__(self):
        raise RuntimeError(
            "Ellipsoid can only be initialized like 'Ellipsoid.from_*()'."
        )

    @staticmethod
    cdef Ellipsoid create(PJ_CONTEXT* context, PJ* ellipsoid_pj):
        cdef Ellipsoid ellips = Ellipsoid.__new__(Ellipsoid)
        ellips.context = context
        ellips.projobj = ellipsoid_pj
        cdef int is_semi_minor_computed = 0
        proj_ellipsoid_get_parameters(
            context,
            ellipsoid_pj,
            &ellips.semi_major_metre,
            &ellips.semi_minor_metre,
            &is_semi_minor_computed,
            &ellips.inverse_flattening,
        )
        ellips.is_semi_minor_computed = is_semi_minor_computed == 1
        ellips._set_base_info()
        _clear_proj_error()
        return ellips

    @staticmethod
    def from_authority(str auth_name not None, code not None):
        """
        .. versionadded:: 2.2.0

        Create an Ellipsoid from an authority code.

        Parameters
        ----------
        auth_name: str
            Name of the authority.
        code: str or int
            The code used by the authority.

        Returns
        -------
        Ellipsoid
        """
        cdef PJ_CONTEXT* context = pyproj_context_create()
        cdef PJ* ellipsoid_pj = proj_create_from_database(
            context,
            cstrencode(auth_name),
            cstrencode(str(code)),
            PJ_CATEGORY_ELLIPSOID,
            False,
            NULL,
        )

        if ellipsoid_pj == NULL:
            pyproj_context_destroy(context)
            raise CRSError(f"Invalid authority or code ({auth_name}, {code})")
        _clear_proj_error()
        return Ellipsoid.create(context, ellipsoid_pj)

    @staticmethod
    def from_epsg(code not None):
        """
        .. versionadded:: 2.2.0

        Create an Ellipsoid from an EPSG code.

        Parameters
        ----------
        code: str or int
            The code used by the EPSG.

        Returns
        -------
        Ellipsoid
        """
        return Ellipsoid.from_authority("EPSG", code)

    @staticmethod
    def _from_string(str ellipsoid_string not None):
        """
        Create an Ellipsoid from a string.

        Examples:
          - urn:ogc:def:ellipsoid:EPSG::7001
          - ELLIPSOID["Airy 1830",6377563.396,299.3249646,
            LENGTHUNIT["metre",1],
            ID["EPSG",7001]]

        Parameters
        ----------
        ellipsoid_string: str
            Ellipsoid string.

        Returns
        -------
        Ellipsoid
        """
        cdef PJ_CONTEXT* context = pyproj_context_create()
        cdef PJ* ellipsoid_pj = proj_create(
            context,
            cstrencode(ellipsoid_string)
        )
        if ellipsoid_pj == NULL or proj_get_type(ellipsoid_pj) != PJ_TYPE_ELLIPSOID:
            proj_destroy(ellipsoid_pj)
            pyproj_context_destroy(context)
            raise CRSError(
                f"Invalid ellipsoid string: {ellipsoid_string}"
            )
        _clear_proj_error()
        return Ellipsoid.create(context, ellipsoid_pj)

    @staticmethod
    def from_string(str ellipsoid_string not None):
        """
        .. versionadded:: 2.2.0

        Create an Ellipsoid from a string.

        Examples:
          - urn:ogc:def:ellipsoid:EPSG::7001
          - ELLIPSOID["Airy 1830",6377563.396,299.3249646,
            LENGTHUNIT["metre",1],
            ID["EPSG",7001]]
          - WGS 84

        Parameters
        ----------
        ellipsoid_string: str
            Ellipsoid string.

        Returns
        -------
        Ellipsoid
        """
        try:
            return Ellipsoid._from_string(ellipsoid_string)
        except CRSError as crs_err:
            try:
                return Ellipsoid.from_name(ellipsoid_string)
            except CRSError:
                raise crs_err

    @staticmethod
    def from_json_dict(dict ellipsoid_dict not None):
        """
        .. versionadded:: 2.4.0

        Create Ellipsoid from a JSON dictionary.

        Parameters
        ----------
        ellipsoid_dict: str
            Ellipsoid dictionary.

        Returns
        -------
        Ellipsoid
        """
        return Ellipsoid._from_string(json.dumps(ellipsoid_dict, cls=NumpyEncoder))

    @staticmethod
    def from_json(str ellipsoid_json_str not None):
        """
        .. versionadded:: 2.4.0

        Create Ellipsoid from a JSON string.

        Parameters
        ----------
        ellipsoid_json_str: str
            Ellipsoid JSON string.

        Returns
        -------
        Ellipsoid
        """
        return Ellipsoid.from_json_dict(_load_proj_json(ellipsoid_json_str))

    @staticmethod
    def _from_name(
        str ellipsoid_name,
        str auth_name,
    ):
        """
        .. versionadded:: 2.5.0

        Create a Ellipsoid from a name.

        Parameters
        ----------
        ellipsoid_name: str
            Ellipsoid name.
        auth_name: str
            The authority name to refine search (e.g. 'EPSG').
            If None, will search all authorities.

        Returns
        -------
        Ellipsoid
        """
        cdef PJ_CONTEXT* context = pyproj_context_create()
        cdef PJ* ellipsoid_pj = _from_name(
            context,
            ellipsoid_name,
            auth_name,
            PJ_TYPE_ELLIPSOID,
        )
        if ellipsoid_pj == NULL:
            pyproj_context_destroy(context)
            raise CRSError(f"Invalid ellipsoid name: {ellipsoid_name}")
        _clear_proj_error()
        return Ellipsoid.create(context, ellipsoid_pj)

    @staticmethod
    def from_name(
        str ellipsoid_name not None,
        str auth_name=None,
    ):
        """
        .. versionadded:: 2.5.0

        Create a Ellipsoid from a name.

        Examples:
          - WGS 84

        Parameters
        ----------
        ellipsoid_name: str
            Ellipsoid name.
        auth_name: str, optional
            The authority name to refine search (e.g. 'EPSG').
            If None, will search all authorities.

        Returns
        -------
        Ellipsoid
        """
        try:
            return Ellipsoid._from_name(
                ellipsoid_name=ellipsoid_name,
                auth_name=auth_name,
            )
        except CRSError:
            if auth_name not in ("PROJ", None):
                raise
            pass

        # add support for past names for PROJ ellipsoids
        try:
            ellipsoid_params = pj_ellps[
                _PJ_ELLPS_NAME_MAP.get(ellipsoid_name, ellipsoid_name)
            ]
        except KeyError:
            raise CRSError(f"Invalid ellipsoid name: {ellipsoid_name}")
        return CustomEllipsoid(
            name=ellipsoid_params["description"],
            semi_major_axis=ellipsoid_params["a"],
            semi_minor_axis=ellipsoid_params.get("b"),
            inverse_flattening=ellipsoid_params.get("rf"),
        )


cdef class PrimeMeridian(_CRSParts):
    """
    .. versionadded:: 2.0.0

    Prime Meridian for CRS

    Attributes
    ----------
    name: str
        The name of the prime meridian.
    unit_name: str
        The unit name for the prime meridian.

    """
    def __cinit__(self):
        self.unit_name = None

    def __init__(self):
        raise RuntimeError(
            "PrimeMeridian can only be initialized like 'PrimeMeridian.from_*()'."
        )

    @staticmethod
    cdef PrimeMeridian create(PJ_CONTEXT* context, PJ* prime_meridian_pj):
        cdef PrimeMeridian prime_meridian = PrimeMeridian.__new__(PrimeMeridian)
        prime_meridian.context = context
        prime_meridian.projobj = prime_meridian_pj
        cdef const char * unit_name
        proj_prime_meridian_get_parameters(
            prime_meridian.context,
            prime_meridian.projobj,
            &prime_meridian.longitude,
            &prime_meridian.unit_conversion_factor,
            &unit_name,
        )
        prime_meridian.unit_name = decode_or_undefined(unit_name)
        prime_meridian._set_base_info()
        _clear_proj_error()
        return prime_meridian

    @staticmethod
    def from_authority(str auth_name not None, code not None):
        """
        .. versionadded:: 2.2.0

        Create a PrimeMeridian from an authority code.

        Parameters
        ----------
        auth_name: str
            Name of the authority.
        code: str or int
            The code used by the authority.

        Returns
        -------
        PrimeMeridian
        """
        cdef PJ_CONTEXT* context = pyproj_context_create()
        cdef PJ* prime_meridian_pj = proj_create_from_database(
            context,
            cstrencode(auth_name),
            cstrencode(str(code)),
            PJ_CATEGORY_PRIME_MERIDIAN,
            False,
            NULL,
        )

        if prime_meridian_pj == NULL:
            pyproj_context_destroy(context)
            raise CRSError(f"Invalid authority or code ({auth_name}, {code})")
        _clear_proj_error()
        return PrimeMeridian.create(context, prime_meridian_pj)

    @staticmethod
    def from_epsg(code not None):
        """
        .. versionadded:: 2.2.0

        Create a PrimeMeridian from an EPSG code.

        Parameters
        ----------
        code: str or int
            The code used by EPSG.

        Returns
        -------
        PrimeMeridian
        """
        return PrimeMeridian.from_authority("EPSG", code)

    @staticmethod
    def _from_string(str prime_meridian_string not None):
        """
        Create an PrimeMeridian from a string.

        Examples:
          - urn:ogc:def:meridian:EPSG::8901
          - PRIMEM["Greenwich",0,
            ANGLEUNIT["degree",0.0174532925199433],
            ID["EPSG",8901]]

        Parameters
        ----------
        prime_meridian_string: str
            prime meridian string.

        Returns
        -------
        PrimeMeridian
        """
        cdef PJ_CONTEXT* context = pyproj_context_create()
        cdef PJ* prime_meridian_pj = proj_create(
            context,
            cstrencode(prime_meridian_string)
        )
        if (
            prime_meridian_pj == NULL or
            proj_get_type(prime_meridian_pj) != PJ_TYPE_PRIME_MERIDIAN
        ):
            proj_destroy(prime_meridian_pj)
            pyproj_context_destroy(context)
            raise CRSError(
                f"Invalid prime meridian string: {prime_meridian_string}"
            )
        _clear_proj_error()
        return PrimeMeridian.create(context, prime_meridian_pj)

    @staticmethod
    def from_string(str prime_meridian_string not None):
        """
        .. versionadded:: 2.2.0

        Create an PrimeMeridian from a string.

        Examples:
          - urn:ogc:def:meridian:EPSG::8901
          - PRIMEM["Greenwich",0,
            ANGLEUNIT["degree",0.0174532925199433],
            ID["EPSG",8901]]
          - Greenwich

        Parameters
        ----------
        prime_meridian_string: str
            prime meridian string.

        Returns
        -------
        PrimeMeridian
        """
        try:
            return PrimeMeridian._from_string(prime_meridian_string)
        except CRSError as crs_err:
            try:
                return PrimeMeridian.from_name(prime_meridian_string)
            except CRSError:
                raise crs_err

    @staticmethod
    def from_json_dict(dict prime_meridian_dict not None):
        """
        .. versionadded:: 2.4.0

        Create PrimeMeridian from a JSON dictionary.

        Parameters
        ----------
        prime_meridian_dict: str
            PrimeMeridian dictionary.

        Returns
        -------
        PrimeMeridian
        """
        return PrimeMeridian._from_string(
            json.dumps(prime_meridian_dict, cls=NumpyEncoder)
        )

    @staticmethod
    def from_json(str prime_meridian_json_str not None):
        """
        .. versionadded:: 2.4.0

        Create PrimeMeridian from a JSON string.

        Parameters
        ----------
        prime_meridian_json_str: str
            PrimeMeridian JSON string.

        Returns
        -------
        PrimeMeridian
        """
        return PrimeMeridian.from_json_dict(_load_proj_json(prime_meridian_json_str))

    @staticmethod
    def from_name(
        str prime_meridian_name not None,
        str auth_name=None,
    ):
        """
        .. versionadded:: 2.5.0

        Create a Prime Meridian from a name.

        Examples:
          - Greenwich

        Parameters
        ----------
        prime_meridian_name: str
            Prime Meridian name.
        auth_name: str, optional
            The authority name to refine search (e.g. 'EPSG').
            If None, will search all authorities.

        Returns
        -------
        PrimeMeridian
        """
        cdef PJ_CONTEXT* context = pyproj_context_create()
        cdef PJ* prime_meridian_pj = _from_name(
            context,
            prime_meridian_name,
            auth_name,
            PJ_TYPE_PRIME_MERIDIAN,
        )
        if prime_meridian_pj == NULL:
            pyproj_context_destroy(context)
            raise CRSError(
                f"Invalid prime meridian name: {prime_meridian_name}"
            )
        _clear_proj_error()
        return PrimeMeridian.create(context, prime_meridian_pj)


cdef dict _DATUM_TYPE_MAP = {
    PJ_TYPE_GEODETIC_REFERENCE_FRAME: "Geodetic Reference Frame",
    PJ_TYPE_DYNAMIC_GEODETIC_REFERENCE_FRAME: "Dynamic Geodetic Reference Frame",
    PJ_TYPE_VERTICAL_REFERENCE_FRAME: "Vertical Reference Frame",
    PJ_TYPE_DYNAMIC_VERTICAL_REFERENCE_FRAME: "Dynamic Vertical Reference Frame",
    PJ_TYPE_DATUM_ENSEMBLE: "Datum Ensemble",
    PJ_TYPE_TEMPORAL_DATUM: "Temporal Datum",
    PJ_TYPE_ENGINEERING_DATUM: "Engineering Datum",
    PJ_TYPE_PARAMETRIC_DATUM: "Parametric Datum",
}

cdef dict _PJ_DATUM_TYPE_MAP = {
    DatumType.DATUM_ENSEMBLE: PJ_TYPE_DATUM_ENSEMBLE,
    DatumType.GEODETIC_REFERENCE_FRAME: PJ_TYPE_GEODETIC_REFERENCE_FRAME,
    DatumType.DYNAMIC_GEODETIC_REFERENCE_FRAME:
    PJ_TYPE_DYNAMIC_GEODETIC_REFERENCE_FRAME,
    DatumType.VERTICAL_REFERENCE_FRAME: PJ_TYPE_VERTICAL_REFERENCE_FRAME,
    DatumType.DYNAMIC_VERTICAL_REFERENCE_FRAME:
    PJ_TYPE_DYNAMIC_VERTICAL_REFERENCE_FRAME,
}


cdef class Datum(_CRSParts):
    """
    .. versionadded:: 2.2.0

    Datum for CRS. If it is a compound CRS it is the horizontal datum.

    Attributes
    ----------
    name: str
        The name of the datum.

    """
    def __cinit__(self):
        self._ellipsoid = None
        self._prime_meridian = None

    def __init__(self):
        raise RuntimeError(
            "Datum can only be initialized like 'Datum.from_*()'."
        )

    @staticmethod
    cdef Datum create(PJ_CONTEXT* context, PJ* datum_pj):
        cdef Datum datum = Datum.__new__(Datum)
        datum.context = context
        datum.projobj = datum_pj
        datum._set_base_info()
        datum.type_name = _DATUM_TYPE_MAP[proj_get_type(datum.projobj)]
        return datum

    @staticmethod
    def _from_authority(str auth_name not None, code not None, PJ_CATEGORY category):
        """
        Create a Datum from an authority code.

        Parameters
        ----------
        auth_name: str
            Name of the authority.
        code: str or int
            The code used by the authority.

        Returns
        -------
        Datum
        """
        cdef PJ_CONTEXT* context = pyproj_context_create()

        cdef PJ* datum_pj = proj_create_from_database(
            context,
            cstrencode(auth_name),
            cstrencode(str(code)),
            category,
            False,
            NULL,
        )

        if datum_pj == NULL:
            pyproj_context_destroy(context)
            raise CRSError(f"Invalid authority or code ({auth_name}, {code})")
        _clear_proj_error()
        return Datum.create(context, datum_pj)

    @staticmethod
    def from_authority(str auth_name not None, code not None):
        """
        Create a Datum from an authority code.

        Parameters
        ----------
        auth_name: str
            Name of the authority.
        code: str or int
            The code used by the authority.

        Returns
        -------
        Datum
        """
        try:
            return Datum._from_authority(auth_name, code, PJ_CATEGORY_DATUM_ENSEMBLE)
        except CRSError:
            return Datum._from_authority(auth_name, code, PJ_CATEGORY_DATUM)

    @staticmethod
    def from_epsg(code not None):
        """
        Create a Datum from an EPSG code.

        Parameters
        ----------
        code: str or int
            The code used by EPSG.

        Returns
        -------
        Datum
        """
        return Datum.from_authority("EPSG", code)

    @staticmethod
    def _from_string(str datum_string not None):
        """
        Create a Datum from a string.

        Examples:
          - urn:ogc:def:datum:EPSG::6326
          - DATUM["World Geodetic System 1984",
            ELLIPSOID["WGS 84",6378137,298.257223563,
            LENGTHUNIT["metre",1]],
            ID["EPSG",6326]]

        Parameters
        ----------
        datum_string: str
            Datum string.

        Returns
        -------
        Datum
        """
        cdef PJ_CONTEXT* context = pyproj_context_create()
        cdef PJ* datum_pj = proj_create(
            context,
            cstrencode(datum_string)
        )
        if (
            datum_pj == NULL or
             proj_get_type(datum_pj) not in _DATUM_TYPE_MAP
        ):
            proj_destroy(datum_pj)
            pyproj_context_destroy(context)
            raise CRSError(f"Invalid datum string: {datum_string}")
        _clear_proj_error()
        return Datum.create(context, datum_pj)

    @staticmethod
    def from_string(str datum_string not None):
        """
        Create a Datum from a string.

        Examples:
          - urn:ogc:def:datum:EPSG::6326
          - DATUM["World Geodetic System 1984",
            ELLIPSOID["WGS 84",6378137,298.257223563,
            LENGTHUNIT["metre",1]],
            ID["EPSG",6326]]
          - World Geodetic System 1984

        Parameters
        ----------
        datum_string: str
            Datum string.

        Returns
        -------
        Datum
        """
        try:
            return Datum._from_string(datum_string)
        except CRSError as crs_err:
            try:
                return Datum.from_name(datum_string)
            except CRSError:
                raise crs_err

    @staticmethod
    def _from_name(
        str datum_name,
        str auth_name,
        object datum_type,
    ):
        """
        .. versionadded:: 2.5.0

        Create a Datum from a name.

        Parameters
        ----------
        datum_name: str
            Datum name.
        auth_name: str
            The authority name to refine search (e.g. 'EPSG').
            If None, will search all authorities.
        datum_type: DatumType
            The datum type to create.

        Returns
        -------
        Datum
        """
        pj_datum_type = _PJ_DATUM_TYPE_MAP[datum_type]
        cdef PJ_CONTEXT* context = pyproj_context_create()
        cdef PJ* datum_pj = _from_name(
            context,
            datum_name,
            auth_name,
            <PJ_TYPE>pj_datum_type,
        )
        if datum_pj == NULL:
            pyproj_context_destroy(context)
            raise CRSError(f"Invalid datum name: {datum_name}")
        _clear_proj_error()
        return Datum.create(context, datum_pj)

    @staticmethod
    def from_name(
        str datum_name not None,
        str auth_name=None,
        datum_type=None,
    ):
        """
        .. versionadded:: 2.5.0

        Create a Datum from a name.

        Examples:
          - WGS 84
          - World Geodetic System 1984

        Parameters
        ----------
        datum_name: str
            Datum name.
        auth_name: str, optional
            The authority name to refine search (e.g. 'EPSG').
            If None, will search all authorities.
        datum_type: DatumType, optional
            The datum type to create. If it is None, it uses any datum type.

        Returns
        -------
        Datum
        """
        if datum_type is None:
            # try creating name from all datum types
            first_error = None
            for datum_type in _PJ_DATUM_TYPE_MAP:
                try:
                    return Datum.from_name(
                        datum_name=datum_name,
                        auth_name=auth_name,
                        datum_type=datum_type,
                    )
                except CRSError as err:
                    if first_error is None:
                        first_error = err
            raise first_error

        datum_type = DatumType.create(datum_type)
        return Datum._from_name(
            datum_name=datum_name,
            auth_name=auth_name,
            datum_type=datum_type,
        )

    @staticmethod
    def from_json_dict(dict datum_dict not None):
        """
        .. versionadded:: 2.4.0

        Create Datum from a JSON dictionary.

        Parameters
        ----------
        datum_dict: str
            Datum dictionary.

        Returns
        -------
        Datum
        """
        return Datum._from_string(json.dumps(datum_dict, cls=NumpyEncoder))

    @staticmethod
    def from_json(str datum_json_str not None):
        """
        .. versionadded:: 2.4.0

        Create Datum from a JSON string.

        Parameters
        ----------
        datum_json_str: str
            Datum JSON string.

        Returns
        -------
        Datum
        """
        return Datum.from_json_dict(_load_proj_json(datum_json_str))

    @property
    def ellipsoid(self):
        """
        Returns
        -------
        Ellipsoid:
            The ellipsoid object with associated attributes.
        """
        if self._ellipsoid is not None:
            return None if self._ellipsoid is False else self._ellipsoid
        cdef PJ_CONTEXT* context = pyproj_context_create()
        cdef PJ* ellipsoid_pj = proj_get_ellipsoid(
            context,
            self.projobj,
        )
        _clear_proj_error()
        if ellipsoid_pj == NULL:
            pyproj_context_destroy(context)
            self._ellipsoid = False
            return None
        self._ellipsoid = Ellipsoid.create(context, ellipsoid_pj)
        return self._ellipsoid

    @property
    def prime_meridian(self):
        """
        Returns
        -------
        PrimeMeridian:
            The CRS prime meridian object with associated attributes.
        """
        if self._prime_meridian is not None:
            return None if self._prime_meridian is False else self._prime_meridian
        cdef PJ_CONTEXT* context = pyproj_context_create()
        cdef PJ* prime_meridian_pj = proj_get_prime_meridian(
            context,
            self.projobj,
        )
        _clear_proj_error()
        if prime_meridian_pj == NULL:
            pyproj_context_destroy(context)
            self._prime_meridian = False
            return None
        self._prime_meridian = PrimeMeridian.create(
            context,
            prime_meridian_pj,
        )
        return self._prime_meridian


cdef class Param:
    """
    .. versionadded:: 2.2.0

    Coordinate operation parameter.

    Attributes
    ----------
    name: str
        The name of the parameter.
    auth_name: str
        The authority name of the parameter (i.e. EPSG).
    code: str
        The code of the parameter (i.e. 9807).
    value: str or double
        The value of the parameter.
    unit_conversion_factor: double
        The factor to convert to meters.
    unit_name: str
        The name of the unit.
    unit_auth_name: str
        The authority name of the unit (i.e. EPSG).
    unit_code: str
        The code of the unit (i.e. 9807).
    unit_category: str
        The category of the unit (“unknown”, “none”, “linear”,
        “angular”, “scale”, “time” or “parametric”).

    """
    def __cinit__(self):
        self.name = "undefined"
        self.auth_name = "undefined"
        self.code = "undefined"
        self.value = "undefined"
        self.unit_conversion_factor = float("nan")
        self.unit_name = "undefined"
        self.unit_auth_name = "undefined"
        self.unit_code = "undefined"
        self.unit_category = "undefined"

    @staticmethod
    cdef Param create(PJ_CONTEXT* context, PJ* projobj, int param_idx):
        cdef:
            Param param = Param()
            const char *out_name
            const char *out_auth_name
            const char *out_code
            const char *out_value
            const char *out_value_string
            const char *out_unit_name
            const char *out_unit_auth_name
            const char *out_unit_code
            const char *out_unit_category
            double value_double

        proj_coordoperation_get_param(
            context,
            projobj,
            param_idx,
            &out_name,
            &out_auth_name,
            &out_code,
            &value_double,
            &out_value_string,
            &param.unit_conversion_factor,
            &out_unit_name,
            &out_unit_auth_name,
            &out_unit_code,
            &out_unit_category
        )
        param.name = decode_or_undefined(out_name)
        param.auth_name = decode_or_undefined(out_auth_name)
        param.code = decode_or_undefined(out_code)
        param.unit_name = decode_or_undefined(out_unit_name)
        param.unit_auth_name = decode_or_undefined(out_unit_auth_name)
        param.unit_code = decode_or_undefined(out_unit_code)
        param.unit_category = decode_or_undefined(out_unit_category)
        value_string = cstrdecode(out_value_string)
        param.value = value_double if value_string is None else value_string
        return param

    def __str__(self):
        return f"{self.auth_name}:{self.auth_code}"

    def __repr__(self):
        return (
            f"Param(name={self.name}, auth_name={self.auth_name}, code={self.code}, "
            f"value={self.value}, unit_name={self.unit_name}, "
            f"unit_auth_name={self.unit_auth_name}, unit_code={self.unit_code}, "
            f"unit_category={self.unit_category})"
        )


cdef class Grid:
    """
    .. versionadded:: 2.2.0

    Coordinate operation grid.

    Attributes
    ----------
    short_name: str
        The short name of the grid.
    full_name: str
        The full name of the grid.
    package_name: str
        The package name where the grid might be found.
    url: str
        The grid URL or the package URL where the grid might be found.
    direct_download: int
        If 1, *url* can be downloaded directly.
    open_license: int
        If 1, the grid is released with an open license.
    available: int
        If 1, the grid is available at runtime.

    """
    def __cinit__(self):
        self.short_name = "undefined"
        self.full_name = "undefined"
        self.package_name = "undefined"
        self.url = "undefined"
        self.direct_download = False
        self.open_license = False
        self.available = False

    @staticmethod
    cdef Grid create(PJ_CONTEXT* context, PJ* projobj, int grid_idx):
        cdef:
            Grid grid = Grid()
            const char *out_short_name
            const char *out_full_name
            const char *out_package_name
            const char *out_url
            int direct_download = 0
            int open_license = 0
            int available = 0

        proj_coordoperation_get_grid_used(
            context,
            projobj,
            grid_idx,
            &out_short_name,
            &out_full_name,
            &out_package_name,
            &out_url,
            &direct_download,
            &open_license,
            &available
        )
        grid.short_name = decode_or_undefined(out_short_name)
        grid.full_name = decode_or_undefined(out_full_name)
        grid.package_name = decode_or_undefined(out_package_name)
        grid.url = decode_or_undefined(out_url)
        grid.direct_download = direct_download == 1
        grid.open_license = open_license == 1
        grid.available = available == 1
        _clear_proj_error()
        return grid

    def __str__(self):
        return self.full_name

    def __repr__(self):
        return (
            f"Grid(short_name={self.short_name}, full_name={self.full_name}, "
            f"package_name={self.package_name}, url={self.url}, "
            f"direct_download={self.direct_download}, "
            f"open_license={self.open_license}, available={self.available})"
        )


cdef dict _COORDINATE_OPERATION_TYPE_MAP = {
    PJ_TYPE_UNKNOWN: "Unknown",
    PJ_TYPE_CONVERSION: "Conversion",
    PJ_TYPE_TRANSFORMATION: "Transformation",
    PJ_TYPE_CONCATENATED_OPERATION: "Concatenated Operation",
    PJ_TYPE_OTHER_COORDINATE_OPERATION: "Other Coordinate Operation",
}

cdef dict _PJ_COORDINATE_OPERATION_TYPE_MAP = {
    CoordinateOperationType.CONVERSION: PJ_TYPE_CONVERSION,
    CoordinateOperationType.TRANSFORMATION: PJ_TYPE_TRANSFORMATION,
    CoordinateOperationType.CONCATENATED_OPERATION: PJ_TYPE_CONCATENATED_OPERATION,
    CoordinateOperationType.OTHER_COORDINATE_OPERATION:
    PJ_TYPE_OTHER_COORDINATE_OPERATION,
}

cdef class CoordinateOperation(_CRSParts):
    """
    .. versionadded:: 2.2.0

    Coordinate operation for CRS.

    Attributes
    ----------
    name: str
        The name of the method(projection) with authority information.
    method_name: str
        The method (projection) name.
    method_auth_name: str
        The method authority name.
    method_code: str
        The method code.
    is_instantiable: int
        If 1, a coordinate operation can be instantiated as a PROJ pipeline.
        This also checks that referenced grids are available.
    has_ballpark_transformation: int
        If 1, the coordinate operation has a “ballpark” transformation,
        that is a very approximate one, due to lack of more accurate transformations.
    accuracy: float
        The accuracy (in metre) of a coordinate operation.

    """
    def __cinit__(self):
        self._params = None
        self._grids = None
        self._area_of_use = None
        self.method_name = "undefined"
        self.method_auth_name = "undefined"
        self.method_code = "undefined"
        self.is_instantiable = False
        self.has_ballpark_transformation = False
        self.accuracy = float("nan")
        self._towgs84 = None
        self._operations = None

    def __init__(self):
        raise RuntimeError(
            "CoordinateOperation can only be initialized like "
            "CoordinateOperation.from_*()'."
        )

    @staticmethod
    cdef CoordinateOperation create(PJ_CONTEXT* context, PJ* coord_operation_pj):
        cdef CoordinateOperation coord_operation = CoordinateOperation.__new__(
            CoordinateOperation
        )
        coord_operation.context = context
        coord_operation.projobj = coord_operation_pj
        cdef const char *out_method_name = NULL
        cdef const char *out_method_auth_name = NULL
        cdef const char *out_method_code = NULL
        proj_coordoperation_get_method_info(
            coord_operation.context,
            coord_operation.projobj,
            &out_method_name,
            &out_method_auth_name,
            &out_method_code
        )
        coord_operation._set_base_info()
        coord_operation.method_name = decode_or_undefined(out_method_name)
        coord_operation.method_auth_name = decode_or_undefined(out_method_auth_name)
        coord_operation.method_code = decode_or_undefined(out_method_code)
        coord_operation.accuracy = proj_coordoperation_get_accuracy(
            coord_operation.context,
            coord_operation.projobj
        )
        coord_operation.is_instantiable = proj_coordoperation_is_instantiable(
            coord_operation.context,
            coord_operation.projobj
        ) == 1
        coord_operation.has_ballpark_transformation = \
            proj_coordoperation_has_ballpark_transformation(
                coord_operation.context,
                coord_operation.projobj
            ) == 1
        cdef PJ_TYPE operation_type = proj_get_type(coord_operation.projobj)
        coord_operation.type_name = _COORDINATE_OPERATION_TYPE_MAP[operation_type]
        _clear_proj_error()
        return coord_operation

    @staticmethod
    def from_authority(
        str auth_name not None,
        code not None,
        bint use_proj_alternative_grid_names=False,
    ):
        """
        Create a CoordinateOperation from an authority code.

        Parameters
        ----------
        auth_name: str
            Name of the authority.
        code: str or int
            The code used by the authority.
        use_proj_alternative_grid_names: bool, default=False
            Use the PROJ alternative grid names.

        Returns
        -------
        CoordinateOperation
        """
        cdef PJ_CONTEXT* context = pyproj_context_create()
        cdef PJ* coord_operation_pj = proj_create_from_database(
            context,
            cstrencode(auth_name),
            cstrencode(str(code)),
            PJ_CATEGORY_COORDINATE_OPERATION,
            use_proj_alternative_grid_names,
            NULL,
        )

        if coord_operation_pj == NULL:
            pyproj_context_destroy(context)
            raise CRSError(f"Invalid authority or code ({auth_name}, {code})")
        _clear_proj_error()
        return CoordinateOperation.create(context, coord_operation_pj)

    @staticmethod
    def from_epsg(code not None, bint use_proj_alternative_grid_names= False):
        """
        Create a CoordinateOperation from an EPSG code.

        Parameters
        ----------
        code: str or int
            The code used by EPSG.
        use_proj_alternative_grid_names: bool, default=False
            Use the PROJ alternative grid names.

        Returns
        -------
        CoordinateOperation
        """
        return CoordinateOperation.from_authority(
            "EPSG", code, use_proj_alternative_grid_names
        )

    @staticmethod
    def _from_string(str coordinate_operation_string not None):
        """
        Create a CoordinateOperation from a string.

        Example:
          - urn:ogc:def:coordinateOperation:EPSG::1671

        Parameters
        ----------
        coordinate_operation_string: str
            Coordinate operation string.

        Returns
        -------
        CoordinateOperation
        """
        cdef PJ_CONTEXT* context = pyproj_context_create()
        cdef PJ* coord_operation_pj = proj_create(
            context,
            cstrencode(coordinate_operation_string)
        )
        if (
            coord_operation_pj == NULL or
            proj_get_type(coord_operation_pj) not in (
                PJ_TYPE_CONVERSION,
                PJ_TYPE_TRANSFORMATION,
                PJ_TYPE_CONCATENATED_OPERATION,
                PJ_TYPE_OTHER_COORDINATE_OPERATION,
            )
        ):
            proj_destroy(coord_operation_pj)
            pyproj_context_destroy(context)
            raise CRSError(
                "Invalid coordinate operation string: "
                f"{coordinate_operation_string}"
            )
        _clear_proj_error()
        return CoordinateOperation.create(context, coord_operation_pj)

    @staticmethod
    def from_string(str coordinate_operation_string not None):
        """
        Create a CoordinateOperation from a string.

        Example:
          - urn:ogc:def:coordinateOperation:EPSG::1671
          - UTM zone 14N
          - +proj=utm +zone=14

        Parameters
        ----------
        coordinate_operation_string: str
            Coordinate operation string.

        Returns
        -------
        CoordinateOperation
        """
        try:
            return CoordinateOperation._from_string(coordinate_operation_string)
        except CRSError as crs_err:
            try:
                return CoordinateOperation.from_name(coordinate_operation_string)
            except CRSError:
                raise crs_err

    @staticmethod
    def from_json_dict(dict coordinate_operation_dict not None):
        """
        Create CoordinateOperation from a JSON dictionary.

        .. versionadded:: 2.4.0

        Parameters
        ----------
        coordinate_operation_dict: str
            CoordinateOperation dictionary.

        Returns
        -------
        CoordinateOperation
        """
        return CoordinateOperation._from_string(
            json.dumps(coordinate_operation_dict, cls=NumpyEncoder)
        )

    @staticmethod
    def from_json(str coordinate_operation_json_str not None):
        """
        Create CoordinateOperation from a JSON string.

        .. versionadded:: 2.4.0

        Parameters
        ----------
        coordinate_operation_json_str: str
            CoordinateOperation JSON string.

        Returns
        -------
        CoordinateOperation
        """
        return CoordinateOperation.from_json_dict(
            _load_proj_json(coordinate_operation_json_str
        ))

    @staticmethod
    def from_name(
        str coordinate_operation_name not None,
        str auth_name=None,
        coordinate_operation_type not None=CoordinateOperationType.CONVERSION,
    ):
        """
        .. versionadded:: 2.5.0

        Create a Coordinate Operation from a name.

        Examples:
          - UTM zone 14N

        Parameters
        ----------
        coordinate_operation_name: str
            Coordinate Operation name.
        auth_name: str, optional
            The authority name to refine search (e.g. 'EPSG').
            If None, will search all authorities.
        coordinate_operation_type: CoordinateOperationType, optional
            The coordinate operation type to create. Default is
            ``pyproj.crs.enums.CoordinateOperationType.CONVERSION``

        Returns
        -------
        CoordinateOperation
        """
        pj_coordinate_operation_type = _PJ_COORDINATE_OPERATION_TYPE_MAP[
            CoordinateOperationType.create(coordinate_operation_type)
        ]
        cdef PJ_CONTEXT* context = pyproj_context_create()
        cdef PJ* coordinate_operation_pj = _from_name(
            context,
            coordinate_operation_name,
            auth_name,
            <PJ_TYPE>pj_coordinate_operation_type,
        )
        if coordinate_operation_pj == NULL:
            pyproj_context_destroy(context)
            raise CRSError(
                "Invalid coordinate operation name: "
                f"{coordinate_operation_name}"
            )
        _clear_proj_error()
        return CoordinateOperation.create(context, coordinate_operation_pj)

    @property
    def params(self):
        """
        Returns
        -------
        list[Param]:
            The coordinate operation parameters.
        """
        if self._params is not None:
            return self._params
        self._params = []
        cdef int num_params = 0
        num_params = proj_coordoperation_get_param_count(
            self.context,
            self.projobj
        )
        for param_idx from 0 <= param_idx < num_params:
            self._params.append(
                Param.create(
                    self.context,
                    self.projobj,
                    param_idx
                )
            )
        _clear_proj_error()
        return self._params

    @property
    def grids(self):
        """
        Returns
        -------
        list[Grid]:
            The coordinate operation grids.
        """
        if self._grids is not None:
            return self._grids
        self._grids = []
        cdef int num_grids = 0
        num_grids = proj_coordoperation_get_grid_used_count(
            self.context,
            self.projobj
        )
        for grid_idx from 0 <= grid_idx < num_grids:
            self._grids.append(
                Grid.create(
                    self.context,
                    self.projobj,
                    grid_idx
                )
            )
        _clear_proj_error()
        return self._grids

    @property
    def area_of_use(self):
        """
        Returns
        -------
        AreaOfUse:
            The area of use object with associated attributes.
        """
        if self._area_of_use is not None:
            return self._area_of_use
        self._area_of_use = create_area_of_use(self.context, self.projobj)
        return self._area_of_use

    def to_proj4(self, version not None=ProjVersion.PROJ_5, bint pretty=False):
        """
        Convert the projection to a PROJ string.

        .. versionadded:: 3.1.0 pretty

        Parameters
        ----------
        version: pyproj.enums.ProjVersion, default=pyproj.enums.ProjVersion.PROJ_5
            The version of the PROJ string output.
        pretty: bool, default=False
            If True, it will set the output to be a multiline string.

        Returns
        -------
        str:
            The PROJ string.

        """
        return _to_proj4(self.context, self.projobj, version=version, pretty=pretty)

    @property
    def towgs84(self):
        """
        Returns
        -------
        list[float]:
            A list of 3 or 7 towgs84 values if they exist.

        """
        if self._towgs84 is not None:
            return self._towgs84
        towgs84_dict = OrderedDict(
            (
                ('X-axis translation', None),
                ('Y-axis translation', None),
                ('Z-axis translation', None),
                ('X-axis rotation', None),
                ('Y-axis rotation', None),
                ('Z-axis rotation', None),
                ('Scale difference', None),
            )
        )
        for param in self.params:
            if param.name in towgs84_dict:
                towgs84_dict[param.name] = param.value
        self._towgs84 = [val for val in towgs84_dict.values() if val is not None]
        return self._towgs84

    @property
    def operations(self):
        """
        .. versionadded:: 2.4.0

        Returns
        -------
        tuple[CoordinateOperation]:
            The operations in a concatenated operation.

        """
        if self._operations is not None:
            return self._operations
        self._operations = _get_concatenated_operations(self.context, self.projobj)
        return self._operations

    def __repr__(self):
        return (
            f"<Coordinate Operation: {self.type_name}>\n"
            f"Name: {self.name}\n"
            f"Method: {self.method_name}\n"
            f"Area of Use:\n{self.area_of_use or '- undefined'}"
        )

AuthorityMatchInfo = namedtuple(
    "AuthorityMatchInfo",
    [
        "auth_name",
        "code",
        "confidence",
    ],
)
AuthorityMatchInfo.__doc__ = """
.. versionadded:: 3.2.0

CRS Authority Match Information

Parameters
----------
auth_name: str
    Authority name.
code: str
    Object code.
confidence: int
    Confidence that this CRS matches
    the authority and code.
"""


cdef dict _CRS_TYPE_MAP = {
    PJ_TYPE_UNKNOWN: "Unknown CRS",
    PJ_TYPE_CRS: "CRS",
    PJ_TYPE_GEODETIC_CRS: "Geodetic CRS",
    PJ_TYPE_GEOCENTRIC_CRS: "Geocentric CRS",
    PJ_TYPE_GEOGRAPHIC_CRS: "Geographic CRS",
    PJ_TYPE_GEOGRAPHIC_2D_CRS: "Geographic 2D CRS",
    PJ_TYPE_GEOGRAPHIC_3D_CRS: "Geographic 3D CRS",
    PJ_TYPE_VERTICAL_CRS: "Vertical CRS",
    PJ_TYPE_PROJECTED_CRS: "Projected CRS",
    PJ_TYPE_COMPOUND_CRS: "Compound CRS",
    PJ_TYPE_TEMPORAL_CRS: "Temporal CRS",
    PJ_TYPE_ENGINEERING_CRS: "Engineering CRS",
    PJ_TYPE_BOUND_CRS: "Bound CRS",
    PJ_TYPE_OTHER_CRS: "Other CRS",
}

IF (CTE_PROJ_VERSION_MAJOR, CTE_PROJ_VERSION_MINOR) >= (9, 2):
    _CRS_TYPE_MAP[PJ_TYPE_DERIVED_PROJECTED_CRS] = "Derived Projected CRS"

cdef class _CRS(Base):
    """
    .. versionadded:: 2.0.0

    The cython CRS class to be used as the base for the
    python CRS class.
    """
    def __cinit__(self):
        self._ellipsoid = None
        self._area_of_use = None
        self._prime_meridian = None
        self._datum = None
        self._sub_crs_list = None
        self._source_crs = None
        self._target_crs = None
        self._geodetic_crs = None
        self._coordinate_system = None
        self._coordinate_operation = None
        self._type_name = None

    def __init__(self, const char *proj_string):
        self.context = pyproj_context_create()
        # initialize projection
        self.projobj = proj_create(
            self.context,
            proj_string,
        )
        if self.projobj == NULL:
            raise CRSError(f"Invalid projection: {proj_string}")
        # make sure the input is a CRS
        if not proj_is_crs(self.projobj):
            raise CRSError(f"Input is not a CRS: {proj_string}")
        # set proj information
        self.srs = proj_string
        self._type = proj_get_type(self.projobj)
        self._set_base_info()
        _clear_proj_error()

    @property
    def type_name(self):
        """
        Returns
        -------
        str:
            The name of the type of the CRS object.
        """
        if self._type_name is not None:
            return self._type_name
        self._type_name = _CRS_TYPE_MAP[self._type]
        if not self.is_derived or self._type == PJ_TYPE_PROJECTED_CRS:
            # Projected CRS are derived by definition
            # https://github.com/OSGeo/PROJ/issues/3525#issuecomment-1365790999
            return self._type_name

        # Handle Derived Projected CRS
        # https://github.com/OSGeo/PROJ/issues/3525#issuecomment-1366002289
        IF (CTE_PROJ_VERSION_MAJOR, CTE_PROJ_VERSION_MINOR) < (9, 2):
            if self._type == PJ_TYPE_OTHER_CRS:
                self._type_name = "Derived Projected CRS"
                return self._type_name
        ELSE:
            if self._type == PJ_TYPE_DERIVED_PROJECTED_CRS:
                return self._type_name

        self._type_name = f"Derived {self._type_name}"
        return self._type_name

    @property
    def axis_info(self):
        """
        Retrieves all relevant axis information in the CRS.
        If it is a Bound CRS, it gets the axis list from the Source CRS.
        If it is a Compound CRS, it gets the axis list from the Sub CRS list.

        Returns
        -------
        list[Axis]:
            The list of axis information.
        """
        axis_info_list = []
        if self.coordinate_system:
            axis_info_list.extend(self.coordinate_system.axis_list)
        elif self.is_bound and self.source_crs:
            axis_info_list.extend(self.source_crs.axis_info)
        else:
            for sub_crs in self.sub_crs_list:
                axis_info_list.extend(sub_crs.axis_info)
        return axis_info_list

    @property
    def area_of_use(self):
        """
        Returns
        -------
        AreaOfUse:
            The area of use object with associated attributes.
        """
        if self._area_of_use is not None:
            return self._area_of_use
        self._area_of_use = create_area_of_use(self.context, self.projobj)
        return self._area_of_use

    @property
    def ellipsoid(self):
        """
        .. versionadded:: 2.2.0

        Returns
        -------
        Ellipsoid:
            The ellipsoid object with associated attributes.
        """
        if self._ellipsoid is not None:
            return None if self._ellipsoid is False else self._ellipsoid
        cdef PJ_CONTEXT* context = pyproj_context_create()
        cdef PJ* ellipsoid_pj = proj_get_ellipsoid(
            context,
            self.projobj
        )
        _clear_proj_error()
        if ellipsoid_pj == NULL:
            pyproj_context_destroy(context)
            self._ellipsoid = False
            return None
        self._ellipsoid = Ellipsoid.create(context, ellipsoid_pj)
        return self._ellipsoid

    @property
    def prime_meridian(self):
        """
        .. versionadded:: 2.2.0

        Returns
        -------
        PrimeMeridian:
            The prime meridian object with associated attributes.
        """
        if self._prime_meridian is not None:
            return None if self._prime_meridian is True else self._prime_meridian
        cdef PJ_CONTEXT* context = pyproj_context_create()
        cdef PJ* prime_meridian_pj = proj_get_prime_meridian(
            context,
            self.projobj,
        )
        _clear_proj_error()
        if prime_meridian_pj == NULL:
            pyproj_context_destroy(context)
            self._prime_meridian = False
            return None
        self._prime_meridian = PrimeMeridian.create(context, prime_meridian_pj)
        return self._prime_meridian

    @property
    def datum(self):
        """
        .. versionadded:: 2.2.0

        Returns
        -------
        Datum
        """
        if self._datum is not None:
            return None if self._datum is False else self._datum
        cdef PJ_CONTEXT* context = pyproj_context_create()
        cdef PJ* datum_pj = proj_crs_get_datum(
            context,
            self.projobj,
        )
        if datum_pj == NULL:
            datum_pj = proj_crs_get_horizontal_datum(
                context,
                self.projobj,
            )
        _clear_proj_error()
        if datum_pj == NULL:
            pyproj_context_destroy(context)
            self._datum = False
            return None
        self._datum = Datum.create(context, datum_pj)
        return self._datum

    @property
    def coordinate_system(self):
        """
        .. versionadded:: 2.2.0

        Returns
        -------
        CoordinateSystem
        """
        if self._coordinate_system is not None:
            return None if self._coordinate_system is False else self._coordinate_system
        cdef PJ_CONTEXT* context = pyproj_context_create()
        cdef PJ* coord_system_pj = proj_crs_get_coordinate_system(
            context,
            self.projobj
        )
        _clear_proj_error()
        if coord_system_pj == NULL:
            pyproj_context_destroy(context)
            self._coordinate_system = False
            return None

        self._coordinate_system = CoordinateSystem.create(
            context,
            coord_system_pj,
        )
        return self._coordinate_system

    @property
    def coordinate_operation(self):
        """
        .. versionadded:: 2.2.0

        Returns
        -------
        CoordinateOperation
        """
        if self._coordinate_operation is not None:
            return (
                None
                if self._coordinate_operation is False
                else self._coordinate_operation
            )

        if not (
            self.is_bound or self.is_derived
        ):
            self._coordinate_operation = False
            return None

        cdef PJ_CONTEXT* context = pyproj_context_create()
        cdef PJ* coord_pj = proj_crs_get_coordoperation(
            context,
            self.projobj
        )
        _clear_proj_error()
        if coord_pj == NULL:
            pyproj_context_destroy(context)
            self._coordinate_operation = False
            return None
        self._coordinate_operation = CoordinateOperation.create(
            context,
            coord_pj,
        )
        return self._coordinate_operation

    @property
    def source_crs(self):
        """
        Returns
        -------
        _CRS:
            The base CRS of a BoundCRS or a DerivedCRS/ProjectedCRS.
        """
        if self._source_crs is not None:
            return None if self._source_crs is False else self._source_crs
        cdef PJ * projobj = proj_get_source_crs(self.context, self.projobj)
        _clear_proj_error()
        if projobj == NULL:
            self._source_crs = False
            return None
        try:
            self._source_crs = _CRS(_to_wkt(
                self.context,
                projobj,
                version=WktVersion.WKT2_2019,
                pretty=False,
            ))
        finally:
            proj_destroy(projobj)
        return self._source_crs

    @property
    def target_crs(self):
        """
        .. versionadded:: 2.2.0

        Returns
        -------
        _CRS:
            The hub CRS of a BoundCRS.
        """
        if self._target_crs is not None:
            return None if self._target_crs is False else self._target_crs
        cdef PJ * projobj = proj_get_target_crs(self.context, self.projobj)
        _clear_proj_error()
        if projobj == NULL:
            self._target_crs = False
            return None
        try:
            self._target_crs = _CRS(_to_wkt(
                self.context,
                projobj,
                version=WktVersion.WKT2_2019,
                pretty=False,
            ))
        finally:
            proj_destroy(projobj)
        return self._target_crs

    @property
    def sub_crs_list(self):
        """
        If the CRS is a compound CRS, it will return a list of sub CRS objects.

        Returns
        -------
        list[_CRS]
        """
        if self._sub_crs_list is not None:
            return self._sub_crs_list
        if not self.is_compound:
            self._sub_crs_list = []
            return self._sub_crs_list

        cdef int iii = 0
        cdef PJ * projobj = proj_crs_get_sub_crs(
            self.context,
            self.projobj,
            iii,
        )
        self._sub_crs_list = []
        while projobj != NULL:
            try:
                self._sub_crs_list.append(_CRS(_to_wkt(
                    self.context,
                    projobj,
                    version=WktVersion.WKT2_2019,
                    pretty=False,
                )))
            finally:
                proj_destroy(projobj)  # deallocate temp proj
            iii += 1
            projobj = proj_crs_get_sub_crs(
                self.context,
                self.projobj,
                iii,
            )
        _clear_proj_error()
        return self._sub_crs_list

    @property
    def geodetic_crs(self):
        """
        .. versionadded:: 2.2.0

        The geodeticCRS / geographicCRS from the CRS.

        Returns
        -------
        _CRS
        """
        if self._geodetic_crs is not None:
            return self._geodetic_crs if self. _geodetic_crs is not False else None
        cdef PJ * projobj = proj_crs_get_geodetic_crs(self.context, self.projobj)
        _clear_proj_error()
        if projobj == NULL:
            self._geodetic_crs = False
            return None
        try:
            self._geodetic_crs = _CRS(_to_wkt(
                self.context,
                projobj,
                version=WktVersion.WKT2_2019,
                pretty=False,
            ))
        finally:
            proj_destroy(projobj)  # deallocate temp proj
        return self._geodetic_crs

    def to_proj4(self, version=ProjVersion.PROJ_4):
        """
        Convert the projection to a PROJ string.

        .. warning:: You will likely lose important projection
          information when converting to a PROJ string from
          another format. See:
          https://proj.org/faq.html#what-is-the-best-format-for-describing-coordinate-reference-systems  # noqa: E501

        Parameters
        ----------
        version: pyproj.enums.ProjVersion, default=pyproj.enums.ProjVersion.PROJ_4
            The version of the PROJ string output.

        Returns
        -------
        str
        """
        warnings.warn(
            "You will likely lose important projection information when "
            "converting to a PROJ string from another format. See: "
            "https://proj.org/faq.html#what-is-the-best-format-for-describing-"
            "coordinate-reference-systems"
        )
        return _to_proj4(self.context, self.projobj, version=version, pretty=False)

    def to_epsg(self, int min_confidence=70):
        """
        Return the EPSG code best matching the CRS
        or None if it a match is not found.

        Example:

        >>> from pyproj import CRS
        >>> ccs = CRS("EPSG:4328")
        >>> ccs.to_epsg()
        4328

        If the CRS is bound, you can attempt to get an epsg code from
        the source CRS:

        >>> from pyproj import CRS
        >>> ccs = CRS("+proj=geocent +datum=WGS84 +towgs84=0,0,0")
        >>> ccs.to_epsg()
        >>> ccs.source_crs.to_epsg()
        4978
        >>> ccs == CRS.from_epsg(4978)
        False

        Parameters
        ----------
        min_confidence: int, default=70
            A value between 0-100 where 100 is the most confident.
            :ref:`min_confidence`


        Returns
        -------
        Optional[int]:
            The best matching EPSG code matching the confidence level.
        """
        auth_info = self.to_authority(
            auth_name="EPSG",
            min_confidence=min_confidence
        )
        if auth_info is not None and auth_info[0].upper() == "EPSG":
            return int(auth_info[1])
        return None

    def to_authority(self, str auth_name=None, int min_confidence=70):
        """
        .. versionadded:: 2.2.0

        Return the authority name and code best matching the CRS
        or None if it a match is not found.

        Example:

        >>> from pyproj import CRS
        >>> ccs = CRS("EPSG:4328")
        >>> ccs.to_authority()
        ('EPSG', '4328')

        If the CRS is bound, you can get an authority from
        the source CRS:

        >>> from pyproj import CRS
        >>> ccs = CRS("+proj=geocent +datum=WGS84 +towgs84=0,0,0")
        >>> ccs.to_authority()
        >>> ccs.source_crs.to_authority()
        ('EPSG', '4978')
        >>> ccs == CRS.from_authorty('EPSG', '4978')
        False

        Parameters
        ----------
        auth_name: str, optional
            The name of the authority to filter by.
        min_confidence: int, default=70
            A value between 0-100 where 100 is the most confident.
            :ref:`min_confidence`

        Returns
        -------
        tuple(str, str) or None:
            The best matching (<auth_name>, <code>) for the confidence level.
        """
        try:
            authority = self.list_authority(
                auth_name=auth_name, min_confidence=min_confidence,
            )[0]
            return authority.auth_name, authority.code
        except IndexError:
            return None

    def list_authority(self, str auth_name=None, int min_confidence=70):
        """
        .. versionadded:: 3.2.0

        Return the authority names and codes best matching the CRS.

        Example:

        >>> from pyproj import CRS
        >>> ccs = CRS("EPSG:4328")
        >>> ccs.list_authority()
        [AuthorityMatchInfo(auth_name='EPSG', code='4326', confidence=100)]

        If the CRS is bound, you can get an authority from
        the source CRS:

        >>> from pyproj import CRS
        >>> ccs = CRS("+proj=geocent +datum=WGS84 +towgs84=0,0,0")
        >>> ccs.list_authority()
        []
        >>> ccs.source_crs.list_authority()
        [AuthorityMatchInfo(auth_name='EPSG', code='4978', confidence=70)]
        >>> ccs == CRS.from_authorty('EPSG', '4978')
        False

        Parameters
        ----------
        auth_name: str, optional
            The name of the authority to filter by.
        min_confidence: int, default=70
            A value between 0-100 where 100 is the most confident.
            :ref:`min_confidence`

        Returns
        -------
        list[AuthorityMatchInfo]:
            List of authority matches for the CRS.
        """
        # get list of possible matching projections
        cdef PJ_OBJ_LIST *proj_list = NULL
        cdef int *c_out_confidence_list = NULL
        cdef int num_proj_objects = -9999
        cdef bytes b_auth_name
        cdef char *user_auth_name = NULL
        cdef int iii = 0

        if auth_name is not None:
            b_auth_name = cstrencode(auth_name)
            user_auth_name = b_auth_name

        out_confidence_list = []
        try:
            proj_list = proj_identify(
                self.context,
                self.projobj,
                user_auth_name,
                NULL,
                &c_out_confidence_list
            )
            if proj_list != NULL:
                num_proj_objects = proj_list_get_count(proj_list)
            if c_out_confidence_list != NULL and num_proj_objects > 0:
                out_confidence_list = [
                    c_out_confidence_list[iii] for iii in range(num_proj_objects)
                ]
        finally:
            if c_out_confidence_list != NULL:
                proj_int_list_destroy(c_out_confidence_list)
            _clear_proj_error()

        # retrieve the best matching projection
        cdef PJ* proj = NULL
        cdef const char* code
        cdef const char* out_auth_name
        authority_list = []
        try:
            for iii in range(num_proj_objects):
                if out_confidence_list[iii] < min_confidence:
                    continue
                proj = proj_list_get(self.context, proj_list, iii)
                code = proj_get_id_code(proj, 0)
                out_auth_name = proj_get_id_auth_name(proj, 0)
                if out_auth_name != NULL and code != NULL:
                    authority_list.append(
                        AuthorityMatchInfo(
                            out_auth_name,
                            code,
                            out_confidence_list[iii]
                        )
                    )
                # at this point, the auth name is copied and we can release the proj object
                proj_destroy(proj)
                proj = NULL
        finally:
            # If there was an error we have to call proj_destroy
            # If there was none, calling it on NULL does nothing
            proj_destroy(proj)
            proj_list_destroy(proj_list)
            _clear_proj_error()
        return authority_list

    def to_3d(self, str name=None):
        """
        .. versionadded:: 3.1.0

        Convert the current CRS to the 3D version if it makes sense.

        New vertical axis attributes:
          - ellipsoidal height
          - oriented upwards
          - metre units

        Parameters
        ----------
        name: str, optional
            CRS name. If None, it will use the name of the original CRS.

        Returns
        -------
        _CRS
        """
        cdef char* c_name = NULL
        cdef bytes b_name
        if name is not None:
            b_name = cstrencode(name)
            c_name = b_name

        cdef PJ * projobj = proj_crs_promote_to_3D(
            self.context, c_name, self.projobj
        )
        _clear_proj_error()
        if projobj == NULL:
            return self
        try:
            crs_3d = _CRS(_to_wkt(
                self.context,
                projobj,
                version=WktVersion.WKT2_2019,
                pretty=False,
            ))
        finally:
            proj_destroy(projobj)
        return crs_3d

    def to_2d(self, str name=None):
        """
        .. versionadded:: 3.6.0

        Convert the current CRS to the 2D version if it makes sense.

        Parameters
        ----------
        name: str, optional
            CRS name. If None, it will use the name of the original CRS.

        Returns
        -------
        _CRS
        """
        cdef char* c_name = NULL
        cdef bytes b_name
        if name is not None:
            b_name = cstrencode(name)
            c_name = b_name

        cdef PJ * projobj = proj_crs_demote_to_2D(
            self.context, c_name, self.projobj
        )
        _clear_proj_error()
        if projobj == NULL:
            return self
        try:
            crs_2d = _CRS(_to_wkt(
                self.context,
                projobj,
                version=WktVersion.WKT2_2019,
                pretty=False,
            ))
        finally:
            proj_destroy(projobj)
        return crs_2d

    def _is_crs_property(
        self, str property_name, tuple property_types, int sub_crs_index=0
    ):
        """
        .. versionadded:: 2.2.0

        This method will check for a property on the CRS.
        It will check if it has the property on the sub CRS
        if it is a compound CRS and will check if the source CRS
        has the property if it is a bound CRS.

        Parameters
        ----------
        property_name: str
            The name of the CRS property.
        property_types: tuple(PJ_TYPE)
            The types to check for for the property.
        sub_crs_index: int, default=0
            THe index of the CRS in the sub CRS list.

        Returns
        -------
        bool:
            True if the CRS has this property.
        """
        if self.sub_crs_list:
            sub_crs = self.sub_crs_list[sub_crs_index]
            if sub_crs.is_bound:
                is_property = getattr(sub_crs.source_crs, property_name)
            else:
                is_property = getattr(sub_crs, property_name)
        elif self.is_bound:
            is_property = getattr(self.source_crs, property_name)
        else:
            is_property = self._type in property_types
        return is_property

    @property
    def is_geographic(self):
        """
        This checks if the CRS is geographic.
        It will check if it has a geographic CRS
        in the sub CRS if it is a compound CRS and will check if
        the source CRS is geographic if it is a bound CRS.

        Returns
        -------
        bool:
            True if the CRS is in geographic (lon/lat) coordinates.
        """
        return self._is_crs_property(
            "is_geographic",
            (
                PJ_TYPE_GEOGRAPHIC_CRS,
                PJ_TYPE_GEOGRAPHIC_2D_CRS,
                PJ_TYPE_GEOGRAPHIC_3D_CRS
            )
        )

    @property
    def is_projected(self):
        """
        This checks if the CRS is projected.
        It will check if it has a projected CRS
        in the sub CRS if it is a compound CRS and will check if
        the source CRS is projected if it is a bound CRS.

        Returns
        -------
        bool:
            True if CRS is projected.
        """
        return self._is_crs_property(
            "is_projected",
            (PJ_TYPE_PROJECTED_CRS,)
        )

    @property
    def is_vertical(self):
        """
        .. versionadded:: 2.2.0

        This checks if the CRS is vertical.
        It will check if it has a vertical CRS
        in the sub CRS if it is a compound CRS and will check if
        the source CRS is vertical if it is a bound CRS.

        Returns
        -------
        bool:
            True if CRS is vertical.
        """
        return self._is_crs_property(
            "is_vertical",
            (PJ_TYPE_VERTICAL_CRS,),
            sub_crs_index=1
        )

    @property
    def is_bound(self):
        """
        Returns
        -------
        bool:
            True if CRS is bound.
        """
        return self._type == PJ_TYPE_BOUND_CRS

    @property
    def is_compound(self):
        """
        .. versionadded:: 3.1.0

        Returns
        -------
        bool:
            True if CRS is compound.
        """
        return self._type == PJ_TYPE_COMPOUND_CRS

    @property
    def is_engineering(self):
        """
        .. versionadded:: 2.2.0

        Returns
        -------
        bool:
            True if CRS is local/engineering.
        """
        return self._type == PJ_TYPE_ENGINEERING_CRS

    @property
    def is_geocentric(self):
        """
        This checks if the CRS is geocentric and
        takes into account if the CRS is bound.

        Returns
        -------
        bool:
            True if CRS is in geocentric (x/y) coordinates.
        """
        if self.is_bound:
            return self.source_crs.is_geocentric
        return self._type == PJ_TYPE_GEOCENTRIC_CRS

    @property
    def is_derived(self):
        """
        .. versionadded:: 3.2.0

        Returns
        -------
        bool:
            True if CRS is a Derived CRS.
        """
        return proj_is_derived_crs(self.context, self.projobj) == 1

    def _equals(self, _CRS other, bint ignore_axis_order):
        if ignore_axis_order:
            # Only to be used with DerivedCRS/ProjectedCRS/GeographicCRS
            return proj_is_equivalent_to_with_ctx(
                self.context,
                self.projobj,
                other.projobj,
                PJ_COMP_EQUIVALENT_EXCEPT_AXIS_ORDER_GEOGCRS,
            ) == 1
        return self._is_equivalent(other)

    def equals(self, other, ignore_axis_order=False):
        """
        Check if the projection objects are equivalent.

        Properties
        ----------
        other: CRS
            Check if the other object
        ignore_axis_order: bool, default=False
            If True, it will compare the CRS class and ignore the axis order.

        Returns
        -------
        bool
        """
        if not isinstance(other, _CRS):
            return False
        return self._equals(other, ignore_axis_order=ignore_axis_order)
