"""Index coordinates and ``Shapely`` geometries against the BNG index system.

Supports bi-directional conversion between easting/northing coordinate pairs
and :class:`~osbng.bng_reference.BNGReference` objects at supported resolutions as
defined in the :doc:`resolution` module. Additionally, it enables the indexing of
geometries, represented using Shapely_ ``Geometry`` objects, into grid squares at a
specified resolution. ``Shapely`` geometries can also be decomposed into simplified
representations bounded by their presence in each grid square at a specified resolution.

Indexing functionality facilitates grid-based spatial analysis, enabling applications
such as statistical aggregation, data visualisation, and data interoperability.

Summary of functionality:

- Encoding easting and northing coordinates into
  :class:`~osbng.bng_reference.BNGReference` objects at a specified resolution.
- Decoding :class:`~osbng.bng_reference.BNGReference` objects back into
  easting/northing coordinates, bounding boxes and grid squares as ``Shapely``
  geometries.
- Indexing bounding boxes into grid squares at a specified resolution.
- Indexing ``Shapely`` geometries into grid squares at a specified resolution.
- Decomposing ``Shapely`` geometries into simplified representations bounded by their
  presence in each grid square at a specified resolution.

Supported resolutions:

- The module supports the 'standard' and 'intermediate' quadtree resolutions:
  ``100km``, ``50km``, ``10km``, ``5km``, ``1km``, ``500m``, ``100m``, ``50m``,
  ``10m``, ``5m`` and ``1m``.
- These resolutions passed to indexing functions are validated and normalised using
  the resolution mapping defined in the :doc:`resolution` module.

See Also:
    The :doc:`indexing_gpd` module which provides a
    :func:`~osbng.indexing_gpd.gdf_to_bng_intersection_explode` function supporting the
    indexing  of geometries in a GeoPandas_ ``GeoDataFrame`` against the BNG index
    system.

.. _Shapely:
   https://github.com/shapely/shapely

.. _GeoPandas:
   https://github.com/geopandas/geopandas
"""

import warnings

import numpy as np
import numpy.typing as npt
from shapely import Geometry, box, contains, intersection, intersects, prepare
from shapely.geometry import Polygon

from osbng.bng_reference import _PATTERN, BNGReference, _validate_bngreferences
from osbng.errors import BNGExtentError, BNGResolutionError
from osbng.resolution import BNG_RESOLUTIONS

__all__ = [
    "PREFIXES",
    "SUFFIXES",
    "BNGIndexedGeometry",
    "xy_to_bng",
    "bng_to_xy",
    "bng_to_bbox",
    "bng_to_grid_geom",
    "bbox_to_bng",
    "geom_to_bng",
    "geom_to_bng_intersection",
]

# Set warnings to always display
warnings.simplefilter("always")

# 100km BNG grid square letter prefixes and corresponding positional indices
PREFIXES: npt.NDArray[np.str_] = np.array(
    [
        ["SV", "SW", "SX", "SY", "SZ", "TV", "TW"],
        ["SQ", "SR", "SS", "ST", "SU", "TQ", "TR"],
        ["SL", "SM", "SN", "SO", "SP", "TL", "TM"],
        ["SF", "SG", "SH", "SJ", "SK", "TF", "TG"],
        ["SA", "SB", "SC", "SD", "SE", "TA", "TB"],
        ["NV", "NW", "NX", "NY", "NZ", "OV", "OW"],
        ["NQ", "NR", "NS", "NT", "NU", "OQ", "OR"],
        ["NL", "NM", "NN", "NO", "NP", "OL", "OM"],
        ["NF", "NG", "NH", "NJ", "NK", "OF", "OG"],
        ["NA", "NB", "NC", "ND", "NE", "OA", "OB"],
        ["HV", "HW", "HX", "HY", "HZ", "JV", "JW"],
        ["HQ", "HR", "HS", "HT", "HU", "JQ", "JR"],
        ["HL", "HM", "HN", "HO", "HP", "JL", "JM"],
    ]
)
"""npt.NDArray[np.str_] of shape (13, 7): 100km BNG square letter prefixes.

Each element is a two-letter string itentifying a 100km grid square. The positional 
indices correspond to the 10km grid square location in the BNG index system and are 
used to determine the specific grid square for a given set of easting and northing 
coordinates.
"""

# BNG ordinal direction suffixes and corresponding positional indices
# Used to identify intermediate quadtree resolutions
SUFFIXES: npt.NDArray[np.str_] = np.array([["SW", "NW"], ["SE", "NE"]])
"""npt.NDArray[np.str_] of shape (2, 2): BNG ordinal direction suffixes.

Used to identify intermediate quadtree resolutions.
"""


class BNGIndexedGeometry:
    """Represents the decomposition of a Shapely Geometry object into BNG grid squares.

    The ``BNGIndexedGeometry`` class stores information about the relationship between
    an input geometry and the grid squares it intersects. This is particularly useful
    for spatial indexing and analysis of geometries against the BNG index system.

    Attributes:
        bng_ref (BNGReference): The ``BNGReference`` corresponding to this grid square.
        is_core (bool): A boolean flag indicating whether this grid square geometry is
            entirely contained by the input geometry.
        geom (Geometry): The intersection geometry between the input geometry and this
            grid square.

    See Also:
        The ``BNGIndexedGeometry`` class is instantiated as part of the
        :func:`~osbng.indexing.geom_to_bng_intersection` indexing function that
        decomposes a ``Shapely Geometry`` into grid squares at a specified resolution.
        The decomposition can be used for indexing, spatial analysis, or visualisation.
    """

    def __init__(self, bng_ref: BNGReference, is_core: bool, geom: Geometry):
        """Initialises a ``BNGIndexedGeometry``."""
        self._bng_ref = bng_ref
        self._is_core = is_core
        self._geom = geom

    @property
    def bng_ref(self) -> BNGReference:
        """The ``BNGReference`` corresponding to this grid square."""
        return self._bng_ref

    @property
    def is_core(self) -> bool:
        """Flag indicating whether this grid square is contained by the input geometry.

        This is particularly relevant for ``Polygon`` and ``MultiPolygon`` geometries
        and helps distinguish between ``core`` (fully inside) and ``edge``
        (partially overlapping) grid squares.
        """
        return self._is_core

    @property
    def geom(self) -> Geometry:
        """The intersection geometry between the input geometry and this grid square.

        The ``Shapely Geometry`` representing the intersection between the input
        geometry and this grid square. This can one of a number of geometry types
        depending on the overlap. When ``is_core`` = True, ``geom`` is equal to the
        grid square geometry.
        """
        return self._geom

    def __repr__(self):
        """String representation of the ``BNGIndexedGeometry``."""
        return (
            f"BNGIndexedGeometry(bng_ref={self._bng_ref}, "
            f"is_core={self._is_core}, geom={self._geom.wkt})"
        )


def _validate_and_normalise_bng_resolution(resolution: int | str) -> int:
    """Validates and normalises a BNG resolution to its metre-based integer value.

    Args:
        resolution (int | str): The resolution, either as a metre-based integer
            or string label.

    Returns:
        int: The numeric metre-based resolution.

    Raises:
        BNGResolutionError: If an invalid resolution is provided.

    Examples:
        >>> _validate_and_normalise_bng_resolution(1000)
        1000
        >>> _validate_and_normalise_bng_resolution("1km")
        1000
    """
    # If resolution is an integer, check if it's a valid metre-based resolution
    if isinstance(resolution, int):
        if resolution not in BNG_RESOLUTIONS.keys():
            raise BNGResolutionError()
        return resolution

    # If resolution is a string, check if it's a valid resolution label
    elif isinstance(resolution, str):
        if resolution not in [value["label"] for value in BNG_RESOLUTIONS.values()]:
            raise BNGResolutionError()
        # Get the corresponding metre-based resolution
        return next(
            res
            for res, value in BNG_RESOLUTIONS.items()
            if value["label"] == resolution
        )

    # If resolution is neither an integer nor a string, raise BNGResolutionError
    else:
        raise BNGResolutionError()


def _validate_easting_northing(easting: int | float, northing: int | float) -> None:
    """Validates that coordinates are within the bounds of the BNG index system extent.

    The easting and northing coordinates must be below the upper bounds of the BNG
    system. Coordinates of 700000 (easting) and 1300000 (northing) would correspond to
    a BNG reference representing the southwest (lower-left) corner of a grid square
    beyond the BNG index system's limits.

    Args:
        easting (int | float): The easting coordinate.
            Must be in the range 0 <= easting < 700000.
        northing (int | float): The northing coordinate.
            Must be in the range 0 <= northing < 1300000.

    Raises:
        BNGExtentError: If the easting or northing coordinates are outside the BNG
            index system extent.
    """
    if not (0 <= easting < 700000):
        raise BNGExtentError()
    if not (0 <= northing < 1300000):
        raise BNGExtentError()


def _validate_and_normalise_bbox(
    xmin: int | float, ymin: int | float, xmax: int | float, ymax: int | float
) -> tuple[int | float, int | float, int | float, int | float]:
    """Validates and normalises bounding box coordinates to the BNG index system extent.

    Args:
        xmin (int | float): The minimum easting coordinate of the bounding box.
        ymin (int | float): The minimum northing coordinate of the bounding box.
        xmax (int | float): The maximum easting coordinate of the bounding box.
        ymax (int | float): The maximum northing coordinate of the bounding box.

    Returns:
        tuple[float, float, float, float]: The normalised bounding box coordinates.
    """
    # Initialise list to store warning messages
    messages = []

    # Normalise xmin and ymin to 0 if xmin < 0 or ymin < 0
    if xmin < 0:
        messages.append(f"xmin < 0, normalising {xmin} to 0")
        xmin = 0
    if ymin < 0:
        messages.append(f"ymin < 0, normalising {ymin} to 0")
        ymin = 0

    # Normalise xmax to 700000 if xmax > 700000
    if xmax > 700000:
        messages.append(f"xmax > 700000, normalising {xmax} to 700000")
        xmax = 700000

    # Normalise ymax to 1300000 if ymax > 1300000
    if ymax > 1300000:
        messages.append(f"ymax > 1300000, normalising {ymax} to 1300000")
        ymax = 1300000

    if messages:
        warnings.warn(
            "Bounding box coordinates fall outside of the BNG index system extent: \n"
            + "\n".join(messages)
        )

    return xmin, ymin, xmax, ymax


def _get_bng_suffix(
    easting: int | float, northing: int | float, resolution: int
) -> str:
    """Gets the BNG ordinal suffix given coordinates and a quadtree resolution.

    Args:
        easting (int | float): Easting coordinate.
        northing (int | float): Northing coordinate.
        resolution (int): Resolution expressed as a metre-based integer. Must be an
            intermediate quadtree resolution e.g. 5, 50, 500, 5000, 50000.

    Returns:
        str: The BNG ordinal direction suffix.

    Examples:
        >>> _get_bng_suffix(437289, 115541, 5000)
        'NE'
    """
    # Normalise easting and northing coordinates
    # Calculate the fractional part of the normalised easting and northing coordinates
    # Round fractional part to the nearest integer (0 or 1)
    suffix_x = 1 if ((easting % 100000) / (resolution * 2) % 1) >= 0.5 else 0
    suffix_y = 1 if ((northing % 100000) / (resolution * 2) % 1) >= 0.5 else 0

    # Return the suffix from the lookup using quadtree positional index
    return SUFFIXES[suffix_x, suffix_y]


def _decompose_geom(geom: Geometry) -> list[Geometry]:
    """Recursively decompose a Shapely Geometry into its constituent parts.

    Args:
        geom (Geometry): Shapely Geometry object.

    Returns:
        list[Geometry]: List of Shapely Geometry object parts.

    Raises:
        ValueError: If the geometry type is not supported.
    """
    # Single part geometries are returned as-is
    if geom.geom_type in ["Point", "LineString", "Polygon"]:
        return [geom]

    # Multi-part geometries are decomposed into their constituent parts
    elif geom.geom_type in [
        "MultiPoint",
        "MultiLineString",
        "MultiPolygon",
        "GeometryCollection",
    ]:
        # Initialise list to store decomposed geometries
        geometries = []
        # Recursively decompose each part of the multi-part geometry
        for part in geom.geoms:
            geometries.extend(_decompose_geom(part))
        return geometries

    # Raise an error if the geometry type is not supported
    else:
        raise ValueError(f"Unsupported geometry type: {geom.geom_type}")


def xy_to_bng(
    easting: int | float, northing: int | float, resolution: int | str
) -> BNGReference:
    """Returns a ``BNGReference`` given easting and northing coordinates and resolution.

    Args:
        easting (int | float): The easting coordinate.
        northing (int | float): The northing coordinate.
        resolution (int | str): The BNG resolution expressed either as a metre-based
            integer or as a string label.

    Returns:
        BNGReference: The :class:`~osbng.bng_reference.BNGReference`.

    Raises:
        BNGResolutionError: If an invalid resolution is provided.
        BNGExtentError: If the easting and northing coordinates are outside the BNG
            index system extent.

    Examples:
        >>> xy_to_bng(437289, 115541, "100km")
        BNGReference(bng_ref_formatted=SU, resolution_label=100km)
        >>> xy_to_bng(437289, 115541, "10km")
        BNGReference(bng_ref_formatted=SU 3 1, resolution_label=10km)
        >>> xy_to_bng(437289, 115541, "5km")
        BNGReference(bng_ref_formatted=SU 3 1 NE, resolution_label=5km)
        >>> xy_to_bng(437289, 115541, 1)
        BNGReference(bng_ref_formatted=SU 37289 15541, resolution_label=1m)

    See Also:
        The :func:`~osbng.indexing.bng_to_xy` function and
        :meth:`osbng.bng_reference.BNGReference.bng_to_xy` instance method for
        decoding a :class:`~osbng.bng_reference.BNGReference` to easting and northing
        coordinates.
    """
    # Validate and normalise the resolution to its metre-based integer value
    validated_resolution = _validate_and_normalise_bng_resolution(resolution)
    # Validate the easting and northing coordinates are within the BNG extent
    _validate_easting_northing(easting, northing)

    # Calculate prefix positional indices
    prefix_x = int(easting // 100000)
    prefix_y = int(northing // 100000)

    # Return the prefix from the lookup using positional indices
    prefix = PREFIXES[prefix_y][prefix_x]

    # Calculate scaled resolution for quadtree resolutions
    if BNG_RESOLUTIONS[validated_resolution]["quadtree"]:
        scaled_resolution = validated_resolution * 2
        # Get BNG ordinal suffix
        suffix = _get_bng_suffix(easting, northing, validated_resolution)
    else:
        # For non-quadtree (standard) resolutions,
        # the scaled resolution is the same as the resolution
        scaled_resolution = validated_resolution
        # No suffix for non-quadtree resolutions
        suffix = ""

    # Calculate easting and northing bins
    easting_bin = int(easting % 100000 // scaled_resolution)
    northing_bin = int(northing % 100000 // scaled_resolution)

    # Padding length for variable easting and northing bin length
    pad_length = 6 - len(str(scaled_resolution))

    # Pad easting and northing bins
    easting_bin = str(easting_bin).zfill(pad_length)
    northing_bin = str(northing_bin).zfill(pad_length)

    # Construct BNG reference for all resolutions less than 50km
    if validated_resolution < 50000:
        return BNGReference(f"{prefix}{easting_bin}{northing_bin}{suffix}")
    # BNG reference for 50km resolution consists of both prefix and suffix
    elif validated_resolution == 50000:
        return BNGReference(f"{prefix}{suffix}")
    # BNG reference for 100km resolution consist of the prefix only
    elif validated_resolution == 100000:
        return BNGReference(prefix)


@_validate_bngreferences
def bng_to_xy(
    bng_ref: BNGReference, *, position: str = "lower-left"
) -> tuple[int | float, int | float]:
    """Returns easting and northing coordinates given a ``BNGReference``.

    An optional grid square ``position`` keyword argument can be specified to return
    the coordinates of a specific corner or the centre of the grid square.

    Args:
        bng_ref (BNGReference): The :class:`~osbng.bng_reference.BNGReference`.

    Keyword Args:
        position (str, optional): The grid square position expressed as a string.
            One of: 'lower-left', 'upper-left', 'upper-right', 'lower-right', 'centre'.

    Returns:
        tuple[int | float, int | float]: Easting and northing coordinates as a tuple.

    Raises:
        BNGReferenceError: If the first positional argument is not a
            :class:`~osbng.bng_reference.BNGReference`.
        TypeError: If the first argument is not a
            :class:`~osbng.bng_reference.BNGReference`.
        ValueError: If an invalid position provided.

    Examples:
        >>> bng_to_xy(BNGReference("SU"), position="lower-left")
        (400000, 100000)
        >>> bng_to_xy(BNGReference("SU 3 1"), position="lower-left")
        (430000, 110000)
        >>> bng_to_xy(BNGReference("SU 3 1 NE"), position="centre")
        (437500, 117500)
        >>> bng_to_xy(BNGReference("SU 37289 15541"), position="centre")
        (437289.5, 115541.5)

    See Also:
        - The equivalent :meth:`osbng.bng_reference.BNGReference.bng_to_xy` instance
          method.
        - The :func:`~osbng.indexing.xy_to_bng` function for encoding a easting and
          northing coordinates to a :class:`~osbng.BNGReference` at a given resolution.
    """
    # validate position string
    valid_positions = [
        "lower-left",
        "upper-left",
        "upper-right",
        "lower-right",
        "centre",
    ]

    if position not in valid_positions:
        raise ValueError(
            "Invalid position provided. Supported positions are: "
            f"{', '.join(p for p in valid_positions)}"
        )

    # Extract resolution in metres from BNG reference
    resolution = bng_ref.resolution_metres

    # Get the pattern match for the BNG reference in the compact format
    match = _PATTERN.match(bng_ref.bng_ref_compact)
    # Extract prefix, numerical component and suffix of the BNG reference
    prefix = match.group(1)
    en_components = match.group(2)
    suffix = match.group(3)

    # Get the prefix indices from prefix position in PREFIXES array
    prefix_indices = np.argwhere(PREFIXES == prefix)[0]

    # Convert the prefix indices to easting and northing
    # coordinates of 100km grid square
    prefix_easting = int(prefix_indices[1] * 100000)
    prefix_northing = int(prefix_indices[0] * 100000)

    # For quadtree resolutions, scale the resolution value by 2
    if BNG_RESOLUTIONS[resolution]["quadtree"]:
        scaled_resolution = resolution * 2

    # For non-quadtree (standard) resolutions, the scaled resolution
    # is the same as the resolution
    else:
        scaled_resolution = resolution

    # Generate the offset values from the en_components
    if en_components:
        length = len(en_components)
        easting_offset = int(en_components[: length // 2]) * scaled_resolution
        northing_offset = int(en_components[length // 2 :]) * scaled_resolution

    # For 100km grid square, no additional offset is required
    else:
        easting_offset = 0
        northing_offset = 0

    # Generate the suffix values from the position in the SUFFIXES array
    if suffix:
        suffix_indices = np.argwhere(SUFFIXES == suffix)[0]
        # Convert the suffix indices to coordinate values
        # by multiplying by the resolution
        suffix_easting = int(suffix_indices[0] * resolution)
        suffix_northing = int(suffix_indices[1] * resolution)

    # For standard resolutions, no suffix easting or northing is required
    else:
        suffix_easting = 0
        suffix_northing = 0

    # Compile the easting and northing values of the lower-left corner of the grid cell
    easting = prefix_easting + easting_offset + suffix_easting
    northing = prefix_northing + northing_offset + suffix_northing

    # If position is lower-left, coordinates remain unchanged
    if position == "lower-left":
        return easting, northing
    # If position is upper-left, add resolution value to northing
    elif position == "upper-left":
        return easting, northing + resolution
    # If position is upper-right, add resolution value to easting and northing
    elif position == "upper-right":
        return easting + resolution, northing + resolution
    # If postion is lower-right, add resolution value to easting
    elif position == "lower-right":
        return easting + resolution, northing
    # If position is centre, add half the resolution value to easting and northing
    elif position == "centre":
        easting = easting + (resolution / 2)
        northing = northing + (resolution / 2)
        # Cast as integer type if fractional part is equal to .0
        if easting % 1 == 0:
            easting = int(easting)
        if northing % 1 == 0:
            northing = int(northing)
        return easting, northing


@_validate_bngreferences
def bng_to_bbox(bng_ref: BNGReference) -> tuple[int, int, int, int]:
    """Returns grid square bounding box coordinates given a ``BNGReference``.

    Args:
        bng_ref (BNGReference): The :class:`~osbng.bng_reference.BNGReference`.

    Returns:
        tuple[int, int, int, int]: The grid square bounding box coordinates as a tuple.

    Raises:
        TypeError: If first argument is not a
            :class:`~osbng.bng_reference.BNGReference`.

    Example:
        >>> bng_to_bbox(BNGReference("SU"))
        (400000, 100000, 500000, 200000)
        >>> bng_to_bbox(BNGReference("SU 3 1"))
        (430000, 110000, 440000, 120000)
        >>> bng_to_bbox(BNGReference("SU 3 1 NE"))
        (435000, 115000, 440000, 120000)
        >>> bng_to_bbox(BNGReference("SU 37289 15541"))
        (437289, 115541, 437290, 115542)

    See Also:
        - The equivalent :meth:`osbng.bng_reference.BNGReference.bng_to_bbox` instance
          method.
        - The :func:`~osbng.indexing.bng_to_grid_geom` function and
          :meth:`~osbng.bng_reference.BNGReference.bng_to_grid_geom` instance method
          which convert a :class:`~osbng.bng_reference.BNGReference` to a
          ``Shapely Polygon``.
    """
    # Extract lower left and upper right coordinates of grid square
    min_xy = bng_to_xy(bng_ref, position="lower-left")
    max_xy = bng_to_xy(bng_ref, position="upper-right")

    return min_xy + max_xy


@_validate_bngreferences
def bng_to_grid_geom(bng_ref: BNGReference) -> Polygon:
    """Returns a grid square as a ``Shapely Polygon`` given a ``BNGReference``.

    Args:
        bng_ref (BNGReference): The :class:`~osbng.bng_reference.BNGReference`.

    Returns:
        Polygon: Grid square as a ``Shapely Polygon`` object.

    Raises:
        BNGReferenceError: If the first positional argument is not a
            :class:`~osbng.bng_reference.BNGReference`.
        TypeError: If first argument is not a
            :class:`~osbng.bng_reference.BNGReference`.

    Examples:
        >>> bng_to_grid_geom(BNGReference("SU")).wkt
        (
        'POLYGON ((500000 100000, 500000 200000, 400000 200000, 400000 100000, '
        '500000 100000))'
        )
        >>> bng_to_grid_geom(BNGReference("SU 3 1")).wkt
        (
        'POLYGON ((440000 110000, 440000 120000, 430000 120000, 430000 110000, '
        '440000 110000))'
        )
        >>> bng_to_grid_geom(BNGReference("SU 3 1 NE")).wkt
        (
        'POLYGON ((440000 115000, 440000 120000, 435000 120000, 435000 115000, '
        '440000 115000))'
        )
        >>> bng_to_grid_geom(BNGReference("SU 37289 15541")).wkt
        (
        'POLYGON ((437290 115541, 437290 115542, 437289 115542, 437289 115541, '
        '437290 115541))'
        )

    See Also:
        - The equivalent :meth:`osbng.bng_reference.BNGReference.bng_to_grid_geom`
          instance method.
        - The :func:`~osbng.indexing.bng_to_bbox` function and
          :meth:`osbng.bng_reference.BNGReference.bng_to_bbox` instance method which
          convert a :class:`~osbng.bng_reference.BNGReference` to bounding box
          coordinates.
    """
    return box(*bng_to_bbox(bng_ref))


def bbox_to_bng(
    xmin: int | float,
    ymin: int | float,
    xmax: int | float,
    ymax: int | float,
    resolution: int | str,
) -> list[BNGReference]:
    """Returns a ``BNGReference`` list given a bounding box and resolution.

    Validates and normalises the bounding box (BBOX) coordinates to the BNG index system
    extent. If BBOX coordinates fall outside of the BNG index system extent,
    then a warning is raised and the coordinates are snapped to the bounds of the BNG
    index system.

    Notes:
        The relationship between the BBOX and the returned grid squares
        depends on the alignment of the BBOX with the BNG index system:

        - **BNG Aligned**: If the BBOX edges align with the BNG index system (xmin,
          ymin, xmax, ymax are multiples of the specified resolution), only the grid
          squares entirely contained within the BBOX are returned. Grid squares that
          intersect but are not fully contained within the BBOX are excluded.

        - **Non-BNG Aligned**: If the BBOX edges are not aligned with the BNG index
          system, grid squares that are partially overlapped by the BBOX are also
          included. In this case, the function ensures all relevant grid squares that
          the BBOX touches are returned, including those at the edges.

    Args:
        xmin (int | float): The minimum easting coordinate of the BBOX.
        ymin (int | float): The minimum northing coordinate of the BBOX.
        xmax (int | float): The maximum easting coordinate of the BBOX.
        ymax (int | float): The maximum northing coordinate of the BBOX.
        resolution (int | str): The BNG resolution expressed either as a metre-based
            integer or as a string label.

    Returns:
        list[BNGReference]: :class:`~osbng.bng_reference.BNGReference` list.

    Raises:
        BNGResolutionError: If an invalid resolution is provided.

    Examples:
        >>> bbox_to_bng(400000, 100000, 500000, 200000, "50km")
        [BNGReference(bng_ref_formatted=SU SW, resolution_label=50km),
         BNGReference(bng_ref_formatted=SU SE, resolution_label=50km),
         BNGReference(bng_ref_formatted=SU NW, resolution_label=50km),
         BNGReference(bng_ref_formatted=SU NE, resolution_label=50km)]
        >>> bbox_to_bng(285137.06, 78633.75, 299851.01, 86427.96, 5000)
        [BNGReference(bng_ref_formatted=SX 8 7 NE, resolution_label=5km),
         BNGReference(bng_ref_formatted=SX 9 7 NW, resolution_label=5km),
         BNGReference(bng_ref_formatted=SX 9 7 NE, resolution_label=5km),
         BNGReference(bng_ref_formatted=SX 8 8 SE, resolution_label=5km),
         BNGReference(bng_ref_formatted=SX 9 8 SW, resolution_label=5km),
         BNGReference(bng_ref_formatted=SX 9 8 SE, resolution_label=5km),
         BNGReference(bng_ref_formatted=SX 8 8 NE, resolution_label=5km),
         BNGReference(bng_ref_formatted=SX 9 8 NW, resolution_label=5km),
         BNGReference(bng_ref_formatted=SX 9 8 NE, resolution_label=5km)]

    See Also:
        The :func:`~osbng.grids.bbox_to_bng_iterfeatures` function which returns an
        iterator of :class:`~osbng.bng_reference.BNGReference` ``Features`` given a
        bounding box and resolution.
    """
    # Validate and normalise the resolution to its metre-based integer value
    validated_resolution = _validate_and_normalise_bng_resolution(resolution)

    # Validate and normalise bounding box coordinates to the BNG index system extent
    xmin, ymin, xmax, ymax = _validate_and_normalise_bbox(xmin, ymin, xmax, ymax)

    # Snap the maximum easting and maximum northing coordinates
    # to an integer multiple of resolution
    xmax_snapped = int(np.ceil(xmax / validated_resolution) * validated_resolution)
    ymax_snapped = int(np.ceil(ymax / validated_resolution) * validated_resolution)

    # Generate a grid of easting and northing coordinates
    eastings = np.arange(xmin, xmax_snapped, validated_resolution)
    northings = np.arange(ymin, ymax_snapped, validated_resolution)

    # For vertical or horizontal lines which exactly align with the grid boundaries,
    # the above returns an empty list, so ensure that at least one element
    # is in the eastings and northings
    eastings = eastings if len(eastings) > 0 else np.array([xmax_snapped])
    northings = northings if len(northings) > 0 else np.array([ymax_snapped])

    easting_grid, northing_grid = np.meshgrid(eastings, northings)

    # Flatten grids for iteration
    easting_grid = easting_grid.ravel()
    northing_grid = northing_grid.ravel()

    # Convert grid coordinates to BNGReference objects
    bng_refs = [
        xy_to_bng(easting, northing, validated_resolution)
        for easting, northing in zip(easting_grid, northing_grid)
    ]

    return bng_refs


def geom_to_bng(geom: Geometry, resolution: int | str) -> list[BNGReference]:
    """Returns a ``BNGReference`` list given a ``Shapely Geometry`` and resolution.

    The :class:`~osbng.bng_reference.BNGReference` list returned represents the grid
    squares intersected by the input geometry.
    :class:`~osbng.bng_reference.BNGReference` objects are deduplicated in cases where
    two or more parts of a multi-part geometry intersect the same grid square.

    This function is useful for spatial indexing and aggregation of geometries against
    the BNG index system.

    Notes:
        A note on the type of the input geometry. This also applies to the parts within
        a multi-part geometry:

        - For ``Point`` geometries, the function returns a list comprising a single
          :class:`~osbng.bng_reference.BNGReference`. A
          :class:`~osbng.errors.BNGExtentError` exception is raised if the coordinates
          fall outside of the BNG index system extent.
        - For ``LineString`` and ``Polygon`` geometry types, the function returns a
          :class:`~osbng.bng_reference.BNGReference` list representing the grid squares
          intersected by the geometry. When a geometry extends beyond the BNG index
          system extent, the function will show a feature bounding box warning but will
          still return a :class:`~osbng.bng_reference.BNGReference` for each of the
          intersected grid squares within the BNG index system extent.

    Args:
        geom (Geometry): ``Shapely`` Geometry.
        resolution (int | str): The BNG resolution expressed either as a metre-based
            integer or as a string label.

    Returns:
        list[BNGReference]: :class:`~osbng.bng_reference.BNGReference` list.

    Raises:
        BNGResolutionError: If an invalid resolution is provided.
        ValueError: If the geometry type is not supported.
        BNGExtentError: If the coordinates of a ``Point`` geometry are outside of the
            BNG index system extent.

    Examples:
        >>> geom_to_bng(Point(430000, 110000), "100km")
        [BNGReference(bng_ref_formatted=SU, resolution_label=100km)]
        >>> geom_to_bng(
        ...     LineString([[430000, 110000], [430010, 110000], [430010, 110010]]), "5m"
        ... )
        [BNGReference(bng_ref_formatted=SU 3000 1000 SE, resolution_label=5m),
         BNGReference(bng_ref_formatted=SU 3000 1000 SW, resolution_label=5m),
         BNGReference(bng_ref_formatted=SU 3000 1000 NE, resolution_label=5m)]

    See Also:
        For geometry decomposition by the BNG index system, use
        :func:`~osbng.indexing.geom_to_bng_intersection` or
        :func:`~osbng.indexing_gpd.gdf_to_bng_intersection_explode` instead.
    """
    # Validate and normalise the resolution to its metre-based integer value
    validated_resolution = _validate_and_normalise_bng_resolution(resolution)

    # Initialise an empty list to store the BNGReference objects
    bng_refs = []

    # Recursively decompose geometry into its constituent parts
    for part in _decompose_geom(geom):
        if part.geom_type == "Point":
            # Convert the Point to BNGReference object and append to bng_refs list
            bng_refs.append(xy_to_bng(part.x, part.y, validated_resolution))

        # All other geometry types
        else:
            # Get the bounding box of the geometry part
            bbox = part.bounds
            # Convert the bounding box to BNGReference objects
            _bng_refs = np.array(bbox_to_bng(*bbox, validated_resolution))
            # Get the grid square geometries of the BNGReference objects
            bng_geoms = np.array([bng_to_grid_geom(bng_ref) for bng_ref in _bng_refs])
            # Prepare the geometry part to speed up intersects spatial predicate tests
            prepare(part)
            # Test where the geometry part intersects the grid square geometries
            bng_bool = intersects(part, bng_geoms)
            # Append the intersected BNGReference objects to the bng_refs list
            bng_refs.extend(_bng_refs[bng_bool].tolist())

    # Deduplicate the list of BNGReference objects
    return list(set(bng_refs))


def geom_to_bng_intersection(
    geom: Geometry, resolution: int | str
) -> list[BNGIndexedGeometry]:
    """Returns a ``BNGIndexedGeometry`` list given a Shapely Geometry and resolution.

    Decomposes a ``Shapely Geometry`` into grid squares at a specified resolution.

    Unlike :func:`~osbng.indexing.geom_to_bng` which only returns
    :class:`osbng.bng_reference.BNGReference` objects representing the grid squares
    intersected by the input geometry, ``geom_to_bng_intersection`` returns
    :class:`BNGIndexedGeometry` objects that store the intersection between the input
    geometry and the grid square geometries.

    This is particularly useful for spatial indexing, aggregation and visualisation
    use cases that requires the decomposition of geometries into their constituent
    parts bounded by the BNG index system.

    Notes:
        A note on the type of the input geometry. This also applies to the parts within
        a multi-part geometry:

        - For ``Point`` geometries, the function returns a list comprising a single
          :class:`~osbng.indexing.BNGIndexedGeometry` object. A
          :class:`~osbng.errors.BNGExtentError` exception is raised if the coordinates
          are outside of the BNG index system extent.

        - For ``LineString`` and ``Polygon`` geometry types, the function returns a
          list of :class:`~osbng.indexing.BNGIndexedGeometry` objects representing the
          intersections between the grid squares and the geometry. When the geometry
          extends beyond the BNG index system extent, the function will show a feature
          bounding box warning but will still return the
          :class:`~osbng.indexing.BNGIndexedGeometry` objects for the intersected grid
          squares.

    Args:
        geom (Geometry): ``Shapely Geometry`` object.
        resolution (int | str): The BNG resolution expressed either as a metre-based
            integer or as a string label.

    Returns:
        list[BNGIndexedGeometry]: List of :class:`~osbng.indexing.BNGIndexedGeometry`.

    Raises:
        BNGResolutionError: If an invalid resolution is provided.
        ValueError: If the geometry type is not supported.
        BNGExtentError: If the coordinates of a ``Point`` geometry are outside of the
            BNG index system extent.

    Examples:
        >>> from shapely.geometry import Point
        >>> geom_to_bng_intersection(Point(430000, 110000), "100km")
        [
            BNGIndexedGeometry(
                bng_ref=BNGReference(bng_ref_formatted=SU, resolution_label=100km),
                is_core=False,
                geom=POINT (430000 110000)
            )
        ]
        >>> from shapely.geometry import LineString
        >>> geom_to_bng_intersection(
        ...     LineString([[430000, 110000], [430010, 110000], [430010, 110010]]), "5m"
        ... )
        [
            BNGIndexedGeometry(
                bng_ref=BNGReference(
                            bng_ref_formatted=SU 3000 1000 SE,
                            resolution_label=5m
                        ),
                is_core=False,
                geom=MULTILINESTRING (
                     (430005 110000, 430010 110000),
                     (430010 110000, 430010 110005)
                )
            ),
            BNGIndexedGeometry(
                bng_ref=BNGReference(
                            bng_ref_formatted=SU 3000 1000 SW,
                            resolution_label=5m
                ),
                is_core=False,
                geom=LINESTRING (430000 110000, 430005 110000)
            ),
            BNGIndexedGeometry(
                bng_ref=BNGReference(
                            bng_ref_formatted=SU 3000 1000 NE,
                            resolution_label=5m
                ),
                is_core=False,
                geom=LINESTRING (430010 110005, 430010 110010))
        ]
        >>> from shapely import wkt
        >>> geom_to_bng_intersection(
        ...     wkt.loads(
        ...         "POLYGON ("
        ...         "(375480.64511692 144999.23691181, 426949.67604058 160255.02751493,"
        ...         " 465166.20199588 153320.57724078, 453762.88376729 94454.79935802,"
        ...         " 393510.2158297 91989.21703833, 375480.64511692 144999.23691181)"
        ...         ")"
        ...     ),
        ...     "50km",
        ... )
        [
            BNGIndexedGeometry(
                bng_ref=BNGReference(bng_ref_formatted=SU SW,
                                     resolution_label=50km
                ),
                is_core=True,
                geom=POLYGON (
                     (450000 100000, 450000 150000, 400000 150000,
                      400000 100000, 450000 100000)
                )
            ),
            BNGIndexedGeometry(
                bng_ref=BNGReference(bng_ref_formatted=ST NE,
                                     resolution_label=50km
                ),
                is_core=False,
                geom=POLYGON (
                     (400000 152266.94988613573, 400000 150000,
                      392351.90644475375 150000, 400000 152266.94988613573)
                )
            ),
            BNGIndexedGeometry(
                bng_ref=BNGReference(bng_ref_formatted=ST SE,
                                     resolution_label=50km
                ),
                is_core=False,
                geom=POLYGON (
                     (392351.90644475375 150000, 400000 150000, 400000 100000,
                      390785.6181363417 100000, 375480.64511692 144999.23691181,
                      392351.90644475375 150000)
                )
            ),
            BNGIndexedGeometry(
                bng_ref=BNGReference(bng_ref_formatted=SZ NW,
                                     resolution_label=50km
                ),
                is_core=False,
                geom=POLYGON (
                     (400000 92254.78365399371, 400000 100000, 450000 100000,
                      450000 94300.8194596147, 400000 92254.78365399371)
                )
            ),
            BNGIndexedGeometry(
                bng_ref=BNGReference(bng_ref_formatted=SZ NE,
                                     resolution_label=50km
                ),
                is_core=False,
                geom=POLYGON (
                     (453762.88376729 94454.79935802, 450000 94300.8194596147,
                      450000 100000, 454837.0849387723 100000,
                      453762.88376729 94454.79935802)
                )
            ),
            BNGIndexedGeometry(
                bng_ref=BNGReference(bng_ref_formatted=SU SE,
                                     resolution_label=50km
                ),
                is_core=False,
                geom=POLYGON (
                     (454837.0849387723 100000, 450000 100000, 450000 150000,
                      464522.9488131115 150000, 454837.0849387723 100000)
                )
            ),
            BNGIndexedGeometry(
                bng_ref=BNGReference(bng_ref_formatted=SU NE,
                                     resolution_label=50km
                ),
                is_core=False,
                geom=POLYGON (
                     (465166.20199588 153320.57724078, 464522.9488131115 150000,
                      450000 150000, 450000 156072.50905454965,
                      465166.20199588 153320.57724078)
                )
            ),
            BNGIndexedGeometry(
                bng_ref=BNGReference(bng_ref_formatted=SU NW,
                                     resolution_label=50km
                ),
                is_core=False,
                geom=POLYGON (
                     (426949.67604058 160255.02751493, 450000 156072.50905454965,
                      450000 150000, 400000 150000, 400000 152266.94988613573,
                      426949.67604058 160255.02751493)
                )
            ),
            BNGIndexedGeometry(
                bng_ref=BNGReference(bng_ref_formatted=SY NE,
                                     resolution_label=50km
                ),
                is_core=False,
                geom=POLYGON (
                     (393510.2158297 91989.21703833, 390785.6181363417 100000,
                      400000 100000, 400000 92254.78365399371,
                      393510.2158297 91989.21703833)
                )
            )
        ]

    See Also:
        - The :func:`~osbng.indexing_gpd.gdf_to_bng_intersection_explode` function
          function supporting the indexing  of geometries in a
          ``GeoPandas GeoDataFrame`` against the BNG index system.
        - The :func:`~osbng.indexing.geom_to_bng` function if geometry decomposition is
          not required and a list of :class:`~osbng.bng_reference.BNGReference` is
          sufficient.
    """
    # Initialise an empty list to store the BNGIndexedGeometry objects
    bng_idx_geoms = []

    # Recursively decompose geometry into its constituent parts
    for part in _decompose_geom(geom):
        # Convert the geometry part to BNGReference objects
        bng_refs = np.array(geom_to_bng(part, resolution))

        if part.geom_type == "Point":
            # Convert the Point to a BNGIndexedGeometry object
            # and append to bng_idx_geoms list
            bng_idx_geoms.append(BNGIndexedGeometry(bng_refs[0], False, part))

        elif part.geom_type == "LineString":
            # Get the grid square geometries of the BNGReference objects
            bng_geoms = np.array([bng_to_grid_geom(bng_ref) for bng_ref in bng_refs])
            # Prepare the geometry part to speed up intersects spatial predicate tests
            prepare(part)
            # Derive the intersections between the geometry part
            # and the grid square geometries
            intersections = intersection(part, bng_geoms)
            # Derive BNGIndexedGeometry objects for geometry part
            # and add to the bng_idx_geoms list
            bng_idx_geoms.extend(
                [
                    BNGIndexedGeometry(bng_ref, False, geometry)
                    for bng_ref, geometry in zip(bng_refs, intersections)
                ]
            )

        elif part.geom_type == "Polygon":
            # Get the grid square geometries of the BNGReference objects
            bng_geoms = np.array([bng_to_grid_geom(bng_ref) for bng_ref in bng_refs])
            # Prepare the geometry part to speed up contains spatial predicate tests
            prepare(part)
            # Test whether grid square geometries are contained by the geometry part
            bng_bool = contains(part, bng_geoms)
            # Subset bng_refs array based on positive containment
            core = bng_refs[bng_bool]
            # Subset bng_refs array based on negative containment
            edge = bng_refs[~bng_bool]
            # Derive BNGIndexedGeometry objects for core cases
            # and add to the bng_idx_geoms list
            bng_idx_geom_core = [
                BNGIndexedGeometry(bng_ref, True, bng_ref.bng_to_grid_geom())
                for bng_ref in core
            ]
            # Derive the intersection between the part geometry
            # and the 'edge' grid square geometries
            intersections = intersection(part, bng_geoms[~bng_bool])
            # Derive BNGIndexedGeometry objects for 'edge' grid squares
            # and add to the bng_idx_geoms list
            bng_idx_geom_edge = [
                BNGIndexedGeometry(bng_ref, False, geometry)
                for bng_ref, geometry in zip(edge, intersections)
            ]
            # Combine the core and edge BNGIndexedGeometry objects
            bng_idx_geoms.extend(bng_idx_geom_core + bng_idx_geom_edge)

    return bng_idx_geoms
