from __future__ import annotations

from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Optional, Tuple

import numpy as np

try:
    from shapely import points as _sh_points, contains as _sh_contains, prepare as _sh_prepare
    _HAVE_SHP2 = True
except ImportError:
    _HAVE_SHP2 = False
    _sh_points = _sh_contains = _sh_prepare = None

try:
    from shapely.prepared import prep as _sh1_prep
except ImportError:
    _sh1_prep = None


def _as_rng(rng: Optional[np.random.Generator]) -> np.random.Generator:
    """Return a NumPy Generator; accept None or a seed."""
    return rng if isinstance(rng, np.random.Generator) else np.random.default_rng(rng)

def _require_scipy(feature: str) -> None:
    """Guard optional SciPy features."""
    try:
        import scipy  # noqa: F401
    except Exception as exc:
        raise RuntimeError(
            f"{feature} requires SciPy. Install with `pip install pyspat[fast]`."
        ) from exc

def _require_shapely(feature: str) -> None:
    """Guard optional shapely features."""
    try:
        import shapely  # noqa: F401
    except Exception as exc:
        raise RuntimeError(f"{feature} requires Shapely. Install with `pip install shapely`.") from exc


@dataclass(frozen=True)
class Image:
    """
    Minimal gridded image representation.

    origin: (x0, y0) world coordinate of pixel (0, 0) corner (lower-left).
    spacing: (sx, sy) pixel spacing in world units.
    values: (ny, nx) scalar at pixel centres.
    """
    origin: Tuple[float, float]
    spacing: Tuple[float, float]
    values: np.ndarray

    @property
    def shape(self) -> Tuple[int, int]:
        return self.values.shape


class Window(ABC):
    """Abstract window type - geometry only."""
    unit: str = "1"

    @abstractmethod
    def area(self) -> float:
        """Geometric area in squared world units."""

    @abstractmethod
    def contains(self, xy: np.ndarray) -> np.ndarray:
        """Vectorised membership test for points (n, 2) -> (n,) boolean."""

    @abstractmethod
    def sample_uniform(self, n: int, rng: Optional[np.random.Generator] = None) -> np.ndarray:
        """Draw `n` points uniformly from the window (world coordinates)."""

    @abstractmethod
    def erode(self, r: float) -> "Window":
        """Minkowski erosion by radius r, same concrete type."""

    @abstractmethod
    def distance_to_boundary(self) -> Image:
        """Image of distance (at pixel centres) to the nearest boundary inside the window."""

    @abstractmethod
    def rescale(self, factor: float) -> "Window":
        """Return a geometrically scaled copy of the window."""

    def overlap_fraction(self, shift: Tuple[float, float]) -> float:
        """
        Approximate |W ∩ (W + shift)| / |W|.
        Base fallback: crude Monte Carlo. Subclasses should override where possible.
        """
        rng = np.random.default_rng(12345)
        xy = self.sample_uniform(25_000, rng=rng)
        moved = xy + np.asarray(shift, float)
        return float(np.mean(self.contains(moved)))


class MaskWindow(Window):
    """
    Window defined by a binary mask on a regular grid.

    mask[yi, xi] == True -> that pixel is inside the window.
    World coordinates map to pixel index i = floor((x-x0)/sx), j = floor((y-y0)/sy).
    A point is inside if it falls into a True pixel; left/bottom inclusive, right/top exclusive.
    """

    def __init__(
        self,
        mask: np.ndarray,
        origin: Tuple[float, float] = (0.0, 0.0),
        spacing: Tuple[float, float] = (1.0, 1.0),
        unit: str = "1",
    ) -> None:
        m = np.asarray(mask, dtype=bool)
        if m.ndim != 2 or m.size == 0:
            raise ValueError("Mask must be a non-empty 2D array")
        self._mask = m
        self._origin = (float(origin[0]), float(origin[1]))
        self._spacing = (float(spacing[0]), float(spacing[1]))
        self._unit = unit
        # Cached EDT image (Image). Safe as geometry is immutable.
        self._dtb_cache: Optional[Image] = None

    @property
    def mask(self) -> np.ndarray:
        return self._mask

    @property
    def origin(self) -> Tuple[float, float]:
        return self._origin

    @property
    def spacing(self) -> Tuple[float, float]:
        return self._spacing

    @property
    def shape(self) -> Tuple[int, int]:
        return self._mask.shape

    def area(self) -> float:
        sx, sy = self.spacing
        return float(np.count_nonzero(self._mask)) * sx * sy

    def contains(self, xy: np.ndarray) -> np.ndarray:
        xy = np.asarray(xy, dtype=float)
        if xy.ndim != 2 or xy.shape[1] != 2:
            raise ValueError("xy must have shape (n, 2)")
        x0, y0 = self._origin
        sx, sy = self._spacing
        ix = np.floor((xy[:, 0] - x0) / sx).astype(int)
        iy = np.floor((xy[:, 1] - y0) / sy).astype(int)
        inside_bounds = (
            (ix >= 0) & (iy >= 0) &
            (ix < self._mask.shape[1]) & (iy < self._mask.shape[0])
        )
        out = np.zeros(xy.shape[0], dtype=bool)
        if inside_bounds.any():
            out_idx = np.where(inside_bounds)[0]
            out[out_idx] = self._mask[iy[inside_bounds], ix[inside_bounds]]
        return out

    def sample_uniform(self, n: int, rng: Optional[np.random.Generator] = None) -> np.ndarray:
        if n <= 0:
            return np.empty((0, 2), float)
        rng = _as_rng(rng)
        y_idx, x_idx = np.nonzero(self._mask)
        if y_idx.size == 0:
            raise ValueError("Cannot sample from an empty window (mask has no True cells).")
        choose = rng.integers(0, y_idx.size, size=n)
        px = x_idx[choose].astype(float)
        py = y_idx[choose].astype(float)
        jitter = rng.random((n, 2))
        x0, y0 = self._origin
        sx, sy = self._spacing
        xs = x0 + (px + jitter[:, 0]) * sx
        ys = y0 + (py + jitter[:, 1]) * sy
        return np.column_stack([xs, ys])

    def distance_to_boundary(self) -> Image:
        if self._dtb_cache is not None:
            return self._dtb_cache
        _require_scipy("MaskWindow.distance_to_boundary")
        from scipy import ndimage as ndi

        m = self._mask
        ny, nx = m.shape
        mp = np.pad(m, ((1, 1), (1, 1)), mode="constant", constant_values=False)

        sx, sy = self._spacing
        dt_pad, inds = ndi.distance_transform_edt(
            mp, sampling=(sy, sx), return_indices=True
        )

        yy_int, xx_int = np.indices((ny, nx))
        yy_int += 1
        xx_int += 1

        inds_y = inds[0, 1:-1, 1:-1]
        inds_x = inds[1, 1:-1, 1:-1]
        y_changed = inds_y != yy_int
        x_changed = inds_x != xx_int

        corr = np.zeros((ny, nx), dtype=float)
        corr[x_changed & ~y_changed] = sx
        corr[~x_changed & y_changed] = sy
        corr[x_changed & y_changed] = (sx * sx + sy * sy) ** 0.5

        dt = dt_pad[1:-1, 1:-1] - corr
        dt[~m] = 0.0
        dt = np.clip(dt, 0.0, None)
        img = Image(origin=self._origin, spacing=self._spacing, values=dt)
        self._dtb_cache = img
        return img

    def erode(self, r: float) -> "MaskWindow":
        if r < 0:
            raise ValueError("Erosion radius must be non-negative.")
        if r == 0:
            return MaskWindow(self._mask.copy(), origin=self._origin, spacing=self._spacing, unit=self._unit)

        dtb = self.distance_to_boundary().values
        eroded = dtb >= float(r)

        if not eroded.any():
            eroded = np.zeros_like(self._mask, bool)
        return MaskWindow(eroded, self._origin, self._spacing, unit=self._unit)

    def overlap_fraction(self, shift: Tuple[float, float]) -> float:
        """Approximate |W ∩ (W + shift)| / |W| with zero-padded integer-pixel shift. No toroidal wrapping."""
        sx, sy = self._spacing
        off_x = int(np.rint(shift[0] / sx))
        off_y = int(np.rint(shift[1] / sy))
        if off_x == 0 and off_y == 0:
            return 1.0
        ny, nx = self._mask.shape

        src_x0 = max(0, -off_x)
        src_x1 = min(nx, nx - off_x)
        dst_x0 = max(0, off_x)
        dst_x1 = min(nx, nx + off_x)

        src_y0 = max(0, -off_y)
        src_y1 = min(ny, ny - off_y)
        dst_y0 = max(0, off_y)
        dst_y1 = min(ny, ny + off_y)

        if src_x0 >= src_x1 or src_y0 >= src_y1:
            return 0.0

        shifted = np.zeros_like(self._mask, bool)
        shifted[dst_y0:dst_y1, dst_x0:dst_x1] = self._mask[src_y0:src_y1, src_x0:src_x1]
        inter = np.count_nonzero(self._mask & shifted)
        denom = np.count_nonzero(self._mask)
        return 0.0 if denom == 0 else float(inter) / float(denom)

    def rescale(self, factor: float) -> "MaskWindow":
        if factor <= 0:
            raise ValueError("Rescaling factor must be positive.")
        ox, oy = self._origin
        sx, sy = self._spacing
        return MaskWindow(
            self._mask.copy(),
            origin=(ox * factor, oy * factor),
            spacing=(sx * factor, sy * factor),
            unit=self._unit,
        )


class RectangleWindow(Window):
    """Axis-aligned rectangle [xmin, xmax) × [ymin, ymax) (right/top exclusive)."""

    def __init__(self, xmin: float, ymin: float, xmax: float, ymax: float, *,
                 unit: str = "1", dtb_shape: Tuple[int, int] = (256, 256)) -> None:
        x0 = float(xmin); y0 = float(ymin); x1 = float(xmax); y1 = float(ymax)
        if not np.isfinite([x0, y0, x1, y1]).all():
            raise ValueError("RectangleWindow requires finite coordinates.")
        if not (x1 > x0):
            raise ValueError("RectangleWindow requires x1 > x0.")
        if not (y1 > y0):
            raise ValueError("RectangleWindow requires y1 > y0.")
        self._xmin, self._ymin, self._xmax, self._ymax = xmin, ymin, xmax, ymax
        self._unit = unit
        self._dtb_shape = (int(dtb_shape[0]), int(dtb_shape[1]))
        self._dtb_cache: Optional[Image] = None

    @property
    def bounds(self) -> Tuple[float, float, float, float]:
        return self._xmin, self._ymin, self._xmax, self._ymax

    def width(self) -> float:
        return self._xmax - self._xmin

    def height(self) -> float:
        return self._ymax - self._ymin

    def area(self) -> float:
        w = self.width(); h = self.height()
        return max(0.0, w) * max(0.0, h)

    def contains(self, xy: np.ndarray) -> np.ndarray:
        xy = np.asarray(xy, float)
        if xy.ndim != 2 or xy.shape[1] != 2:
            raise ValueError("xy must have shape (n, 2)")
        x, y = xy[:, 0], xy[:, 1]
        return (x >= self._xmin) & (y >= self._ymin) & (x < self._xmax) & (y < self._ymax)

    def sample_uniform(self, n: int, rng: Optional[np.random.Generator] = None) -> np.ndarray:
        if n < 0:
            raise ValueError("n must be non-negative.")
        if n == 0:
            return np.empty((0, 2), float)
        g = _as_rng(rng)
        xs = g.random(n) * (self._xmax - self._xmin) + self._xmin
        ys = g.random(n) * (self._ymax - self._ymin) + self._ymin
        return np.column_stack([xs, ys])

    def erode(self, r: float) -> "RectangleWindow":
        if r < 0:
            raise ValueError("Erosion radius must be non-negative.")
        if r == 0:
            return RectangleWindow(self._xmin, self._ymin, self._xmax, self._ymax,
                                   unit=self._unit, dtb_shape=self._dtb_shape)

        nx0 = self._xmin + r
        ny0 = self._ymin + r
        nx1 = self._xmax - r
        ny1 = self._ymax - r

        if nx1 <= nx0 or ny1 <= ny0:
            cx = 0.5 * (self._xmin + self._xmax)
            cy = 0.5 * (self._ymin + self._ymax)
            eps = 1e-9
            return RectangleWindow(cx, cy, cx + eps, cy + eps,
                                   unit=self._unit, dtb_shape=self._dtb_shape)

        return RectangleWindow(nx0, ny0, nx1, ny1,
                               unit=self._unit, dtb_shape=self._dtb_shape)

    def distance_to_boundary(self) -> Image:
        if self._dtb_cache is not None:
            return self._dtb_cache
        ny, nx = self._dtb_shape
        w = max(self.width(), 0.0); h = max(self.height(), 0.0)
        if w == 0.0 or h == 0.0:
            img = Image((self._xmin, self._ymin), (1.0, 1.0), np.zeros((ny, nx), float))
            self._dtb_cache = img
            return img
        sx = w / nx; sy = h / ny
        cx = self._xmin + (np.arange(nx) + 0.5) * sx
        cy = self._ymin + (np.arange(ny) + 0.5) * sy
        X, Y = np.meshgrid(cx, cy)
        dx = np.minimum(X - self._xmin, self._xmax - X)
        dy = np.minimum(Y - self._ymin, self._ymax - Y)
        dt = np.minimum(dx, dy)
        dt = np.maximum(dt, 0.0)
        img = Image((self._xmin, self._ymin), (sx, sy), dt)
        self._dtb_cache = img
        return img

    def overlap_fraction(self, shift: Tuple[float, float]) -> float:
        if self.area() == 0.0:
            return 0.0
        dx = abs(float(shift[0])); dy = abs(float(shift[1]))
        ox = max(0.0, self.width()  - dx)
        oy = max(0.0, self.height() - dy)
        return (ox * oy) / self.area()

    def rescale(self, factor: float) -> "RectangleWindow":
        if factor <= 0:
            raise ValueError("Factor must be positive.")
        return RectangleWindow(
            self._xmin * factor, self._ymin * factor, self._xmax * factor, self._ymax * factor,
            unit=self._unit, dtb_shape=self._dtb_shape
        )


class PolyWindow(Window):
    """
    Polygonal window (possibly with holes / multiple components), powered by Shapely.
    Exact area/contains/erosion; sampling via rejection in bbox; dtb at points exact.
    A raster is used only to produce a distance image when required.
    """

    def __init__(self, geom, unit: str = "1", dtb_shape: Tuple[int, int] = (256, 256)) -> None:
        _require_shapely("PolyWindow")

        import shapely.geometry as sgeom
        if not isinstance(geom, (sgeom.Polygon, sgeom.MultiPolygon)):
            raise ValueError("geom must be a Shapely Polygon or MultiPolygon.")
        self._geom = geom
        self._unit = unit
        self._dtb_shape = (int(dtb_shape[0]), int(dtb_shape[1]))
        self._dtb_cache: Optional[Image] = None
        try:
            from shapely.prepared import prep
            self._prep = prep(self._geom)
        except (ImportError, AttributeError):
            self._prep = None

        if _HAVE_SHP2 and _sh_prepare is not None:
            _sh_prepare(self._geom)
        elif _sh1_prep is not None:
            self._prep = _sh1_prep(self._geom)
        else:
            self._prep = None

    @property
    def geom(self):
        return self._geom

    def area(self) -> float:
        return float(self._geom.area)

    def bounds(self) -> Tuple[float, float, float, float]:
        return tuple(self._geom.bounds)

    def contains(self, xy: np.ndarray) -> np.ndarray:
        xy = np.asarray(xy, float)
        if xy.ndim != 2 or xy.shape[1] != 2:
            raise ValueError("xy must have shape (n, 2)")

        if _HAVE_SHP2 and _sh_points is not None and _sh_contains is not None:
            pts = _sh_points(xy[:, 0], xy[:, 1])
            return np.asarray(_sh_contains(self._geom, pts), dtype=bool)

        from shapely.geometry import Point
        if getattr(self, "_prep", None) is not None:
            return np.array([self._prep.contains(Point(float(x), float(y))) for x, y in xy],
                            dtype=bool)
        return np.array([self._geom.contains(Point(float(x), float(y))) for x, y in xy],
                        dtype=bool)

    def sample_uniform(self, n: int, rng: Optional[np.random.Generator] = None) -> np.ndarray:
        if n < 0:
            raise ValueError("n must be non-negative.")
        if n == 0:
            return np.empty((0, 2), float)

        rng = rng if isinstance(rng, np.random.Generator) else np.random.default_rng(rng)
        minx, miny, maxx, maxy = self._geom.bounds

        out = np.empty((0, 2), float)
        need = int(n)

        while need > 0:
            cand = np.column_stack([
                rng.random(need) * (maxx - minx) + minx,
                rng.random(need) * (maxy - miny) + miny
            ])

            if _HAVE_SHP2 and _sh_points is not None and _sh_contains is not None:
                pts = _sh_points(cand[:, 0], cand[:, 1])
                inside = np.asarray(_sh_contains(self._geom, pts), dtype=bool)
            else:
                from shapely.geometry import Point
                if getattr(self, "_prep", None) is not None:
                    inside = np.array([self._prep.contains(Point(float(x), float(y)))
                                       for x, y in cand], dtype=bool)
                else:
                    inside = np.array([self._geom.contains(Point(float(x), float(y)))
                                       for x, y in cand], dtype=bool)

            cand = np.asarray(cand, dtype=float)
            inside = np.asarray(inside, dtype=bool).ravel()

            idx = np.flatnonzero(inside)
            if idx.size:
                chosen = cand[idx]
                out = chosen if out.size == 0 else np.vstack((out, chosen))
            need = n - out.shape[0]

        return out[:n]

    def erode(self, r: float) -> "PolyWindow":
        if r < 0:
            raise ValueError("Erosion radius must be non-negative.")
        if r == 0.0:
            return PolyWindow(self._geom, unit=self._unit, dtb_shape=self._dtb_shape)

        from shapely.geometry import Polygon, MultiPolygon, GeometryCollection

        g2 = self._geom.buffer(-float(r), join_style=2)
        if g2.is_empty:
            g2 = Polygon()
        if isinstance(g2, GeometryCollection) and len(g2.geoms) == 0:
            g2 = Polygon()
        if not isinstance(g2, (Polygon, MultiPolygon)):
            g2 = Polygon()
        return PolyWindow(g2, unit=self._unit, dtb_shape=self._dtb_shape)

    def dtb_at_points(self, xy: np.ndarray) -> np.ndarray:
        xy = np.asarray(xy, float)
        if xy.ndim != 2 or xy.shape[1] != 2:
            raise ValueError("xy must have shape (n, 2)")
        try:
            from shapely import points as s_points, distance as s_distance
            pts = s_points(xy[:, 0], xy[:, 1])
            d = s_distance(pts, self._geom.boundary)
            return np.asarray(d, float)
        except (ImportError, AttributeError, TypeError):
            from shapely.geometry import Point
            b = self._geom.boundary
            return np.fromiter(
                (Point(float(x), float(y)).distance(b) for x, y in xy),
                dtype=float, count=xy.shape[0]
            )

    def distance_to_boundary(self) -> Image:
        if self._dtb_cache is not None:
            return self._dtb_cache
        _require_scipy("PolyWindow.distance_to_boundary")
        _require_shapely("PolyWindow.distance_to_boundary")
        from scipy import ndimage as ndi

        ny, nx = self._dtb_shape
        xmin, ymin, xmax, ymax = self.bounds()
        if xmax <= xmin or ymax <= ymin:
            img = Image((xmin, ymin), (1.0, 1.0), np.zeros((ny, nx), float))
            self._dtb_cache = img
            return img

        sx = (xmax - xmin) / nx
        sy = (ymax - ymin) / ny
        cx = xmin + (np.arange(nx) + 0.5) * sx
        cy = ymin + (np.arange(ny) + 0.5) * sy
        X, Y = np.meshgrid(cx, cy)

        m = self.contains(np.column_stack([X.ravel(), Y.ravel()])).reshape(ny, nx)
        mp = np.pad(m, ((1, 1), (1, 1)), mode="constant", constant_values=False)
        dt_pad, inds = ndi.distance_transform_edt(mp, sampling=(sy, sx), return_indices=True)

        yy_int, xx_int = np.indices((ny, nx))
        yy_int += 1
        xx_int += 1
        inds_y = inds[0, 1:-1, 1:-1]
        inds_x = inds[1, 1:-1, 1:-1]
        y_changed = inds_y != yy_int
        x_changed = inds_x != xx_int

        corr = np.zeros((ny, nx), float)
        corr[x_changed & ~y_changed] = sx
        corr[~x_changed & y_changed] = sy
        corr[x_changed & y_changed] = float(np.hypot(sx, sy))

        dt = dt_pad[1:-1, 1:-1] - corr
        dt[~m] = 0.0
        dt = np.clip(dt, 0.0, None)
        img = Image((xmin, ymin), (sx, sy), dt)
        self._dtb_cache = img
        return img

    def overlap_fraction(self, shift: Tuple[float, float]) -> float:
        """
        Exact overlap area fraction via Shapely intersection.
        """
        _require_shapely("PolyWindow.overlap_fraction")
        if self.area() == 0.0:
            return 0.0
        from shapely.affinity import translate
        moved = translate(self._geom, xoff=float(shift[0]), yoff=float(shift[1]))
        inter_area = self._geom.intersection(moved).area
        return float(inter_area) / float(self._geom.area)

    def rescale(self, factor: float, to_unit: Optional[str] = None) -> "PolyWindow":
        if factor <= 0:
            raise ValueError("Factor must be positive.")
        _require_shapely("PolyWindow.rescale")
        from shapely.affinity import scale as shp_scale
        g2 = shp_scale(self._geom, xfact=factor, yfact=factor, origin=(0.0, 0.0))
        return PolyWindow(g2, unit=(to_unit or self._unit), dtb_shape=self._dtb_shape)


class PPP:
    """
    Point pattern (planar PPP) with a study window.

    :param xy: Point coordinates in world units (float), shape (n, 2).
    :type xy: numpy.ndarray
    :param window: Study region. All points must lie inside.
    :type window: Window
    :param marks: Optional per-point marks. Length must be n if provided.
    :type marks: Optional[numpy.ndarray]
    :param unit: Informational unit label (e.g. "µm").
    :type unit: str
    """
    def __init__(
            self,
            xy: np.ndarray,
            window: Window,
            marks: Optional[np.ndarray] = None,
            unit: str = "1",
    ) -> None:
        arr = np.asarray(xy, dtype=float)
        if arr.ndim != 2 or arr.shape[1] != 2:
            raise ValueError("xy must have shape (n, 2).")
        if not np.isfinite(arr).all():
            raise ValueError("xy contains non-finite values.")
        self._xy = arr
        self._window = window
        self._unit = unit
        if marks is not None:
            m = np.asarray(marks)
            if m.shape[0] != arr.shape[0]:
                raise ValueError("Marks length must match the number of points.")
            self._marks = m
        else:
            self._marks = None
        inside = window.contains(self._xy)
        if not bool(np.all(inside)):
            bad = int((~inside).sum())
            raise ValueError(f"{bad} points lie outside the window.")

    @property
    def xy(self) -> np.ndarray:
        return self._xy

    @property
    def window(self) -> Window:
        return self._window

    @property
    def marks(self) -> Optional[np.ndarray]:
        return self._marks

    @property
    def unit(self) -> str:
        return self._unit

    def __len__(self) -> int:
        return int(self._xy.shape[0])

    def bbox(self) -> Tuple[float, float, float, float]:
        """Axis-aligned bounding box (xmin, ymin, xmax, ymax)."""
        x = self._xy[:, 0]; y = self._xy[:, 1]
        if x.size == 0:
            raise ValueError("bbox undefined for empty patterns.")
        return float(x.min()), float(y.min()), float(x.max()), float(y.max())

    def intensity(self) -> float:
        """Homogeneous intensity estimate n / |W|."""
        area = self._window.area()
        if area <= 0:
            raise ValueError("Area must be greater than zero.")
        return float(len(self)) / float(area)

    def thin(self, p: float, rng: Optional[np.random.Generator] = None) -> "PPP":
        """Independent thinning with retention probability p ∈ [0, 1]."""
        if not (0.0 <= p <= 1.0):
            raise ValueError("p must be in [0, 1]")
        if len(self) == 0:
            return PPP(self._xy.copy(), self.window, self.marks, unit=self.unit)
        g = _as_rng(rng)
        keep = g.random(len(self)) < p
        new_xy = self._xy[keep]
        new_marks = (self._marks[keep] if self._marks is not None else None)
        return PPP(new_xy, self._window, new_marks, unit=self.unit)

    def rescale(self, factor: float, to_unit: Optional[str] = None) -> "PPP":
        """Return a rescaled copy (coordinates and window scaled by `factor`)."""
        if factor <= 0:
            raise ValueError("Factor must be positive.")
        new_xy = self._xy * factor
        new_win = self._window.rescale(factor)
        return PPP(new_xy, new_win, None if self._marks is None else self._marks.copy(), unit=(to_unit or self._unit))

    def nndist(self, k: int = 1) -> np.ndarray:
        """
        Nearest-neighbour distances per-point.

        :param k: Number of nearest neighbours to return. k=1 returns (n,).
        :type k: int
        :return: Array of distances, shape (n,) for k=1 else (n, k).
        :rtype: numpy.ndarray
        """
        n = len(self)
        if n == 0:
            return np.empty((0,) if k == 1 else (0, k), float)
        if k < 1:
            raise ValueError("k must be >= 1.")
        if n == 1:
            return np.array([np.inf]) if k == 1 else np.full((1, k), np.inf)

        from scipy.spatial import cKDTree
        tree = cKDTree(self._xy)
        d, _ = tree.query(self._xy, k=k + 1)
        if k == 1:
            return d[:, 1]
        else:
            return d[:, 1: k + 1]
