"""Parse a pyproj CRS from a GeoKeyDirectory.

In the simple case, a GeoTIFF is defined by an EPSG code, which we can pass to
`pyproj.CRS.from_epsg`.

In the complex case, a GeoTIFF is defined by a set of geo keys that specify all
the parameters of a user-defined CRS. In this case, we need to build a PROJJSON
dictionary representing the CRS, which we can then pass to `pyproj.CRS.from_json_dict`.

Most of the code to construct a custom PROJJSON is generated by Claude. For any files
where CRS generation fails, we should add a new test case in `test_crs.py`.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

from pyproj import CRS
from pyproj.crs import CoordinateOperation  # type: ignore[import]

if TYPE_CHECKING:
    from async_tiff import GeoKeyDirectory

MODEL_TYPE_PROJECTED = 1
"""GeoTIFF model type constant for projected coordinate systems."""

MODEL_TYPE_GEOGRAPHIC = 2
"""GeoTIFF model type constant for geographic coordinate systems."""

USER_DEFINED_CRS = 32767
"""Sentinel value for user-defined CRS."""

# The two "public" functions of this module
__all__ = ["crs_from_geo_keys", "projjson_from_geo_keys"]


def crs_from_geo_keys(gkd: GeoKeyDirectory) -> CRS:
    """Parse a pyproj CRS from a GeoKeyDirectory.

    Supports both EPSG-coded and user-defined CRSes.
    """
    model_type = gkd.model_type

    if model_type == MODEL_TYPE_PROJECTED:
        crs = _projected_projection(gkd)
        if isinstance(crs, int):
            return CRS.from_epsg(crs)

        return CRS.from_json_dict(crs)

    if model_type == MODEL_TYPE_GEOGRAPHIC:
        crs = _geographic_projection(gkd)
        if isinstance(crs, int):
            return CRS.from_epsg(crs)

        return CRS.from_json_dict(crs)

    raise ValueError(f"Unsupported GeoTIFF model type: {model_type}")


def projjson_from_geo_keys(gkd: GeoKeyDirectory) -> dict:
    """Build a PROJJSON dict from a GeoKeyDirectory.

    For EPSG-coded CRSes, resolves via pyproj and returns the canonical
    PROJJSON.

    For user-defined CRSes, constructs the PROJJSON from individual
    geo key parameters.
    """
    model_type = gkd.model_type

    if model_type == MODEL_TYPE_PROJECTED:
        crs = _projected_projection(gkd)
        if isinstance(crs, int):
            return CRS.from_epsg(crs).to_json_dict()

        return crs

    if model_type == MODEL_TYPE_GEOGRAPHIC:
        crs = _geographic_projection(gkd)
        if isinstance(crs, int):
            return CRS.from_epsg(crs).to_json_dict()

        return crs

    raise ValueError(f"Unsupported GeoTIFF model type: {model_type}")


def _geographic_projection(gkd: GeoKeyDirectory) -> int | dict:
    """Infer the geographic CRS from geo keys.

    If an EPSG code is present, returns that integer. Otherwise, constructs
    a user-defined geographic CRS PROJJSON dict.
    """
    epsg = gkd.geographic_type
    if epsg is not None and epsg != USER_DEFINED_CRS:
        return epsg

    return _build_user_defined_geographic_projjson(gkd)


def _projected_projection(gkd: GeoKeyDirectory) -> int | dict:
    """Infer the projected CRS from geo keys.

    If an EPSG code is present, returns that integer. Otherwise, constructs
    a user-defined projected CRS PROJJSON dict.
    """
    epsg = gkd.projected_type
    if epsg is not None and epsg != USER_DEFINED_CRS:
        return epsg

    return _build_user_defined_projected_projjson(gkd)


def _parse_geog_citation(citation: str) -> dict[str, str]:
    """Parse a GDAL-style geog_citation string into a dict of key/value pairs.

    Example input:
        "GCS Name = My CRS|Datum = My Datum|Primem = Greenwich|"
    """
    result: dict[str, str] = {}
    for part in citation.split("|"):
        stripped = part.strip()
        if "=" in stripped:
            k, v = stripped.split("=", 1)
            result[k.strip()] = v.strip()
    return result


def _build_user_defined_geographic_projjson(gkd: GeoKeyDirectory) -> dict:
    """Build a user-defined geographic CRS PROJJSON dict.

    Constructs PROJJSON from the ellipsoid, datum, prime meridian, and angular
    unit parameters stored in the GeoKeyDirectory.
    """
    # Build ellipsoid parameters
    ellipsoid = _build_ellipsoid_params(gkd)

    # Build prime meridian
    pm_name = "Greenwich"
    pm_longitude = 0.0
    if (
        gkd.geog_prime_meridian is not None
        and gkd.geog_prime_meridian != USER_DEFINED_CRS
    ):
        # Known prime meridian by EPSG code
        pm_name = f"EPSG:{gkd.geog_prime_meridian}"
    elif gkd.geog_prime_meridian_long is not None:
        pm_longitude = gkd.geog_prime_meridian_long
        pm_name = "User-defined"

    # Try to extract structured names from GDAL-style geog_citation
    citation_parts = (
        _parse_geog_citation(gkd.geog_citation) if gkd.geog_citation else {}
    )
    gcs_name = citation_parts.get("GCS Name") or gkd.geog_citation or "User-defined"
    datum_name = citation_parts.get("Datum") or gkd.geog_citation or "User-defined"
    if citation_parts.get("Primem"):
        pm_name = citation_parts["Primem"]

    # Build datum
    datum_json: dict
    if (
        gkd.geog_geodetic_datum is not None
        and gkd.geog_geodetic_datum != USER_DEFINED_CRS
    ):
        datum_json = {
            "type": "GeodeticReferenceFrame",
            "name": f"Unknown datum based upon EPSG {gkd.geog_geodetic_datum} ellipsoid",  # noqa: E501
        }
    else:
        datum_json = {
            "type": "GeodeticReferenceFrame",
            "name": datum_name,
            "ellipsoid": ellipsoid,
            "prime_meridian": {
                "name": pm_name,
                "longitude": pm_longitude,
            },
        }

    return {
        "type": "GeographicCRS",
        "$schema": "https://proj.org/schemas/v0.7/projjson.schema.json",
        "name": gcs_name,
        "datum": datum_json,
        "coordinate_system": _geographic_cs(gkd),
    }


def _build_user_defined_projected_projjson(gkd: GeoKeyDirectory) -> dict:
    """Build a user-defined projected CRS PROJJSON dict.

    Constructs PROJJSON from the geographic base CRS and projection parameters
    stored in the GeoKeyDirectory.
    """
    # Build the base geographic CRS as PROJJSON
    base_crs = _geographic_projection(gkd)
    if isinstance(base_crs, int):
        base_crs_json = CRS.from_epsg(base_crs).to_json_dict()
    else:
        base_crs_json = base_crs

    # Build the coordinate operation (projection)
    conversion = _build_conversion(gkd)

    # Build the coordinate system for the projected CRS
    cs = _projected_cs(gkd)

    return {
        "type": "ProjectedCRS",
        "$schema": "https://proj.org/schemas/v0.7/projjson.schema.json",
        "name": gkd.proj_citation or gkd.citation or "User-defined",
        "base_crs": base_crs_json,
        "conversion": conversion,
        "coordinate_system": cs,
    }


def _build_ellipsoid_params(gkd: GeoKeyDirectory) -> dict:
    """Build ellipsoid JSON parameters from geo keys."""
    if gkd.geog_ellipsoid is not None and gkd.geog_ellipsoid != USER_DEFINED_CRS:
        # Known ellipsoid by EPSG code — use parameters from geo keys if present,
        # and include the EPSG id so pyproj resolves the canonical name.
        ellipsoid: dict = {
            "name": f"EPSG ellipsoid {gkd.geog_ellipsoid}",
            "id": {"authority": "EPSG", "code": gkd.geog_ellipsoid},
        }

        if gkd.geog_semi_major_axis is not None:
            ellipsoid["semi_major_axis"] = gkd.geog_semi_major_axis
        if gkd.geog_inv_flattening is not None:
            ellipsoid["inverse_flattening"] = gkd.geog_inv_flattening
        elif gkd.geog_semi_minor_axis is not None:
            ellipsoid["semi_minor_axis"] = gkd.geog_semi_minor_axis

        return ellipsoid

    # Fully user-defined ellipsoid
    semi_major = gkd.geog_semi_major_axis
    if semi_major is None:
        raise ValueError("User-defined ellipsoid requires geog_semi_major_axis")

    user_ellipsoid: dict = {
        "name": "User-defined",
        "semi_major_axis": semi_major,
    }

    if gkd.geog_inv_flattening is not None:
        user_ellipsoid["inverse_flattening"] = gkd.geog_inv_flattening
    elif gkd.geog_semi_minor_axis is not None:
        user_ellipsoid["semi_minor_axis"] = gkd.geog_semi_minor_axis
    else:
        raise ValueError(
            "User-defined ellipsoid requires geog_inv_flattening or geog_semi_minor_axis",  # noqa: E501
        )

    return user_ellipsoid


# GeoTIFF coordinate transformation type codes (GeoKey 3075)
# Ref: http://geotiff.maptools.org/spec/geotiff6.html#6.3.3.3
CT_TRANSVERSE_MERCATOR = 1
CT_TRANSVERSE_MERCATOR_SOUTH = 2
CT_OBLIQUE_MERCATOR = 3
CT_OBLIQUE_MERCATOR_LABORDE = 4
CT_OBLIQUE_MERCATOR_ROSENMUND = 5
CT_OBLIQUE_MERCATOR_SPHERICAL = 6
CT_MERCATOR = 7
CT_LAMBERT_CONFORMAL_CONIC_2SP = 8
CT_LAMBERT_CONFORMAL_CONIC_1SP = 9
CT_LAMBERT_AZIMUTHAL_EQUAL_AREA = 10
CT_ALBERS_EQUAL_AREA = 11
CT_AZIMUTHAL_EQUIDISTANT = 12
CT_STEREOGRAPHIC = 14
CT_POLAR_STEREOGRAPHIC = 15
CT_OBLIQUE_STEREOGRAPHIC = 16
CT_EQUIRECTANGULAR = 17
CT_CASSINI_SOLDNER = 18
CT_ORTHOGRAPHIC = 21
CT_POLYCONIC = 22
CT_SINUSOIDAL = 24
CT_NEW_ZEALAND_MAP_GRID = 26
CT_TRANSVERSE_MERCATOR_SOUTH_ORIENTED = 27


def _build_conversion(gkd: GeoKeyDirectory) -> dict:  # noqa: C901, PLR0912, PLR0915
    """Build a PROJ JSON conversion (coordinate operation) from geo keys."""
    ct = gkd.proj_coord_trans
    if ct is None:
        # Fall back to the named projection code (GeoKey 3074) if available
        if gkd.projection is not None and gkd.projection != USER_DEFINED_CRS:
            conv = CoordinateOperation.from_epsg(gkd.projection).to_json_dict()
            conv.pop("$schema", None)
            return conv
        raise ValueError("User-defined projected CRS requires proj_coord_trans")

    # Helper to build a projection parameter with unit
    def _angular(name: str, value: float | None, default: float = 0.0) -> dict:
        return {
            "name": name,
            "value": value if value is not None else default,
            "unit": "degree",
        }

    def _linear(name: str, value: float | None, default: float = 0.0) -> dict:
        return {
            "name": name,
            "value": value if value is not None else default,
            "unit": "metre",
        }

    def _scale(name: str, value: float | None, default: float = 1.0) -> dict:
        return {
            "name": name,
            "value": value if value is not None else default,
            "unit": "unity",
        }

    name = "User-defined"
    method: dict
    parameters: list[dict]

    if ct == CT_TRANSVERSE_MERCATOR:
        name = "Transverse Mercator"
        method = {"name": "Transverse Mercator"}
        parameters = [
            _angular("Latitude of natural origin", gkd.proj_nat_origin_lat),
            _angular("Longitude of natural origin", gkd.proj_nat_origin_long),
            _scale("Scale factor at natural origin", gkd.proj_scale_at_nat_origin),
            _linear("False easting", gkd.proj_false_easting),
            _linear("False northing", gkd.proj_false_northing),
        ]

    elif ct == CT_TRANSVERSE_MERCATOR_SOUTH:
        name = "Transverse Mercator (South Orientated)"
        method = {"name": "Transverse Mercator (South Orientated)"}
        parameters = [
            _angular("Latitude of natural origin", gkd.proj_nat_origin_lat),
            _angular("Longitude of natural origin", gkd.proj_nat_origin_long),
            _scale("Scale factor at natural origin", gkd.proj_scale_at_nat_origin),
            _linear("False easting", gkd.proj_false_easting),
            _linear("False northing", gkd.proj_false_northing),
        ]

    elif ct in (
        CT_OBLIQUE_MERCATOR,
        CT_OBLIQUE_MERCATOR_LABORDE,
        CT_OBLIQUE_MERCATOR_ROSENMUND,
        CT_OBLIQUE_MERCATOR_SPHERICAL,
    ):
        name = "Hotine Oblique Mercator (variant B)"
        method = {"name": "Hotine Oblique Mercator (variant B)"}
        parameters = [
            _angular("Latitude of projection centre", gkd.proj_center_lat),
            _angular("Longitude of projection centre", gkd.proj_center_long),
            _angular("Azimuth of initial line", gkd.proj_azimuth_angle),
            _angular("Angle from Rectified to Skew Grid", gkd.proj_azimuth_angle),
            _scale("Scale factor on initial line", gkd.proj_scale_at_center),
            _linear("Easting at projection centre", gkd.proj_center_easting),
            _linear("Northing at projection centre", gkd.proj_center_northing),
        ]

    elif ct == CT_MERCATOR:
        name = "Mercator (variant A)"
        method = {"name": "Mercator (variant A)"}
        parameters = [
            _angular("Latitude of natural origin", gkd.proj_nat_origin_lat),
            _angular("Longitude of natural origin", gkd.proj_nat_origin_long),
            _scale("Scale factor at natural origin", gkd.proj_scale_at_nat_origin),
            _linear("False easting", gkd.proj_false_easting),
            _linear("False northing", gkd.proj_false_northing),
        ]

    elif ct == CT_LAMBERT_CONFORMAL_CONIC_2SP:
        name = "Lambert Conic Conformal (2SP)"
        method = {"name": "Lambert Conic Conformal (2SP)"}
        parameters = [
            _angular(
                "Latitude of false origin",
                gkd.proj_false_origin_lat or gkd.proj_nat_origin_lat,
            ),
            _angular(
                "Longitude of false origin",
                gkd.proj_false_origin_long or gkd.proj_nat_origin_long,
            ),
            _angular("Latitude of 1st standard parallel", gkd.proj_std_parallel1),
            _angular("Latitude of 2nd standard parallel", gkd.proj_std_parallel2),
            _linear(
                "Easting at false origin",
                gkd.proj_false_origin_easting or gkd.proj_false_easting,
            ),
            _linear(
                "Northing at false origin",
                gkd.proj_false_origin_northing or gkd.proj_false_northing,
            ),
        ]

    elif ct == CT_LAMBERT_CONFORMAL_CONIC_1SP:
        name = "Lambert Conic Conformal (1SP)"
        method = {"name": "Lambert Conic Conformal (1SP)"}
        parameters = [
            _angular("Latitude of natural origin", gkd.proj_nat_origin_lat),
            _angular("Longitude of natural origin", gkd.proj_nat_origin_long),
            _scale("Scale factor at natural origin", gkd.proj_scale_at_nat_origin),
            _linear("False easting", gkd.proj_false_easting),
            _linear("False northing", gkd.proj_false_northing),
        ]

    elif ct == CT_LAMBERT_AZIMUTHAL_EQUAL_AREA:
        name = "Lambert Azimuthal Equal Area"
        method = {"name": "Lambert Azimuthal Equal Area"}
        parameters = [
            _angular("Latitude of natural origin", gkd.proj_center_lat),
            _angular("Longitude of natural origin", gkd.proj_center_long),
            _linear("False easting", gkd.proj_false_easting),
            _linear("False northing", gkd.proj_false_northing),
        ]

    elif ct == CT_ALBERS_EQUAL_AREA:
        name = "Albers Equal Area"
        method = {"name": "Albers Equal Area"}
        parameters = [
            _angular(
                "Latitude of false origin",
                gkd.proj_false_origin_lat or gkd.proj_nat_origin_lat,
            ),
            _angular(
                "Longitude of false origin",
                gkd.proj_false_origin_long or gkd.proj_nat_origin_long,
            ),
            _angular("Latitude of 1st standard parallel", gkd.proj_std_parallel1),
            _angular("Latitude of 2nd standard parallel", gkd.proj_std_parallel2),
            _linear(
                "Easting at false origin",
                gkd.proj_false_origin_easting or gkd.proj_false_easting,
            ),
            _linear(
                "Northing at false origin",
                gkd.proj_false_origin_northing or gkd.proj_false_northing,
            ),
        ]

    elif ct == CT_AZIMUTHAL_EQUIDISTANT:
        name = "Modified Azimuthal Equidistant"
        method = {"name": "Modified Azimuthal Equidistant"}
        parameters = [
            _angular("Latitude of natural origin", gkd.proj_center_lat),
            _angular("Longitude of natural origin", gkd.proj_center_long),
            _linear("False easting", gkd.proj_false_easting),
            _linear("False northing", gkd.proj_false_northing),
        ]

    elif ct == CT_STEREOGRAPHIC:
        name = "Stereographic"
        method = {"name": "Stereographic"}
        parameters = [
            _angular("Latitude of natural origin", gkd.proj_center_lat),
            _angular("Longitude of natural origin", gkd.proj_center_long),
            _scale("Scale factor at natural origin", gkd.proj_scale_at_center),
            _linear("False easting", gkd.proj_false_easting),
            _linear("False northing", gkd.proj_false_northing),
        ]

    elif ct == CT_POLAR_STEREOGRAPHIC:
        name = "Polar Stereographic (variant B)"
        method = {"name": "Polar Stereographic (variant B)"}
        parameters = [
            _angular(
                "Latitude of standard parallel",
                gkd.proj_nat_origin_lat or gkd.proj_std_parallel1,
            ),
            _angular(
                "Longitude of origin",
                gkd.proj_straight_vert_pole_long or gkd.proj_nat_origin_long,
            ),
            _linear("False easting", gkd.proj_false_easting),
            _linear("False northing", gkd.proj_false_northing),
        ]

    elif ct == CT_OBLIQUE_STEREOGRAPHIC:
        name = "Oblique Stereographic"
        method = {"name": "Oblique Stereographic"}
        parameters = [
            _angular("Latitude of natural origin", gkd.proj_center_lat),
            _angular("Longitude of natural origin", gkd.proj_center_long),
            _scale("Scale factor at natural origin", gkd.proj_scale_at_center),
            _linear("False easting", gkd.proj_false_easting),
            _linear("False northing", gkd.proj_false_northing),
        ]

    elif ct == CT_EQUIRECTANGULAR:
        name = "Equidistant Cylindrical"
        method = {"name": "Equidistant Cylindrical"}
        parameters = [
            _angular(
                "Latitude of 1st standard parallel",
                gkd.proj_std_parallel1 or gkd.proj_center_lat,
            ),
            _angular("Longitude of natural origin", gkd.proj_center_long),
            _linear("False easting", gkd.proj_false_easting),
            _linear("False northing", gkd.proj_false_northing),
        ]

    elif ct == CT_CASSINI_SOLDNER:
        name = "Cassini-Soldner"
        method = {"name": "Cassini-Soldner"}
        parameters = [
            _angular("Latitude of natural origin", gkd.proj_nat_origin_lat),
            _angular("Longitude of natural origin", gkd.proj_nat_origin_long),
            _linear("False easting", gkd.proj_false_easting),
            _linear("False northing", gkd.proj_false_northing),
        ]

    elif ct == CT_POLYCONIC:
        name = "American Polyconic"
        method = {"name": "American Polyconic"}
        parameters = [
            _angular("Latitude of natural origin", gkd.proj_nat_origin_lat),
            _angular("Longitude of natural origin", gkd.proj_nat_origin_long),
            _linear("False easting", gkd.proj_false_easting),
            _linear("False northing", gkd.proj_false_northing),
        ]

    elif ct == CT_SINUSOIDAL:
        name = "Sinusoidal"
        method = {"name": "Sinusoidal"}
        parameters = [
            _angular("Longitude of natural origin", gkd.proj_center_long),
            _linear("False easting", gkd.proj_false_easting),
            _linear("False northing", gkd.proj_false_northing),
        ]

    elif ct == CT_ORTHOGRAPHIC:
        name = "Orthographic"
        method = {"name": "Orthographic"}
        parameters = [
            _angular("Latitude of natural origin", gkd.proj_center_lat),
            _angular("Longitude of natural origin", gkd.proj_center_long),
            _linear("False easting", gkd.proj_false_easting),
            _linear("False northing", gkd.proj_false_northing),
        ]

    elif ct == CT_NEW_ZEALAND_MAP_GRID:
        name = "New Zealand Map Grid"
        method = {"name": "New Zealand Map Grid"}
        parameters = [
            _angular("Latitude of natural origin", gkd.proj_nat_origin_lat),
            _angular("Longitude of natural origin", gkd.proj_nat_origin_long),
            _linear("False easting", gkd.proj_false_easting),
            _linear("False northing", gkd.proj_false_northing),
        ]

    elif ct == CT_TRANSVERSE_MERCATOR_SOUTH_ORIENTED:
        name = "Transverse Mercator (South Orientated)"
        method = {"name": "Transverse Mercator (South Orientated)"}
        parameters = [
            _angular("Latitude of natural origin", gkd.proj_nat_origin_lat),
            _angular("Longitude of natural origin", gkd.proj_nat_origin_long),
            _scale("Scale factor at natural origin", gkd.proj_scale_at_nat_origin),
            _linear("False easting", gkd.proj_false_easting),
            _linear("False northing", gkd.proj_false_northing),
        ]

    else:
        raise ValueError(f"Unsupported coordinate transformation type: {ct}")

    return {
        "name": name,
        "method": method,
        "parameters": parameters,
    }


ANGULAR_UNIT_RADIAN = 9101
"""EPSG code for radian angular unit."""

ANGULAR_UNIT_DEGREE = 9102
"""EPSG code for degree angular unit."""

ANGULAR_UNIT_GRAD = 9105
"""EPSG code for grad angular unit."""

ANGULAR_UNIT: dict[int | None, str] = {
    ANGULAR_UNIT_DEGREE: "degree",
    ANGULAR_UNIT_RADIAN: "radian",
    ANGULAR_UNIT_GRAD: "grad",
}


def _geographic_cs(gkd: GeoKeyDirectory) -> dict:
    """Build a geographic coordinate system JSON dict from geo keys."""
    angular_unit = ANGULAR_UNIT.get(
        gkd.geog_angular_units,
        "degree",
    )

    return {
        "subtype": "ellipsoidal",
        "axis": [
            {
                "name": "Latitude",
                "abbreviation": "lat",
                "direction": "north",
                "unit": angular_unit,
            },
            {
                "name": "Longitude",
                "abbreviation": "lon",
                "direction": "east",
                "unit": angular_unit,
            },
        ],
    }


LINEAR_UNIT_METRE = 9001
LINEAR_UNIT_FOOT = 9002
LINEAR_UNIT_US_SURVEY_FOOT = 9003

LINEAR_UNIT: dict[int | None, str | dict[str, str | float]] = {
    LINEAR_UNIT_METRE: "metre",
    LINEAR_UNIT_FOOT: "foot",
    LINEAR_UNIT_US_SURVEY_FOOT: {
        "type": "LinearUnit",
        "name": "US survey foot",
        "conversion_factor": 0.30480060960121924,
    },
}


def _projected_cs(gkd: GeoKeyDirectory) -> dict:
    """Build a projected coordinate system JSON dict from geo keys."""
    linear_unit: str | dict[str, str | float] = LINEAR_UNIT.get(
        gkd.proj_linear_units,
        "metre",
    )

    return {
        "subtype": "Cartesian",
        "axis": [
            {
                "name": "Easting",
                "abbreviation": "E",
                "direction": "east",
                "unit": linear_unit,
            },
            {
                "name": "Northing",
                "abbreviation": "N",
                "direction": "north",
                "unit": linear_unit,
            },
        ],
    }
