"""
This module interfaces with PROJ to produce a pythonic interface
to the coordinate reference system (CRS) information.
"""
import json
import re
import threading
import warnings
from typing import Any, Callable, Dict, List, Optional, Tuple, Type, Union

from pyproj._crs import (  # noqa
    _CRS,
    AreaOfUse,
    Axis,
    CoordinateOperation,
    CoordinateSystem,
    Datum,
    Ellipsoid,
    PrimeMeridian,
    _load_proj_json,
    is_proj,
    is_wkt,
)
from pyproj.crs._cf1x8 import (
    _GEOGRAPHIC_GRID_MAPPING_NAME_MAP,
    _GRID_MAPPING_NAME_MAP,
    _INVERSE_GEOGRAPHIC_GRID_MAPPING_NAME_MAP,
    _INVERSE_GRID_MAPPING_NAME_MAP,
    _horizontal_datum_from_params,
    _try_list_if_string,
)
from pyproj.crs.coordinate_operation import ToWGS84Transformation
from pyproj.crs.coordinate_system import Cartesian2DCS, Ellipsoidal2DCS, VerticalCS
from pyproj.enums import ProjVersion, WktVersion
from pyproj.exceptions import CRSError
from pyproj.geod import Geod


class CRSLocal(threading.local):
    """
    Threading local instance for cython CRS class.

    For more details, see:
    https://github.com/pyproj4/pyproj/issues/782
    """

    def __init__(self):
        self.crs = None  # Initialises in each thread


def _prepare_from_dict(projparams: dict, allow_json: bool = True) -> str:
    # check if it is a PROJ JSON dict
    if "proj" not in projparams and "init" not in projparams and allow_json:
        return json.dumps(projparams)
    # convert a dict to a proj string.
    pjargs = []
    for key, value in projparams.items():
        # the towgs84 as list
        if isinstance(value, (list, tuple)):
            value = ",".join([str(val) for val in value])
        # issue 183 (+ no_rot)
        if value is None or str(value) == "True":
            pjargs.append(f"+{key}")
        elif str(value) == "False":
            pass
        else:
            pjargs.append(f"+{key}={value}")
    return _prepare_from_string(" ".join(pjargs))


def _prepare_from_string(in_crs_string: str) -> str:
    if not in_crs_string:
        raise CRSError(f"CRS is empty or invalid: {in_crs_string!r}")
    elif "{" in in_crs_string:
        # may be json, try to decode it
        try:
            crs_dict = json.loads(in_crs_string, strict=False)
        except ValueError:
            raise CRSError("CRS appears to be JSON but is not valid")

        if not crs_dict:
            raise CRSError("CRS is empty JSON")
        return _prepare_from_dict(crs_dict)
    elif is_proj(in_crs_string):
        in_crs_string = re.sub(r"[\s+]?=[\s+]?", "=", in_crs_string.lstrip())
        # make sure the projection starts with +proj or +init
        starting_params = ("+init", "+proj", "init", "proj")
        if not in_crs_string.startswith(starting_params):
            kvpairs = []  # type: List[str]
            first_item_inserted = False
            for kvpair in in_crs_string.split():
                if not first_item_inserted and (kvpair.startswith(starting_params)):
                    kvpairs.insert(0, kvpair)
                    first_item_inserted = True
                else:
                    kvpairs.append(kvpair)
            in_crs_string = " ".join(kvpairs)

        # make sure it is the CRS type
        if "type=crs" not in in_crs_string:
            if "+" in in_crs_string:
                in_crs_string += " +type=crs"
            else:
                in_crs_string += " type=crs"

        # look for EPSG, replace with epsg (EPSG only works
        # on case-insensitive filesystems).
        in_crs_string = in_crs_string.replace("+init=EPSG", "+init=epsg").strip()
        if in_crs_string.startswith(("+init", "init")):
            warnings.warn(
                "'+init=<authority>:<code>' syntax is deprecated. "
                "'<authority>:<code>' is the preferred initialization method. "
                "When making the change, be mindful of axis order changes: "
                "https://pyproj4.github.io/pyproj/stable/gotchas.html"
                "#axis-order-changes-in-proj-6",
                FutureWarning,
                stacklevel=2,
            )
    return in_crs_string


def _prepare_from_authority(auth_name: str, auth_code: Union[str, int]):
    return f"{auth_name}:{auth_code}"


def _prepare_from_epsg(auth_code: Union[str, int]):
    return _prepare_from_authority("epsg", auth_code)


class CRS:
    """
    A pythonic Coordinate Reference System manager.

    .. versionadded:: 2.0.0

    The functionality is based on other fantastic projects:

    * `rasterio <https://github.com/mapbox/rasterio/blob/c13f0943b95c0eaa36ff3f620bd91107aa67b381/rasterio/_crs.pyx>`_  # noqa: E501
    * `opendatacube <https://github.com/opendatacube/datacube-core/blob/83bae20d2a2469a6417097168fd4ede37fd2abe5/datacube/utils/geometry/_base.py>`_  # noqa: E501

    Attributes
    ----------
    srs: str
        The string form of the user input used to create the CRS.

    """

    def __init__(self, projparams: Any = None, **kwargs) -> None:
        """
        Initialize a CRS class instance with:
          - PROJ string
          - Dictionary of PROJ parameters
          - PROJ keyword arguments for parameters
          - JSON string with PROJ parameters
          - CRS WKT string
          - An authority string [i.e. 'epsg:4326']
          - An EPSG integer code [i.e. 4326]
          - A tuple of ("auth_name": "auth_code") [i.e ('epsg', '4326')]
          - An object with a `to_wkt` method.
          - A :class:`pyproj.crs.CRS` class

        Example usage:

        >>> from pyproj import CRS
        >>> crs_utm = CRS.from_user_input(26915)
        >>> crs_utm
        <Projected CRS: EPSG:26915>
        Name: NAD83 / UTM zone 15N
        Axis Info [cartesian]:
        - E[east]: Easting (metre)
        - N[north]: Northing (metre)
        Area of Use:
        - name: North America - 96°W to 90°W and NAD83 by country
        - bounds: (-96.0, 25.61, -90.0, 84.0)
        Coordinate Operation:
        - name: UTM zone 15N
        - method: Transverse Mercator
        Datum: North American Datum 1983
        - Ellipsoid: GRS 1980
        - Prime Meridian: Greenwich
        <BLANKLINE>
        >>> crs_utm.area_of_use.bounds
        (-96.0, 25.61, -90.0, 84.0)
        >>> crs_utm.ellipsoid
        ELLIPSOID["GRS 1980",6378137,298.257222101,
            LENGTHUNIT["metre",1],
            ID["EPSG",7019]]
        >>> crs_utm.ellipsoid.inverse_flattening
        298.257222101
        >>> crs_utm.ellipsoid.semi_major_metre
        6378137.0
        >>> crs_utm.ellipsoid.semi_minor_metre
        6356752.314140356
        >>> crs_utm.prime_meridian
        PRIMEM["Greenwich",0,
            ANGLEUNIT["degree",0.0174532925199433],
            ID["EPSG",8901]]
        >>> crs_utm.prime_meridian.unit_name
        'degree'
        >>> crs_utm.prime_meridian.unit_conversion_factor
        0.017453292519943295
        >>> crs_utm.prime_meridian.longitude
        0.0
        >>> crs_utm.datum
        DATUM["North American Datum 1983",
            ELLIPSOID["GRS 1980",6378137,298.257222101,
                LENGTHUNIT["metre",1]],
            ID["EPSG",6269]]
        >>> crs_utm.coordinate_system
        CS[Cartesian,2],
            AXIS["(E)",east,
                ORDER[1],
                LENGTHUNIT["metre",1,
                    ID["EPSG",9001]]],
            AXIS["(N)",north,
                ORDER[2],
                LENGTHUNIT["metre",1,
                    ID["EPSG",9001]]]
        >>> print(crs_utm.coordinate_operation.to_wkt(pretty=True))
        CONVERSION["UTM zone 15N",
            METHOD["Transverse Mercator",
                ID["EPSG",9807]],
            PARAMETER["Latitude of natural origin",0,
                ANGLEUNIT["degree",0.0174532925199433],
                ID["EPSG",8801]],
            PARAMETER["Longitude of natural origin",-93,
                ANGLEUNIT["degree",0.0174532925199433],
                ID["EPSG",8802]],
            PARAMETER["Scale factor at natural origin",0.9996,
                SCALEUNIT["unity",1],
                ID["EPSG",8805]],
            PARAMETER["False easting",500000,
                LENGTHUNIT["metre",1],
                ID["EPSG",8806]],
            PARAMETER["False northing",0,
                LENGTHUNIT["metre",1],
                ID["EPSG",8807]],
            ID["EPSG",16015]]
        >>> crs = CRS(proj='utm', zone=10, ellps='WGS84')
        >>> print(crs.to_wkt(pretty=True))
        PROJCRS["unknown",
            BASEGEOGCRS["unknown",
                DATUM["Unknown based on WGS84 ellipsoid",
                    ELLIPSOID["WGS 84",6378137,298.257223563,
                        LENGTHUNIT["metre",1],
                        ID["EPSG",7030]]],
                PRIMEM["Greenwich",0,
                    ANGLEUNIT["degree",0.0174532925199433],
                    ID["EPSG",8901]]],
            CONVERSION["UTM zone 10N",
                METHOD["Transverse Mercator",
                    ID["EPSG",9807]],
                PARAMETER["Latitude of natural origin",0,
                    ANGLEUNIT["degree",0.0174532925199433],
                    ID["EPSG",8801]],
                PARAMETER["Longitude of natural origin",-123,
                    ANGLEUNIT["degree",0.0174532925199433],
                    ID["EPSG",8802]],
                PARAMETER["Scale factor at natural origin",0.9996,
                    SCALEUNIT["unity",1],
                    ID["EPSG",8805]],
                PARAMETER["False easting",500000,
                    LENGTHUNIT["metre",1],
                    ID["EPSG",8806]],
                PARAMETER["False northing",0,
                    LENGTHUNIT["metre",1],
                    ID["EPSG",8807]],
                ID["EPSG",16010]],
            CS[Cartesian,2],
                AXIS["(E)",east,
                    ORDER[1],
                    LENGTHUNIT["metre",1,
                        ID["EPSG",9001]]],
                AXIS["(N)",north,
                    ORDER[2],
                    LENGTHUNIT["metre",1,
                        ID["EPSG",9001]]]]
        >>> geod = crs.get_geod()
        >>> f"+a={geod.a:.0f} +f={geod.f:.8f}"
        '+a=6378137 +f=0.00335281'
        >>> crs.is_projected
        True
        >>> crs.is_geographic
        False
        """
        projstring = ""

        if projparams:
            if isinstance(projparams, _CRS):
                projstring = projparams.srs
            elif isinstance(projparams, str):
                projstring = _prepare_from_string(projparams)
            elif isinstance(projparams, dict):
                projstring = _prepare_from_dict(projparams)
            elif isinstance(projparams, int):
                projstring = _prepare_from_epsg(projparams)
            elif isinstance(projparams, (list, tuple)) and len(projparams) == 2:
                projstring = _prepare_from_authority(*projparams)
            elif hasattr(projparams, "to_wkt"):
                projstring = projparams.to_wkt()  # type: ignore
            else:
                raise CRSError(f"Invalid CRS input: {projparams!r}")

        if kwargs:
            projkwargs = _prepare_from_dict(kwargs, allow_json=False)
            projstring = _prepare_from_string(" ".join((projstring, projkwargs)))

        self.srs = projstring
        self._local = CRSLocal()
        if isinstance(projparams, _CRS):
            self._local.crs = projparams
        else:
            self._local.crs = _CRS(self.srs)

    @property
    def _crs(self):
        """
        Retrieve the Cython based _CRS object for this thread.
        """
        if self._local.crs is None:
            self._local.crs = _CRS(self.srs)
        return self._local.crs

    @staticmethod
    def from_authority(auth_name: str, code: Union[str, int]) -> "CRS":
        """
        .. versionadded:: 2.2.0

        Make a CRS from an authority name and authority code

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

        Returns
        -------
        CRS
        """
        return CRS(_prepare_from_authority(auth_name, code))

    @staticmethod
    def from_epsg(code: Union[str, int]) -> "CRS":
        """Make a CRS from an EPSG code

        Parameters
        ----------
        code : int or str
            An EPSG code.

        Returns
        -------
        CRS
        """
        return CRS(_prepare_from_epsg(code))

    @staticmethod
    def from_proj4(in_proj_string: str) -> "CRS":
        """
        .. versionadded:: 2.2.0

        Make a CRS from a PROJ string

        Parameters
        ----------
        in_proj_string : str
            A PROJ string.

        Returns
        -------
        CRS
        """
        if not is_proj(in_proj_string):
            raise CRSError(f"Invalid PROJ string: {in_proj_string}")
        return CRS(_prepare_from_string(in_proj_string))

    @staticmethod
    def from_wkt(in_wkt_string: str) -> "CRS":
        """
        .. versionadded:: 2.2.0

        Make a CRS from a WKT string

        Parameters
        ----------
        in_wkt_string : str
            A WKT string.

        Returns
        -------
        CRS
        """
        if not is_wkt(in_wkt_string):
            raise CRSError(f"Invalid WKT string: {in_wkt_string}")
        return CRS(_prepare_from_string(in_wkt_string))

    @staticmethod
    def from_string(in_crs_string: str) -> "CRS":
        """
        Make a CRS from:

        Initialize a CRS class instance with:
         - PROJ string
         - JSON string with PROJ parameters
         - CRS WKT string
         - An authority string [i.e. 'epsg:4326']

        Parameters
        ----------
        in_crs_string : str
            An EPSG, PROJ, or WKT string.

        Returns
        -------
        CRS
        """
        return CRS(_prepare_from_string(in_crs_string))

    def to_string(self) -> str:
        """
        .. versionadded:: 2.2.0

        Convert the CRS to a string.

        It attempts to convert it to the authority string.
        Otherwise, it uses the string format of the user
        input to create the CRS.

        Returns
        -------
        str
        """
        auth_info = self.to_authority(min_confidence=100)
        if auth_info:
            return ":".join(auth_info)
        return self.srs

    @staticmethod
    def from_user_input(value: Any, **kwargs) -> "CRS":
        """
        Initialize a CRS class instance with:
          - PROJ string
          - Dictionary of PROJ parameters
          - PROJ keyword arguments for parameters
          - JSON string with PROJ parameters
          - CRS WKT string
          - An authority string [i.e. 'epsg:4326']
          - An EPSG integer code [i.e. 4326]
          - A tuple of ("auth_name": "auth_code") [i.e ('epsg', '4326')]
          - An object with a `to_wkt` method.
          - A :class:`pyproj.crs.CRS` class

        Parameters
        ----------
        value : obj
            A Python int, dict, or str.

        Returns
        -------
        CRS
        """
        if isinstance(value, CRS):
            return value
        return CRS(value, **kwargs)

    def get_geod(self) -> Optional[Geod]:
        """
        Returns
        -------
        pyproj.geod.Geod:
            Geod object based on the ellipsoid.
        """
        if self.ellipsoid is None:
            return None
        return Geod(
            a=self.ellipsoid.semi_major_metre,
            rf=self.ellipsoid.inverse_flattening,
            b=self.ellipsoid.semi_minor_metre,
        )

    @staticmethod
    def from_dict(proj_dict: dict) -> "CRS":
        """
        .. versionadded:: 2.2.0

        Make a CRS from a dictionary of PROJ parameters.

        Parameters
        ----------
        proj_dict : str
            PROJ params in dict format.

        Returns
        -------
        CRS
        """
        return CRS(_prepare_from_dict(proj_dict))

    @staticmethod
    def from_json(crs_json: str) -> "CRS":
        """
        .. versionadded:: 2.4.0

        Create CRS from a CRS JSON string.

        Parameters
        ----------
        crs_json: str
            CRS JSON string.

        Returns
        -------
        CRS
        """
        return CRS.from_json_dict(_load_proj_json(crs_json))

    @staticmethod
    def from_json_dict(crs_dict: dict) -> "CRS":
        """
        .. versionadded:: 2.4.0

        Create CRS from a JSON dictionary.

        Parameters
        ----------
        crs_dict: dict
            CRS dictionary.

        Returns
        -------
        CRS
        """
        return CRS(json.dumps(crs_dict))

    def to_dict(self) -> dict:
        """
        .. versionadded:: 2.2.0

        Converts the CRS to dictionary of PROJ parameters.

        .. 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

        Returns
        -------
        dict:
            PROJ params in dict format.

        """

        def parse(val):
            if val.lower() == "true":
                return True
            elif val.lower() == "false":
                return False
            try:
                return int(val)
            except ValueError:
                pass
            try:
                return float(val)
            except ValueError:
                pass
            return _try_list_if_string(val)

        proj_string = self.to_proj4()
        if proj_string is None:
            return {}

        items = map(
            lambda kv: len(kv) == 2 and (kv[0], parse(kv[1])) or (kv[0], None),
            (part.lstrip("+").split("=", 1) for part in proj_string.strip().split()),
        )

        return {key: value for key, value in items if value is not False}

    def to_cf(
        self,
        wkt_version: Union[WktVersion, str] = WktVersion.WKT2_2019,
        errcheck: bool = False,
    ) -> dict:
        """
        .. versionadded:: 2.2.0

        This converts a :obj:`pyproj.crs.CRS` object
        to a Climate and Forecast (CF) Grid Mapping Version 1.8 dict.

        :ref:`build_crs_cf`

        Parameters
        ----------
        wkt_version: str or pyproj.enums.WktVersion
            Version of WKT supported by CRS.to_wkt.
            Default is :attr:`pyproj.enums.WktVersion.WKT2_2019`.
        errcheck: bool, optional
            If True, will warn when parameters are ignored. Defaults to False.

        Returns
        -------
        dict:
            CF-1.8 version of the projection.

        """
        cf_dict = {"crs_wkt": self.to_wkt(wkt_version)}  # type: Dict[str, Any]

        # handle bound CRS
        if (
            self.is_bound
            and self.coordinate_operation
            and self.coordinate_operation.towgs84
        ):
            sub_cf = self.source_crs.to_cf(errcheck=errcheck)  # type: ignore
            sub_cf.pop("crs_wkt")
            cf_dict.update(sub_cf)
            cf_dict["towgs84"] = self.coordinate_operation.towgs84
            return cf_dict

        # handle compound CRS
        elif self.sub_crs_list:
            for sub_crs in self.sub_crs_list:
                sub_cf = sub_crs.to_cf(errcheck=errcheck)
                sub_cf.pop("crs_wkt")
                cf_dict.update(sub_cf)
            return cf_dict

        # handle vertical CRS
        elif self.is_vertical:
            vert_json = self.to_json_dict()
            if "geoid_model" in vert_json:
                cf_dict["geoid_name"] = vert_json["geoid_model"]["name"]
            if self.datum:
                cf_dict["geopotential_datum_name"] = self.datum.name
            return cf_dict

        # write out datum parameters
        if self.ellipsoid:
            cf_dict.update(
                semi_major_axis=self.ellipsoid.semi_major_metre,
                semi_minor_axis=self.ellipsoid.semi_minor_metre,
                inverse_flattening=self.ellipsoid.inverse_flattening,
            )
            cf_dict["reference_ellipsoid_name"] = self.ellipsoid.name
        if self.prime_meridian:
            cf_dict["longitude_of_prime_meridian"] = self.prime_meridian.longitude
            cf_dict["prime_meridian_name"] = self.prime_meridian.name

        # handle geographic CRS
        if self.geodetic_crs:
            cf_dict["geographic_crs_name"] = self.geodetic_crs.name

        if self.is_geographic:
            if self.coordinate_operation:
                cf_dict.update(
                    _INVERSE_GEOGRAPHIC_GRID_MAPPING_NAME_MAP[
                        self.coordinate_operation.method_name.lower()
                    ](self.coordinate_operation)
                )
                if self.datum:
                    cf_dict["horizontal_datum_name"] = self.datum.name
            else:
                cf_dict["grid_mapping_name"] = "latitude_longitude"
            return cf_dict

        # handle projected CRS
        if self.is_projected and self.datum:
            cf_dict["horizontal_datum_name"] = self.datum.name
        coordinate_operation = None
        if not self.is_bound and self.is_projected:
            coordinate_operation = self.coordinate_operation
            cf_dict["projected_crs_name"] = self.name
        coordinate_operation_name = (
            None
            if not coordinate_operation
            else coordinate_operation.method_name.lower().replace(" ", "_")
        )
        if coordinate_operation_name not in _INVERSE_GRID_MAPPING_NAME_MAP:
            if errcheck:
                if coordinate_operation:
                    warnings.warn(
                        "Unsupported coordinate operation: "
                        f"{coordinate_operation.method_name}"
                    )
                else:
                    warnings.warn("Coordinate operation not found.")

            return {"crs_wkt": self.to_wkt(wkt_version)}

        cf_dict.update(
            _INVERSE_GRID_MAPPING_NAME_MAP[coordinate_operation_name](
                coordinate_operation
            )
        )
        return cf_dict

    @staticmethod
    def from_cf(
        in_cf: dict,
        ellipsoidal_cs: Any = None,
        cartesian_cs: Any = None,
        vertical_cs: Any = None,
        errcheck=False,
    ) -> "CRS":
        """
        .. versionadded:: 2.2.0

        .. versionadded:: 3.0.0 ellipsoidal_cs, cartesian_cs, vertical_cs

        This converts a Climate and Forecast (CF) Grid Mapping Version 1.8
        dict to a :obj:`pyproj.crs.CRS` object.

        :ref:`build_crs_cf`

        Parameters
        ----------
        in_cf: dict
            CF version of the projection.
        ellipsoidal_cs: Any, optional
            Input to create an Ellipsoidal Coordinate System.
            Anything accepted by :meth:`pyproj.crs.CoordinateSystem.from_user_input`
            or an Ellipsoidal Coordinate System created from :ref:`coordinate_system`.
        cartesian_cs: Any, optional
            Input to create a Cartesian Coordinate System.
            Anything accepted by :meth:`pyproj.crs.CoordinateSystem.from_user_input`
            or :class:`pyproj.crs.coordinate_system.Cartesian2DCS`.
        vertical_cs: Any, optional
            Input to create a Vertical Coordinate System accepted by
            :meth:`pyproj.crs.CoordinateSystem.from_user_input`
            or :class:`pyproj.crs.coordinate_system.VerticalCS`
        errcheck: bool, optional
            This parameter is for backwards compatibility with the old version.
            It currently does nothing when True or False.

        Returns
        -------
        CRS
        """
        unknown_names = ("unknown", "undefined")
        if "crs_wkt" in in_cf:
            return CRS(in_cf["crs_wkt"])
        elif "spatial_ref" in in_cf:  # for previous supported WKT key
            return CRS(in_cf["spatial_ref"])

        grid_mapping_name = in_cf.get("grid_mapping_name")
        if grid_mapping_name is None:
            raise CRSError("CF projection parameters missing 'grid_mapping_name'")

        # build datum if possible
        datum = _horizontal_datum_from_params(in_cf)

        # build geographic CRS
        try:
            geographic_conversion_method = _GEOGRAPHIC_GRID_MAPPING_NAME_MAP[
                grid_mapping_name
            ]  # type: Optional[Callable]
        except KeyError:
            geographic_conversion_method = None

        geographic_crs_name = in_cf.get("geographic_crs_name")
        if datum:
            geographic_crs = GeographicCRS(
                name=geographic_crs_name or "undefined",
                datum=datum,
                ellipsoidal_cs=ellipsoidal_cs,
            )  # type: CRS
        elif geographic_crs_name and geographic_crs_name not in unknown_names:
            geographic_crs = CRS(geographic_crs_name)
            if ellipsoidal_cs is not None:
                geographic_crs_json = geographic_crs.to_json_dict()
                geographic_crs_json[
                    "coordinate_system"
                ] = CoordinateSystem.from_user_input(ellipsoidal_cs).to_json_dict()
                geographic_crs = CRS(geographic_crs_json)
        else:
            geographic_crs = GeographicCRS(ellipsoidal_cs=ellipsoidal_cs)
        if grid_mapping_name == "latitude_longitude":
            return geographic_crs
        if geographic_conversion_method is not None:
            return DerivedGeographicCRS(
                base_crs=geographic_crs,
                conversion=geographic_conversion_method(in_cf),
                ellipsoidal_cs=ellipsoidal_cs,
            )

        # build projected CRS
        try:
            conversion_method = _GRID_MAPPING_NAME_MAP[grid_mapping_name]
        except KeyError:
            raise CRSError(f"Unsupported grid mapping name: {grid_mapping_name}")
        projected_crs = ProjectedCRS(
            name=in_cf.get("projected_crs_name", "undefined"),
            conversion=conversion_method(in_cf),
            geodetic_crs=geographic_crs,
            cartesian_cs=cartesian_cs,
        )

        # build bound CRS if exists
        bound_crs = None
        if "towgs84" in in_cf:
            bound_crs = BoundCRS(
                source_crs=projected_crs,
                target_crs="WGS 84",
                transformation=ToWGS84Transformation(
                    projected_crs.geodetic_crs, *_try_list_if_string(in_cf["towgs84"])
                ),
            )
        if "geopotential_datum_name" not in in_cf:
            return bound_crs or projected_crs

        # build Vertical CRS
        vertical_crs = VerticalCRS(
            name="undefined",
            datum=in_cf["geopotential_datum_name"],
            geoid_model=in_cf.get("geoid_name"),
            vertical_cs=vertical_cs,
        )

        # build compound CRS
        return CompoundCRS(
            name="undefined", components=[bound_crs or projected_crs, vertical_crs]
        )

    def cs_to_cf(self) -> List[dict]:
        """
        .. versionadded:: 3.0.0

        This converts all coordinate systems (cs) in the CRS
        to a list of Climate and Forecast (CF) Version 1.8 dicts.

        :ref:`build_crs_cf`

        Returns
        -------
        List[dict]:
            CF-1.8 version of the coordinate systems.
        """
        cf_axis_list = []

        def rotated_pole(crs):
            try:
                return (
                    crs.coordinate_operation
                    and crs.coordinate_operation.method_name.lower()
                    in _INVERSE_GEOGRAPHIC_GRID_MAPPING_NAME_MAP
                )
            except KeyError:
                return False

        if self.type_name == "Temporal CRS" and self.datum:
            datum_json = self.datum.to_json_dict()
            origin = datum_json.get("time_origin", "1875-05-20").strip().rstrip("zZ")
            if len(origin) == 4:
                origin = f"{origin}-01-01"
            axis = self.axis_info[0]
            cf_temporal_axis = {
                "standard_name": "time",
                "long_name": "time",
                "calendar": (
                    datum_json.get("calendar", "proleptic_gregorian")
                    .lower()
                    .replace(" ", "_")
                ),
                "axis": "T",
            }
            unit_name = axis.unit_name.lower().replace("calendar", "").strip()
            # no units for TemporalDateTime
            if unit_name:
                cf_temporal_axis["units"] = f"{unit_name} since {origin}"
            cf_axis_list.append(cf_temporal_axis)
        if self.coordinate_system:
            cf_axis_list.extend(
                self.coordinate_system.to_cf(rotated_pole=rotated_pole(self))
            )
        elif self.is_bound and self.source_crs and self.source_crs.coordinate_system:
            cf_axis_list.extend(
                self.source_crs.coordinate_system.to_cf(
                    rotated_pole=rotated_pole(self.source_crs)
                )
            )
        else:
            for sub_crs in self.sub_crs_list:
                cf_axis_list.extend(sub_crs.cs_to_cf())
        return cf_axis_list

    def is_exact_same(self, other: Any, ignore_axis_order: bool = False) -> bool:
        """
        Check if the CRS objects are the exact same.

        Parameters
        ----------
        other: Any
            Check if the other CRS is the exact same to this object.
            If the other object is not a CRS, it will try to create one.
            On Failure, it will return False.

        Returns
        -------
        bool
        """
        try:
            other = CRS.from_user_input(other)
        except CRSError:
            return False
        return self._crs.is_exact_same(other._crs)

    def equals(self, other: Any, ignore_axis_order: bool = False) -> bool:
        """

        .. versionadded:: 2.5.0

        Check if the CRS objects are equivalent.

        Parameters
        ----------
        other: Any
            Check if the other object is equivalent to this object.
            If the other object is not a CRS, it will try to create one.
            On Failure, it will return False.
        ignore_axis_order: bool, optional
            If True, it will compare the CRS class and ignore the axis order.
            Default is False.

        Returns
        -------
        bool
        """
        try:
            other = CRS.from_user_input(other)
        except CRSError:
            return False
        return self._crs.equals(other._crs, ignore_axis_order=ignore_axis_order)

    @property
    def geodetic_crs(self) -> Optional["CRS"]:
        """
        .. versionadded:: 2.2.0

        Returns
        -------
        CRS:
            The the geodeticCRS / geographicCRS from the CRS.

        """
        return None if self._crs.geodetic_crs is None else CRS(self._crs.geodetic_crs)

    @property
    def source_crs(self) -> Optional["CRS"]:
        """
        The the base CRS of a BoundCRS or a DerivedCRS/ProjectedCRS,
        or the source CRS of a CoordinateOperation.

        Returns
        -------
        CRS
        """
        return None if self._crs.source_crs is None else CRS(self._crs.source_crs)

    @property
    def target_crs(self) -> Optional["CRS"]:
        """
        .. versionadded:: 2.2.0

        Returns
        -------
        CRS:
            The hub CRS of a BoundCRS or the target CRS of a CoordinateOperation.

        """
        return None if self._crs.target_crs is None else CRS(self._crs.target_crs)

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

        Returns
        -------
        List[CRS]
        """
        return [CRS(sub_crs) for sub_crs in self._crs.sub_crs_list]

    @property
    def utm_zone(self) -> Optional[str]:
        """
        .. versionadded:: 2.6.0

        Finds the UTM zone in a Projected CRS, Bound CRS, or Compound CRS

        Returns
        -------
        Optional[str]:
            The UTM zone number and letter if applicable.
        """
        if self.is_bound and self.source_crs:
            return self.source_crs.utm_zone
        elif self.sub_crs_list:
            for sub_crs in self.sub_crs_list:
                if sub_crs.utm_zone:
                    return sub_crs.utm_zone
        elif (
            self.coordinate_operation
            and "UTM ZONE" in self.coordinate_operation.name.upper()
        ):
            return self.coordinate_operation.name.upper().split("UTM ZONE ")[-1]
        return None

    @property
    def name(self) -> str:
        """
        Returns
        -------
        str:
            The name of the CRS (from `proj_get_name <https://proj.org/
            development/reference/functions.html#_CPPv313proj_get_namePK2PJ>`_).
        """
        return self._crs.name

    @property
    def type_name(self) -> str:
        """
        Returns
        -------
        str:
            The name of the type of the CRS object.
        """
        return self._crs.type_name

    @property
    def axis_info(self) -> List[Axis]:
        """
        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.
        """
        return self._crs.axis_info

    @property
    def area_of_use(self) -> Optional[AreaOfUse]:
        """
        Returns
        -------
        AreaOfUse:
            The area of use object with associated attributes.
        """
        return self._crs.area_of_use

    @property
    def ellipsoid(self) -> Optional[Ellipsoid]:
        """
        .. versionadded:: 2.2.0

        Returns
        -------
        Ellipsoid:
            The ellipsoid object with associated attributes.
        """
        return self._crs.ellipsoid

    @property
    def prime_meridian(self) -> Optional[PrimeMeridian]:
        """
        .. versionadded:: 2.2.0

        Returns
        -------
        PrimeMeridian:
            The prime meridian object with associated attributes.
        """
        return self._crs.prime_meridian

    @property
    def datum(self) -> Optional[Datum]:
        """
        .. versionadded:: 2.2.0

        Returns
        -------
        Datum
        """
        return self._crs.datum

    @property
    def coordinate_system(self) -> Optional[CoordinateSystem]:
        """
        .. versionadded:: 2.2.0

        Returns
        -------
        CoordinateSystem
        """
        return self._crs.coordinate_system

    @property
    def coordinate_operation(self) -> Optional[CoordinateOperation]:
        """
        .. versionadded:: 2.2.0

        Returns
        -------
        CoordinateOperation
        """
        return self._crs.coordinate_operation

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

        Returns
        -------
        str:
            Remarks about object.
        """
        return self._crs.remarks

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

        Returns
        -------
        str:
            Scope of object.
        """
        return self._crs.scope

    def to_wkt(
        self,
        version: Union[WktVersion, str] = WktVersion.WKT2_2019,
        pretty: bool = False,
    ) -> str:
        """
        Convert the projection to a WKT string.

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


        Parameters
        ----------
        version: pyproj.enums.WktVersion
            The version of the WKT output.
            Default is :attr:`pyproj.enums.WktVersion.WKT2_2019`.
        pretty: bool
            If True, it will set the output to be a multiline string. Defaults to False.

        Returns
        -------
        str
        """
        return self._crs.to_wkt(version=version, pretty=pretty)

    def to_json(self, pretty: bool = False, indentation: int = 2) -> str:
        """
        .. versionadded:: 2.4.0

        Convert the object to a JSON string.

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

        Returns
        -------
        str
        """
        return self._crs.to_json(pretty=pretty, indentation=indentation)

    def to_json_dict(self) -> dict:
        """
        .. versionadded:: 2.4.0

        Convert the object to a JSON dictionary.

        Returns
        -------
        dict
        """
        return self._crs.to_json_dict()

    def to_proj4(self, version: Union[ProjVersion, int] = ProjVersion.PROJ_5) -> str:
        """
        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
            The version of the PROJ string output.
            Default is :attr:`pyproj.enums.ProjVersion.PROJ_4`.

        Returns
        -------
        str
        """
        return self._crs.to_proj4(version=version)

    def to_epsg(self, min_confidence: int = 70) -> Optional[int]:
        """
        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, optional
            A value between 0-100 where 100 is the most confident. Default is 70.
            :ref:`min_confidence`


        Returns
        -------
        Optional[int]:
            The best matching EPSG code matching the confidence level.
        """
        return self._crs.to_epsg(min_confidence=min_confidence)

    def to_authority(self, auth_name: Optional[str] = None, min_confidence: int = 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, optional
            A value between 0-100 where 100 is the most confident. Default is 70.
            :ref:`min_confidence`

        Returns
        -------
        tuple(str, str) or None:
            The best matching (<auth_name>, <code>) for the confidence level.
        """
        return self._crs.to_authority(
            auth_name=auth_name, min_confidence=min_confidence
        )

    def to_3d(self, name: Optional[str] = None) -> "CRS":
        """
        .. 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. Defaults to use the name of the original CRS.

        Returns
        -------
        CRS
        """
        return CRS(self._crs.to_3d(name=name))

    @property
    def is_geographic(self) -> bool:
        """
        This checks if the CRS is geographic.
        It will check if it has a geographic CRS
        in the sub CRS if it is a compount 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._crs.is_geographic

    @property
    def is_projected(self) -> bool:
        """
        This checks if the CRS is projected.
        It will check if it has a projected CRS
        in the sub CRS if it is a compount 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._crs.is_projected

    @property
    def is_vertical(self) -> bool:
        """
        .. 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 compount 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._crs.is_vertical

    @property
    def is_bound(self) -> bool:
        """
        Returns
        -------
        bool:
            True if CRS is bound.
        """
        return self._crs.is_bound

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

        Returns
        -------
        bool:
            True if CRS is compound.
        """
        return self._crs.is_compound

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

        Returns
        -------
        bool:
            True if CRS is local/engineering.
        """
        return self._crs.is_engineering

    @property
    def is_geocentric(self) -> bool:
        """
        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.
        """
        return self._crs.is_geocentric

    def __eq__(self, other: Any) -> bool:
        return self.equals(other)

    def __reduce__(self) -> Tuple[Type["CRS"], Tuple[str]]:
        """special method that allows CRS instance to be pickled"""
        return self.__class__, (self.srs,)

    def __hash__(self) -> int:
        return hash(self.to_wkt())

    def __str__(self) -> str:
        return self.srs

    def __repr__(self) -> str:
        # get axis information
        axis_info_list = []  # type: List[str]
        for axis in self.axis_info:
            axis_info_list.extend(["- ", str(axis), "\n"])
        axis_info_str = "".join(axis_info_list)

        # get coordinate system & sub CRS info
        source_crs_repr = ""
        sub_crs_repr = ""
        if self.coordinate_system and self.coordinate_system.axis_list:
            coordinate_system_name = str(self.coordinate_system)
        elif self.is_bound and self.source_crs:
            coordinate_system_name = str(self.source_crs.coordinate_system)
            source_crs_repr = f"Source CRS: {self.source_crs.name}\n"
        else:
            coordinate_system_names = []
            sub_crs_repr_list = ["Sub CRS:\n"]
            for sub_crs in self.sub_crs_list:
                coordinate_system_names.append(str(sub_crs.coordinate_system))
                sub_crs_repr_list.extend(["- ", sub_crs.name, "\n"])
            coordinate_system_name = "|".join(coordinate_system_names)
            sub_crs_repr = "".join(sub_crs_repr_list)

        # get coordinate operation repr
        coordinate_operation = ""
        if self.coordinate_operation:
            coordinate_operation = "".join(
                [
                    "Coordinate Operation:\n",
                    "- name: ",
                    str(self.coordinate_operation),
                    "\n" "- method: ",
                    str(self.coordinate_operation.method_name),
                    "\n",
                ]
            )

        # get SRS representation
        srs_repr = self.to_string()
        srs_repr = srs_repr if len(srs_repr) <= 50 else " ".join([srs_repr[:50], "..."])
        axis_info_str = axis_info_str or "- undefined\n"
        return (
            f"<{self.type_name}: {srs_repr}>\n"
            f"Name: {self.name}\n"
            f"Axis Info [{coordinate_system_name or 'undefined'}]:\n"
            f"{axis_info_str}"
            "Area of Use:\n"
            f"{self.area_of_use or '- undefined'}\n"
            f"{coordinate_operation}"
            f"Datum: {self.datum}\n"
            f"- Ellipsoid: {self.ellipsoid or 'undefined'}\n"
            f"- Prime Meridian: {self.prime_meridian or 'undefined'}\n"
            f"{source_crs_repr}"
            f"{sub_crs_repr}"
        )


class GeographicCRS(CRS):
    """
    .. versionadded:: 2.5.0

    This class is for building a Geographic CRS
    """

    def __init__(
        self,
        name: str = "undefined",
        datum: Any = "urn:ogc:def:datum:EPSG::6326",
        ellipsoidal_cs: Any = None,
    ) -> None:
        """
        Parameters
        ----------
        name: str, optional
            Name of the CRS. Default is undefined.
        datum: Any, optional
            Anything accepted by :meth:`pyproj.crs.Datum.from_user_input` or
            a :class:`pyproj.crs.datum.CustomDatum`.
        ellipsoidal_cs: Any, optional
            Input to create an Ellipsoidal Coordinate System.
            Anything accepted by :meth:`pyproj.crs.CoordinateSystem.from_user_input`
            or an Ellipsoidal Coordinate System created from :ref:`coordinate_system`.
        """
        geographic_crs_json = {
            "$schema": "https://proj.org/schemas/v0.2/projjson.schema.json",
            "type": "GeographicCRS",
            "name": name,
            "datum": Datum.from_user_input(datum).to_json_dict(),
            "coordinate_system": CoordinateSystem.from_user_input(
                ellipsoidal_cs or Ellipsoidal2DCS()
            ).to_json_dict(),
        }
        super().__init__(geographic_crs_json)


class DerivedGeographicCRS(CRS):
    """
    .. versionadded:: 2.5.0

    This class is for building a Derived Geographic CRS
    """

    def __init__(
        self,
        base_crs: Any,
        conversion: Any,
        ellipsoidal_cs: Any = None,
        name: str = "undefined",
    ) -> None:
        """
        Parameters
        ----------
        base_crs: Any
            Input to create the Geodetic CRS, a :class:`GeographicCRS` or
            anything accepted by :meth:`pyproj.crs.CRS.from_user_input`.
        conversion: Any
            Anything accepted by :meth:`pyproj.crs.CoordinateSystem.from_user_input`
            or a conversion from :ref:`coordinate_operation`.
        ellipsoidal_cs: Any, optional
            Input to create an Ellipsoidal Coordinate System.
            Anything accepted by :meth:`pyproj.crs.CoordinateSystem.from_user_input`
            or an Ellipsoidal Coordinate System created from :ref:`coordinate_system`.
        name: str, optional
            Name of the CRS. Default is undefined.
        """
        derived_geographic_crs_json = {
            "$schema": "https://proj.org/schemas/v0.2/projjson.schema.json",
            "type": "DerivedGeographicCRS",
            "name": name,
            "base_crs": CRS.from_user_input(base_crs).to_json_dict(),
            "conversion": CoordinateOperation.from_user_input(
                conversion
            ).to_json_dict(),
            "coordinate_system": CoordinateSystem.from_user_input(
                ellipsoidal_cs or Ellipsoidal2DCS()
            ).to_json_dict(),
        }
        super().__init__(derived_geographic_crs_json)


class ProjectedCRS(CRS):
    """
    .. versionadded:: 2.5.0

    This class is for building a Projected CRS.
    """

    def __init__(
        self,
        conversion: Any,
        name: str = "undefined",
        cartesian_cs: Any = None,
        geodetic_crs: Any = None,
    ) -> None:
        """
        Parameters
        ----------
        conversion: Any
            Anything accepted by :meth:`pyproj.crs.CoordinateSystem.from_user_input`
            or a conversion from :ref:`coordinate_operation`.
        name: str, optional
            The name of the Projected CRS. Default is undefined.
        cartesian_cs: Any, optional
            Input to create a Cartesian Coordinate System.
            Anything accepted by :meth:`pyproj.crs.CoordinateSystem.from_user_input`
            or :class:`pyproj.crs.coordinate_system.Cartesian2DCS`.
        geodetic_crs: Any, optional
            Input to create the Geodetic CRS, a :class:`GeographicCRS` or
            anything accepted by :meth:`pyproj.crs.CRS.from_user_input`.
        """
        proj_crs_json = {
            "$schema": "https://proj.org/schemas/v0.2/projjson.schema.json",
            "type": "ProjectedCRS",
            "name": name,
            "base_crs": CRS.from_user_input(
                geodetic_crs or GeographicCRS()
            ).to_json_dict(),
            "conversion": CoordinateOperation.from_user_input(
                conversion
            ).to_json_dict(),
            "coordinate_system": CoordinateSystem.from_user_input(
                cartesian_cs or Cartesian2DCS()
            ).to_json_dict(),
        }
        super().__init__(proj_crs_json)


class VerticalCRS(CRS):
    """
    .. versionadded:: 2.5.0

    This class is for building a Vetical CRS.

    .. warning:: geoid_model support only exists in PROJ >= 6.3.0

    """

    def __init__(
        self,
        name: str,
        datum: Any,
        vertical_cs: Any = None,
        geoid_model: Optional[str] = None,
    ) -> None:
        """
        Parameters
        ----------
        name: str
            The name of the Vertical CRS (e.g. NAVD88 height).
        datum: Any
            Anything accepted by :meth:`pyproj.crs.Datum.from_user_input`
        vertical_cs: Any, optional
            Input to create a Vertical Coordinate System accepted by
            :meth:`pyproj.crs.CoordinateSystem.from_user_input`
            or :class:`pyproj.crs.coordinate_system.VerticalCS`
        geoid_model: str, optional
            The name of the GEOID Model (e.g. GEOID12B).
        """
        vert_crs_json = {
            "$schema": "https://proj.org/schemas/v0.2/projjson.schema.json",
            "type": "VerticalCRS",
            "name": name,
            "datum": Datum.from_user_input(datum).to_json_dict(),
            "coordinate_system": CoordinateSystem.from_user_input(
                vertical_cs or VerticalCS()
            ).to_json_dict(),
        }
        if geoid_model is not None:
            vert_crs_json["geoid_model"] = {"name": geoid_model}

        super().__init__(vert_crs_json)


class CompoundCRS(CRS):
    """
    .. versionadded:: 2.5.0

    This class is for building a Compound CRS.
    """

    def __init__(self, name: str, components: List[Any]) -> None:
        """
        Parameters
        ----------
        name: str
            The name of the Compound CRS.
        components: List[Any], optional
            List of CRS to create a Compound Coordinate System.
            List of anything accepted by :meth:`pyproj.crs.CRS.from_user_input`
        """
        compound_crs_json = {
            "$schema": "https://proj.org/schemas/v0.2/projjson.schema.json",
            "type": "CompoundCRS",
            "name": name,
            "components": [
                CRS.from_user_input(component).to_json_dict()
                for component in components
            ],
        }

        super().__init__(compound_crs_json)


class BoundCRS(CRS):
    """
    .. versionadded:: 2.5.0

    This class is for building a Bound CRS.
    """

    def __init__(self, source_crs: Any, target_crs: Any, transformation: Any) -> None:
        """
        Parameters
        ----------
        source_crs: Any
            Input to create a source CRS.
        target_crs: Any
            Input to create the target CRS.
        transformation: Any
            Input to create the transformation.
        """
        bound_crs_json = {
            "$schema": "https://proj.org/schemas/v0.2/projjson.schema.json",
            "type": "BoundCRS",
            "source_crs": CRS.from_user_input(source_crs).to_json_dict(),
            "target_crs": CRS.from_user_input(target_crs).to_json_dict(),
            "transformation": CoordinateOperation.from_user_input(
                transformation
            ).to_json_dict(),
        }

        super().__init__(bound_crs_json)
