# The MIT License (MIT)
# Copyright (c) 2025 by the xcube development team and contributors
#
# Permission is hereby granted, free of charge, to any person obtaining a
# copy of this software and associated documentation files (the "Software"),
# to deal in the Software without restriction, including without limitation
# the rights to use, copy, modify, merge, publish, distribute, sublicense,
# and/or sell copies of the Software, and to permit persons to whom the
# Software is furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
# DEALINGS IN THE SOFTWARE.

import abc
import copy
import math
import threading
from collections.abc import Callable, Mapping, Sequence
from typing import Any

import numpy as np
import pyproj
import xarray as xr

from xcube_resampling.constants import AffineTransformMatrix, FloatInt

from .assertions import assert_given, assert_instance, assert_true
from .helpers import (
    _assert_valid_xy_coords,
    _assert_valid_xy_names,
    _from_affine,
    _normalize_int_pair,
    _normalize_number_pair,
    _to_affine,
    scale_xy_res_and_size,
)

# WGS84, axis order: lat, lon
CRS_WGS84 = pyproj.crs.CRS(4326)
# CRS84, axis order: lon, lat
CRS84 = "OGC:CRS84"
CRS_CRS84 = pyproj.crs.CRS.from_string(CRS84)

# Default tolerance for all operations that
# accept a key-word argument "tolerance":
DEFAULT_TOLERANCE = 1.0e-5


class GridMapping(abc.ABC):
    """An abstract base class for grid mappings that define an image grid and
    a transformation from image pixel coordinates to spatial Earth coordinates
    defined in a well-known coordinate reference system (CRS).

    This class cannot be instantiated directly. Use one of its factory methods
    to create instances:

    - `regular`
    - `regular_from_bbox`
    - `from_dataset`
    - `from_coords`

    Some instance methods can be used to derive new instances:

    - `derive`
    - `scale`
    - `transform`
    - `to_regular`

    Notes:

    - `x` : eastward axis in CRS coordinates
    - `y` : northward axis in CRS coordinates
    - `i` : eastward axis in pixel space
    - `j` : northward axis in pixel space

    References:
        [CF Conventions — Coordinate types](https://cfconventions.org/Data/cf-conventions/cf-conventions-1.13/cf-conventions.html#coordinate-types)
    """

    def __init__(
        self,
        /,
        size: int | tuple[int, int],
        tile_size: int | tuple[int, int] | None,
        xy_bbox: tuple[FloatInt, FloatInt, FloatInt, FloatInt],
        xy_res: FloatInt | tuple[FloatInt, FloatInt],
        crs: pyproj.crs.CRS,
        xy_var_names: tuple[str, str],
        xy_dim_names: tuple[str, str],
        is_regular: bool | None = None,
        is_lon_360: bool | None = None,
        is_j_axis_up: bool | None = None,
        x_coords: xr.DataArray | None = None,
        y_coords: xr.DataArray | None = None,
    ):
        width, height = _normalize_int_pair(size, name="size")
        assert_true(width > 1 and height > 1, "invalid size")

        tile_width, tile_height = _normalize_int_pair(
            tile_size, default=(width, height)
        )
        assert_true(tile_width > 1 and tile_height > 1, "invalid tile_size")

        assert_given(xy_bbox, name="xy_bbox")
        assert_given(xy_res, name="xy_res")
        _assert_valid_xy_names(xy_var_names, name="xy_var_names")
        _assert_valid_xy_names(xy_dim_names, name="xy_dim_names")
        assert_instance(crs, pyproj.crs.CRS, name="crs")

        if x_coords is not None:
            assert_instance(x_coords, xr.DataArray, name="x_coords")
            assert_true(
                x_coords.ndim in (1, 2),
                message=f"x_coords.ndim must be 1 or 2, was {x_coords.ndim}",
            )
        if y_coords is not None:
            assert_instance(y_coords, xr.DataArray, name="y_coords")
            assert_true(
                y_coords.ndim in (1, 2),
                message=f"y_coords.ndim must be 1 or 2, was {y_coords.ndim}",
            )

        x_res, y_res = _normalize_number_pair(xy_res, name="xy_res")
        assert_true(x_res > 0 and y_res > 0, "invalid xy_res")

        self._lock = threading.RLock()

        self._size = width, height
        self._tile_size = tile_width, tile_height
        self._xy_bbox = xy_bbox
        self._xy_res = x_res, y_res
        self._crs = crs
        self._xy_var_names = xy_var_names
        self._xy_dim_names = xy_dim_names
        self._is_regular = is_regular
        self._is_lon_360 = is_lon_360
        self._is_j_axis_up = is_j_axis_up
        self._x_coords = x_coords
        self._y_coords = y_coords
        self._xy_coords = None

    def derive(
        self,
        /,
        xy_var_names: tuple[str, str] = None,
        xy_dim_names: tuple[str, str] = None,
        tile_size: int | tuple[int, int] = None,
        is_j_axis_up: bool = None,
    ) -> "GridMapping":
        """Derive a new grid mapping from this one with some properties changed.

        Args:
            xy_var_names: The new x-, and y-variable names.
            xy_dim_names: The new x-, and y-dimension names.
            tile_size: The new tile size
            is_j_axis_up: Whether j-axis points up.

        Returns:
            A new, derived grid mapping.
        """
        other = copy.copy(self)
        if xy_var_names is not None:
            _assert_valid_xy_names(xy_var_names, name="xy_var_names")
            other._xy_var_names = xy_var_names
        if xy_dim_names is not None:
            _assert_valid_xy_names(xy_dim_names, name="xy_dim_names")
            other._xy_dim_names = xy_dim_names
        if tile_size is not None:
            tile_width, tile_height = _normalize_int_pair(tile_size, name="tile_size")
            assert_true(tile_width > 1 and tile_height > 1, "invalid tile_size")
            tile_size = tile_width, tile_height
            if other.tile_size != tile_size:
                other._tile_size = tile_width, tile_height
                with self._lock:
                    # if other._xy_coords has not been initialized before, we will do it
                    # in the next line. Otherwise, the following lines raise an error
                    if other._xy_coords is None:
                        _ = other.xy_coords
                    other._xy_coords = other._xy_coords.chunk(
                        {
                            dim: size
                            for (dim, size) in zip(
                                other._xy_coords.dims, other.xy_coords_chunks
                            )
                        }
                    )
        if is_j_axis_up is not None and is_j_axis_up != other._is_j_axis_up:
            other._is_j_axis_up = is_j_axis_up
            if other._y_coords is not None:
                other._y_coords = other._y_coords[::-1]
            if other._xy_coords is not None:
                other._xy_coords = other._xy_coords[:, ::-1, :]
                other._xy_coords = other._xy_coords.chunk(
                    {
                        dim: size
                        for (dim, size) in zip(
                            other._xy_coords.dims, other.xy_coords_chunks
                        )
                    }
                )

        return other

    def scale(
        self,
        xy_scale: FloatInt | tuple[FloatInt, FloatInt],
        tile_size: int | tuple[int, int] | None = None,
    ) -> "GridMapping":
        """Derive a scaled version of this regular grid mapping.

        Scaling factors larger than one correspond to up-scaling
        (pixels sizes decrease, image size increases).

        Scaling factors lower than one correspond to down-scaling.
        (pixels sizes increase, image size decreases).

        Args:
            xy_scale: The x-, and y-scaling factors. May be a single
                number or tuple.
            tile_size: The new tile size

        Returns:
            A new, scaled grid mapping.
        """
        self._assert_regular()
        x_scale, y_scale = _normalize_number_pair(xy_scale)
        new_xy_res, new_size = scale_xy_res_and_size(
            self.xy_res, self.size, (x_scale, y_scale)
        )
        if tile_size is not None:
            tile_width, tile_height = _normalize_int_pair(tile_size, name="tile_size")
        else:
            tile_width, tile_height = self.tile_size
        tile_width = min(new_size[0], tile_width)
        tile_height = min(new_size[1], tile_height)
        return self.regular(
            new_size,
            (self.xy_bbox[0] + new_xy_res[0] / 2, self.xy_bbox[1] + new_xy_res[1] / 2),
            new_xy_res,
            self.crs,
            tile_size=(tile_width, tile_height),
            is_j_axis_up=self.is_j_axis_up,
        ).derive(xy_dim_names=self.xy_dim_names, xy_var_names=self.xy_var_names)

    @property
    def size(self) -> tuple[int, int]:
        """Image size (width, height) in pixels."""
        return self._size

    @property
    def width(self) -> int:
        """Image width in pixels."""
        return self.size[0]

    @property
    def height(self) -> int:
        """Image height in pixels."""
        return self.size[1]

    @property
    def tile_size(self) -> tuple[int, int]:
        """Image tile size (width, height) in pixels."""
        return self._tile_size

    @property
    def is_tiled(self) -> bool:
        """Whether the image is tiled."""
        return self.size != self.tile_size

    @property
    def tile_width(self) -> int:
        """Image tile width in pixels."""
        return self.tile_size[0]

    @property
    def tile_height(self) -> int:
        """Image tile height in pixels."""
        return self.tile_size[1]

    @property
    def x_coords(self):
        """Return the eastward (x) coordinate values.

        The coordinates are provided as either a 1D or 2D array:
        - Shape (width,) for rectilinear grids
        - Shape (height, width) for curvilinear grids

        See Also:
            [CF Conventions, Longitude Coordinate](https://cfconventions.org/Data/cf-conventions/cf-conventions-1.13/cf-conventions.html#longitude-coordinate)
        """
        return self._get_computed_attribute("_x_coords", self._new_x_coords)

    @abc.abstractmethod
    def _new_x_coords(self) -> xr.DataArray:
        """Create new 1D or 2D x-coordinate array of
        shape (width,) or (height, width).
        """

    @property
    def y_coords(self):
        """Return the northward (y) coordinate values.

        The coordinates are provided as either a 1D or 2D array:
        - Shape (width,) for rectilinear grids
        - Shape (height, width) for curvilinear grids

        See Also:
            [CF Conventions, Latitude Coordinate](https://cfconventions.org/Data/cf-conventions/cf-conventions-1.13/cf-conventions.html#latitude-coordinate)
        """
        return self._get_computed_attribute("_y_coords", self._new_y_coords)

    @abc.abstractmethod
    def _new_y_coords(self) -> xr.DataArray:
        """Create new 1D or 2D y-coordinate array of
        shape (width,) or (height, width).
        """

    @property
    def xy_coords(self) -> xr.DataArray:
        """Return the spatial coordinates as a single DataArray.

        The coordinates are provided as a 3D array of shape `(2, height, width)`,
        where the first axis corresponds to the x and y coordinates stacked in order:
        `(x_coords, y_coords)`. All values are expressed in the units of the
        dataset's CRS (coordinate reference system).
        """
        xy_coords = self._get_computed_attribute("_xy_coords", self._new_xy_coords)
        _assert_valid_xy_coords(xy_coords)
        return xy_coords

    @property
    def xy_coords_chunks(self) -> tuple[int, int, int]:
        """Return the chunks for the `xy_coords` array."""
        return 2, self.tile_height, self.tile_width

    @abc.abstractmethod
    def _new_xy_coords(self) -> xr.DataArray:
        """Create new coordinate array of shape (2, height, width)."""

    def _get_computed_attribute(self, name: str, computer: Callable[[], Any]) -> Any:
        """Get the value for a computed attribute.
        Utility to be used by this and derived classes.
        """
        value = getattr(self, name)
        if value is not None:
            return value
        with self._lock:
            # Double null check
            value = getattr(self, name)
            if value is not None:
                return value
            value = computer()
            setattr(self, name, value)
            return value

    @property
    def xy_var_names(self) -> tuple[str, str]:
        """The variable names of `x_coords` and `y_coords` as tuple
        `(x_var_name, y_var_name)`.
        """
        return self._xy_var_names

    @property
    def xy_dim_names(self) -> tuple[str, str]:
        """The dimension names of `x_coords` and `y_coords` as
        tuple `(x_dim_name, y_dim_name)`.
        """
        return self._xy_dim_names

    @property
    def xy_bbox(self) -> tuple[float, float, float, float]:
        """The image's bounding box in CRS coordinates."""
        return self._xy_bbox

    @property
    def x_min(self) -> FloatInt:
        """Minimum eastward (x) coordinate value in CRS units."""
        return round(self._xy_bbox[0] + self.x_res / 2, ndigits=7)

    @property
    def y_min(self) -> FloatInt:
        """Minimum northward (y) coordinate value in CRS units."""
        return round(self._xy_bbox[1] + self.y_res / 2, ndigits=7)

    @property
    def x_max(self) -> FloatInt:
        """Maximum eastward (x) coordinate value in CRS units."""
        return round(self._xy_bbox[2] - self.x_res / 2, ndigits=7)

    @property
    def y_max(self) -> FloatInt:
        """Maximum northward (y) coordinate value in CRS units."""
        return round(self._xy_bbox[3] - self.y_res / 2, ndigits=7)

    @property
    def xy_res(self) -> tuple[FloatInt, FloatInt]:
        """Pixel size in eastward (x) and northward (y) direction `(x_res, y_res)`."""
        return self._xy_res

    @property
    def x_res(self) -> FloatInt:
        """Pixel size in CRS units per pixel in eastward (x) direction."""
        return self._xy_res[0]

    @property
    def y_res(self) -> FloatInt:
        """Pixel size in CRS units per pixel in northward (y) direction."""
        return self._xy_res[1]

    @property
    def crs(self) -> pyproj.crs.CRS:
        """The coordinate reference system."""
        return self._crs

    @property
    def spatial_unit_name(self) -> str:
        return self._crs.axis_info[0].unit_name

    @property
    def is_lon_360(self) -> bool | None:
        """Check whether `x_max` is greater than 180 degrees.
        Effectively tests whether the range `x_min`, `x_max` crosses
        the anti-meridian at 180 degrees.
        Works only for geographical coordinate reference systems.
        """
        return self._is_lon_360

    @property
    def is_regular(self) -> bool | None:
        """Check whether the x (eastward) and y (northward) coordinates form a regular
        grid.

        A regular grid is defined as having constant spacing (delta) along
        the x- and y-axes individually.
        """

        return self._is_regular

    @property
    def is_j_axis_up(self) -> bool | None:
        """Indicate whether the positive image j-axis points upward.

        Here, the j-axis refers to the y-coordinate in pixel space. By convention,
        the positive j-axis usually points downward in image coordinates, so
        `True` indicates that the axis has been flipped to point upward.
        """
        return self._is_j_axis_up

    @property
    def ij_to_xy_transform(self) -> AffineTransformMatrix:
        """The affine transformation matrix from image to CRS coordinates.
        Defined only for grid mappings with regular x (eastward) and y (northward)
        coordinates.
        """
        self._assert_regular()
        if self.is_j_axis_up:
            return (
                (self.x_res, 0.0, self.x_min),
                (0.0, self.y_res, self.y_min),
            )
        else:
            return (
                (self.x_res, 0.0, self.x_min),
                (0.0, -self.y_res, self.y_max),
            )

    @property
    def xy_to_ij_transform(self) -> AffineTransformMatrix:
        """The affine transformation matrix from CRS to image coordinates.
        Defined only for grid mappings with regular x (eastward) and y (northward)
        coordinates.
        """
        self._assert_regular()
        return _from_affine(~_to_affine(self.ij_to_xy_transform))

    def ij_transform_to(self, other: "GridMapping") -> AffineTransformMatrix:
        """Get the affine transformation matrix that transforms
        image coordinates of `other` grid-mapping into image coordinates
        of this image geometry.

        Defined only for grid mappings with regular x (eastward) and y (northward)
        coordinates.

        Args:
            other: The other grid-mapping

        Returns:
            Affine transformation matrix
        """
        self._assert_regular()
        self.assert_regular(other, name="other")
        a = _to_affine(self.ij_to_xy_transform)
        b = _to_affine(other.xy_to_ij_transform)
        return _from_affine(b * a)

    def ij_transform_from(self, other: "GridMapping") -> AffineTransformMatrix:
        """Get the affine transformation matrix that transforms
        image coordinates of this grid-mapping to image
        coordinates of *other* grid-mapping.

        Defined only for grid mappings with regular x (eastward) and y (northward)
        coordinates.

        Args:
            other: The other grid-mapping

        Returns:
            Affine transformation matrix
        """
        self._assert_regular()
        self.assert_regular(other, name="other")
        a = _to_affine(self.ij_transform_to(other))
        return _from_affine(~a)

    @property
    def ij_bbox(self) -> tuple[int, int, int, int]:
        """The image's bounding box in pixel coordinates."""
        return 0, 0, self.width, self.height

    @property
    def ij_bboxes(self) -> np.ndarray:
        """The image tiles' bounding boxes in image pixel coordinates."""
        h, w = self.height, self.width
        th, tw = self.tile_height, self.tile_width

        y_starts = range(0, h, th)
        x_starts = range(0, w, tw)

        n = len(y_starts) * len(x_starts)
        ij_bboxes = np.empty((n, 4), dtype=np.int64)

        k = 0
        for y0 in y_starts:
            y1 = min(y0 + th, h)
            for x0 in x_starts:
                x1 = min(x0 + tw, w)
                ij_bboxes[k] = (x0, y0, x1, y1)
                k += 1

        return ij_bboxes

    @property
    def xy_bboxes(self) -> np.ndarray:
        """The image tiles' bounding boxes in CRS coordinates."""
        x_pad, y_pad = self.x_res / 2, self.y_res / 2
        if self.is_j_axis_up:
            xy_offset = np.array(
                [
                    self.x_min - x_pad,
                    self.y_min - y_pad,
                    self.x_min - x_pad,
                    self.y_min - y_pad,
                ]
            )
            xy_scale = np.array([self.x_res, self.y_res, self.x_res, self.y_res])
            xy_bboxes = xy_offset + xy_scale * self.ij_bboxes
        else:
            xy_offset = np.array(
                [
                    self.x_min - x_pad,
                    self.y_max + y_pad,
                    self.x_min - x_pad,
                    self.y_max + y_pad,
                ]
            )
            xy_scale = np.array([self.x_res, -self.y_res, self.x_res, -self.y_res])
            xy_bboxes = xy_offset + xy_scale * self.ij_bboxes
            xy_bboxes[:, [1, 3]] = xy_bboxes[:, [3, 1]]
        return xy_bboxes

    def to_coords(
        self,
        xy_var_names: tuple[str, str] = None,
        xy_dim_names: tuple[str, str] = None,
        exclude_bounds: bool = False,
        reuse_coords: bool = False,
    ) -> Mapping[str, xr.DataArray]:
        """Get CF-compliant axis coordinate variables and cell boundary
        coordinate variables.

        Defined only for grid mappings with regular x,y coordinates.

        Args:
            xy_var_names: Optional coordinate variable names
                (x_var_name, y_var_name).
            xy_dim_names: Optional coordinate dimensions names
                (x_dim_name, y_dim_name).
            exclude_bounds: If True, do not create bounds coordinates.
                Defaults to False.
            reuse_coords: Whether to either reuse target coordinate
                arrays from target_gm or to compute new ones.

        Returns:
            dictionary with coordinate variables
        """
        self._assert_regular()
        from .coords import grid_mapping_to_coords

        return grid_mapping_to_coords(
            self,
            xy_var_names=xy_var_names,
            xy_dim_names=xy_dim_names,
            exclude_bounds=exclude_bounds,
            reuse_coords=reuse_coords,
        )

    def transform(
        self,
        crs: str | pyproj.crs.CRS,
        *,
        tile_size: int | tuple[int, int] = None,
        xy_var_names: tuple[str, str] = None,
        tolerance: float = DEFAULT_TOLERANCE,
    ) -> "GridMapping":
        """Transform this grid mapping so it uses the given
        spatial coordinate reference system into another *crs*.

        Args:
            crs: The new spatial coordinate reference system.
            tile_size: Optional new tile size.
            xy_var_names: Optional new coordinate names.
            tolerance: Absolute tolerance used when comparing
                coordinates with each other. Must be in the units of the
                *crs* and must be greater zero.

        Returns:
            A new grid mapping that uses *crs*.
        """
        from .transform import transform_grid_mapping

        return transform_grid_mapping(
            self,
            crs,
            tile_size=tile_size,
            xy_var_names=xy_var_names,
            tolerance=tolerance,
        )

    @classmethod
    def regular(
        cls,
        size: int | tuple[int, int],
        xy_min: tuple[float, float],
        xy_res: float | tuple[float, float],
        crs: str | pyproj.crs.CRS,
        *,
        tile_size: int | tuple[int, int] = None,
        is_j_axis_up: bool = False,
    ) -> "GridMapping":
        """Create a new regular grid mapping.

        Args:
            size: Size in pixels.
            xy_min: Minimum x- and y-coordinates.
            xy_res: Resolution in x- and y-directions.
            crs: Spatial coordinate reference system.
            tile_size: Optional tile size.
            is_j_axis_up: Whether positive j-axis points up. Defaults to
                false.

        Returns:
            A new regular grid mapping.
        """
        from .regular import new_regular_grid_mapping

        return new_regular_grid_mapping(
            size=size,
            xy_min=xy_min,
            xy_res=xy_res,
            crs=crs,
            tile_size=tile_size,
            is_j_axis_up=is_j_axis_up,
        )

    @classmethod
    def regular_from_bbox(
        cls,
        bbox: Sequence[FloatInt],
        xy_res: float | tuple[float, float],
        crs: str | pyproj.crs.CRS,
        tile_size: int | tuple[int, int] = None,
        is_j_axis_up: bool = False,
    ) -> "GridMapping":
        """Creates a regular grid mapping for a given coordinate reference system based
        on the given bounding box and spatial resolution.

        Args:
            bbox: Bounding box coordinates in the format [west, south, east, north].
                The values must be in the given CRS.
            xy_res: Resolution in x- and y-directions.
            crs: Coordinate reference system (e.g., "EPSG:4326").
            tile_size: Chunk size for the grid; if a single int is given,
                square chunk size is assumed. Defaults to 1024.
            is_j_axis_up: Whether positive j-axis points up. Defaults to False.

        Returns:
            A regular grid mapping object.
        """
        from .regular import new_regular_grid_mapping_from_bbox

        return new_regular_grid_mapping_from_bbox(
            bbox, xy_res, crs, tile_size=tile_size, is_j_axis_up=is_j_axis_up
        )

    def to_regular(
        self, tile_size: int | tuple[int, int] | None = None, is_j_axis_up: bool = False
    ) -> "GridMapping":
        """Transform this grid mapping into one that is regular.

        Args:
            tile_size: Optional tile size.
            is_j_axis_up: Whether positive j-axis points up. Defaults to
                false.

        Returns:
            A new regular grid mapping or this grid mapping, if it is
            already regular.
        """
        from .regular import to_regular_grid_mapping

        return to_regular_grid_mapping(
            self, tile_size=tile_size, is_j_axis_up=is_j_axis_up
        )

    @classmethod
    def from_dataset(
        cls,
        dataset: xr.Dataset,
        *,
        crs: str | pyproj.crs.CRS | None = None,
        tile_size: int | tuple[int, int] | None = None,
        prefer_is_regular: bool = True,
        prefer_crs: str | pyproj.crs.CRS | None = None,
        emit_warnings: bool = False,
        tolerance: float = DEFAULT_TOLERANCE,
    ) -> "GridMapping":
        """Create a grid mapping for the given *dataset*.

        Args:
            dataset: The dataset.
            crs: Optional spatial coordinate reference system.
            tile_size: Optional tile size
            prefer_is_regular: Whether to prefer a regular grid mapping
                if multiple found. Default is True.
            prefer_crs: The preferred CRS of a grid mapping if multiple
                found.
            emit_warnings: Whether to emit warning for non-CF compliant
                datasets.
            tolerance: Absolute tolerance used when comparing
                coordinates with each other. Must be in the units of the
                *crs* and must be greater zero.

        Returns:
            a new grid mapping instance.
        """
        from .dataset import new_grid_mapping_from_dataset

        return new_grid_mapping_from_dataset(
            dataset=dataset,
            crs=crs,
            tile_size=tile_size,
            prefer_is_regular=prefer_is_regular,
            prefer_crs=prefer_crs,
            emit_warnings=emit_warnings,
            tolerance=tolerance,
        )

    @classmethod
    def from_coords(
        cls,
        x_coords: xr.DataArray,
        y_coords: xr.DataArray,
        crs: str | pyproj.crs.CRS,
        *,
        tile_size: int | tuple[int, int] | None = None,
        tolerance: float = DEFAULT_TOLERANCE,
    ) -> "GridMapping":
        """Create a grid mapping from given x- and y-coordinates
        *x_coords*, *y_coords* and spatial coordinate reference
        system *crs*.

        Args:
            x_coords: The x-coordinates.
            y_coords: The y-coordinates.
            crs: The spatial coordinate reference system.
            tile_size: Optional tile size.
            tolerance: Absolute tolerance used when comparing
                coordinates with each other. Must be in the units of the
                *crs* and must be greater zero.

        Returns:
            A new grid mapping.
        """
        from .coords import new_grid_mapping_from_coords

        return new_grid_mapping_from_coords(
            x_coords=x_coords,
            y_coords=y_coords,
            crs=crs,
            tile_size=tile_size,
            tolerance=tolerance,
        )

    def is_close(
        self, other: "GridMapping", tolerance: float = DEFAULT_TOLERANCE
    ) -> bool:
        """Tests whether this grid mapping is close to *other*.

        Args:
            other: The other grid mapping.
            tolerance: Absolute tolerance used when comparing
                coordinates with each other. Must be in the units of the
                *crs* and must be greater zero.

        Returns:
            True, if so, False otherwise.
        """
        if self is other:
            return True
        if (
            self.is_j_axis_up == other.is_j_axis_up
            and self.is_lon_360 == other.is_lon_360
            and self.is_regular == other.is_regular
            and self.size == other.size
            and self.tile_size == other.tile_size
            and self.crs == other.crs
        ):
            sxr, syr = self.xy_res
            oxr, oyr = other.xy_res
            if math.isclose(sxr, oxr, abs_tol=tolerance) and math.isclose(
                syr, oyr, abs_tol=tolerance
            ):
                sx1, sy1, sx2, sy2 = self.xy_bbox
                ox1, oy1, ox2, oy2 = other.xy_bbox
                return (
                    math.isclose(sx1, ox1, abs_tol=tolerance)
                    and math.isclose(sy1, oy1, abs_tol=tolerance)
                    and math.isclose(sx2, ox2, abs_tol=tolerance)
                    and math.isclose(sy2, oy2, abs_tol=tolerance)
                )
        return False

    @classmethod
    def assert_regular(cls, value: Any, name: str = None):
        assert_instance(value, GridMapping, name=name)
        if not value.is_regular:
            raise ValueError(f"{name or 'value'} must be a regular grid mapping")

    def _assert_regular(self):
        if not self.is_regular:
            raise NotImplementedError(
                "Operation not implemented for non-regular grid mappings"
            )

    def _repr_markdown_(self) -> str:
        """Generate an IPython Notebook Markdown representation."""
        is_regular = self.is_regular if self.is_regular is not None else "_unknown_"
        is_j_axis_up = (
            self.is_j_axis_up if self.is_j_axis_up is not None else "_unknown_"
        )
        is_lon_360 = self.is_lon_360 if self.is_lon_360 is not None else "_unknown_"
        xy_res = repr(self.xy_res) + ("" if self.is_regular else "  _estimated_")
        return "\n".join(
            [
                f"class: **{self.__class__.__name__}**",
                f"* is_regular: {is_regular}",
                f"* is_j_axis_up: {is_j_axis_up}",
                f"* is_lon_360: {is_lon_360}",
                f"* crs: {self.crs}",
                f"* xy_res: {xy_res}",
                f"* xy_bbox: {self.xy_bbox}",
                f"* ij_bbox: {self.ij_bbox}",
                f"* xy_dim_names: {self.xy_dim_names}",
                f"* xy_var_names: {self.xy_var_names}",
                f"* size: {self.size}",
                f"* tile_size: {self.tile_size}",
            ]
        )
