"""Base abstractions for coordinate-aware ansatzes and scalar masks.

This module defines the core contracts shared by the ansatz package:

- :class:`AnsatzND` and its typed descendants for vector director fields
- :class:`MaskND` for scalar modulation fields
- internal composition wrappers implementing ansatz and mask algebra

Ansatzes are expressed in local coordinate components and materialized on
Euclidean sample points through the associated coordinate system.
"""

from __future__ import annotations

from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Callable

import numpy as np

from ._utils import normalize_vector
from .frame import CoordinateSampleND, CoordinateSystemND, EuclideanCoordinates

__all__ = [
    "AnsatzND",
    "Ansatz2D",
    "Ansatz3D",
    "MaskND",
]


def _is_zero_scalar(value) -> bool:
    """Return ``True`` only for scalar additive identities."""

    return np.isscalar(value) and value == 0


def _validate_points(points, ndim: int) -> np.ndarray:
    """Validate that point arrays carry the expected trailing coordinate axis."""

    points = np.asarray(points)
    if points.shape[-1] != ndim:
        raise ValueError(f"Expected points with trailing dimension {ndim}, got {points.shape}.")
    return points


def _stack_points_2d(x, z) -> np.ndarray:
    """Broadcast ``x`` and ``z`` and stack them into ``(..., 2)`` points."""

    x, z = np.broadcast_arrays(np.asarray(x), np.asarray(z))
    return np.stack([x, z], axis=-1)


def _coerce_points(points, ndim: int, extra=()) -> np.ndarray:
    """Coerce user-facing ansatz or mask arguments into a point array."""

    if extra:
        if ndim == 2 and len(extra) == 1:
            return _stack_points_2d(points, extra[0])
        raise TypeError(f"Expected a single point array for ndim={ndim}, got {1 + len(extra)} arguments.")
    return _validate_points(points, ndim)


def _parse_shift(ndim: int, offset=None, **components) -> np.ndarray:
    """Parse either a full shift vector or named coordinate components."""

    if offset is not None and components:
        raise TypeError("Pass either `offset` or named coordinate components, not both.")
    if components:
        if ndim == 2:
            values = (components.pop("x", 0.0), components.pop("z", 0.0))
        elif ndim == 3:
            values = (
                components.pop("x", 0.0),
                components.pop("y", 0.0),
                components.pop("z", 0.0),
            )
        else:
            raise TypeError("Named coordinate shifts are only supported for ndim=2 and ndim=3.")
        if components:
            unknown = ", ".join(sorted(components))
            raise TypeError(f"Unknown shift component(s): {unknown}.")
        return np.asarray(values, dtype=np.float64)
    if offset is None:
        raise TypeError("`offset` or named coordinate components are required.")
    offset = np.asarray(offset, dtype=np.float64)
    if offset.shape != (ndim,):
        raise ValueError(f"`offset` must have shape ({ndim},), got {offset.shape}.")
    return offset


def _validate_coordinates(coordinates: CoordinateSystemND, ndim: int) -> CoordinateSystemND:
    """Check that a bound coordinate system matches the required dimension."""

    if not isinstance(coordinates, CoordinateSystemND):
        raise TypeError(
            f"`coordinates` must be a CoordinateSystemND, got {type(coordinates).__name__}."
        )
    if coordinates.ndim != ndim:
        raise ValueError(f"`coordinates.ndim` must be {ndim}, got {coordinates.ndim}.")
    return coordinates


def _section_output_basis(sample: CoordinateSampleND) -> np.ndarray:
    """Build the canonical 2D section output basis ``(u, mid, v)`` in 3D.

    The wrapped 2D chart defines the two tangent directions ``g_u`` and
    ``g_v`` in the ``(x, z)`` plane. They are embedded into a canonical 3D
    section frame as

    - ``g_u^3 = (dx/du, 0, dz/du)``
    - ``e_mid = (0, 1, 0)``
    - ``g_v^3 = (dx/dv, 0, dz/dv)``
    """

    if sample.ndim != 2:
        raise ValueError(f"Expected a 2D coordinate sample, got ndim={sample.ndim}.")
    dtype = np.result_type(sample.basis, np.float64)
    basis = np.zeros(sample.coords.shape[:-1] + (3, 3), dtype=dtype)
    basis[..., 0, 0] = sample.basis[..., 0, 0]
    basis[..., 0, 2] = sample.basis[..., 0, 1]
    basis[..., 1, 1] = 1.0
    basis[..., 2, 0] = sample.basis[..., 1, 0]
    basis[..., 2, 2] = sample.basis[..., 1, 1]
    return basis


def _pushforward_tangent_components(components: np.ndarray, basis: np.ndarray) -> np.ndarray:
    """Assemble an ambient vector field from local 3-component data.

    Parameters
    ----------
    components:
        Array with shape ``(..., 3)`` storing vector components in the local
        output basis.
    basis:
        Array with shape ``(..., 3, 3)`` whose rows are the ambient images of
        the three local output basis vectors.

    Returns
    -------
    np.ndarray
        Ambient 3-vector field given by raw pushforward
        ``sum_i components_i * basis_i``. No normalization is applied here.
    """

    components = np.asarray(components)
    basis = np.asarray(basis)
    expected_basis = basis.shape[:-2] + (3, 3)
    if basis.shape != expected_basis:
        raise ValueError(f"`basis` must have shape {expected_basis}, got {basis.shape}.")
    expected_components = basis.shape[:-2] + (3,)
    if components.shape != expected_components:
        raise ValueError(
            f"`components` must have shape {expected_components}, got {components.shape}."
        )
    return np.einsum("...i,...ij->...j", components, basis)


class AnsatzND(ABC):
    """Coordinate-aware vector ansatz on ``R^N`` with tangent-space semantics.

    Public evaluation receives Euclidean points. The base class first evaluates
    the associated coordinate system and passes the resulting
    :class:`CoordinateSampleND` to the concrete ansatz hook. Concrete ansatze
    are therefore written in their bound curvilinear coordinates while still
    materializing on Euclidean lattice nodes.

    Concrete ansatz hooks always return local 3-component director data. The
    public :meth:`call` path then pushes those components into an ambient raw
    3-vector field through a dimension-specific output basis. Normalization
    belongs only to the user-facing :meth:`eval` / ``__call__`` path.
    """

    _fixed_ndim: int | None = None

    def __init__(self, ndim: int | None = None, coordinates: CoordinateSystemND | None = None):
        fixed_ndim = self._fixed_ndim
        if ndim is None:
            if fixed_ndim is not None:
                ndim = fixed_ndim
            elif coordinates is not None:
                ndim = coordinates.ndim
            else:
                raise TypeError("`ndim` must be provided for generic ansatze.")
        ndim = int(ndim)
        if fixed_ndim is not None and ndim != fixed_ndim:
            raise ValueError(f"{self.__class__.__name__} requires ndim={fixed_ndim}, got {ndim}.")
        if ndim <= 0:
            raise ValueError("`ndim` must be positive.")
        if coordinates is None:
            coordinates = EuclideanCoordinates(ndim=ndim)
        self.ndim = ndim
        self.coordinates = _validate_coordinates(coordinates, ndim)

    @abstractmethod
    def _call_sample(self, sample: CoordinateSampleND) -> np.ndarray:
        """Return local 3-component director data for a coordinate sample."""

        raise NotImplementedError

    def _output_basis(self, sample: CoordinateSampleND) -> np.ndarray:
        """Return the ambient image of the local output basis for this ansatz."""

        if self.ndim == 3:
            return np.asarray(sample.basis)
        if self.ndim == 2:
            return _section_output_basis(sample)
        raise NotImplementedError(
            f"{self.__class__.__name__} does not define an output basis for ndim={self.ndim}."
        )

    def call(self, points, *extra) -> np.ndarray:
        """Materialize the raw ambient field on Euclidean sample points.

        Concrete ansatz implementations provide local 3-component data via
        :meth:`_call_sample`. This method evaluates the associated coordinate
        system, obtains the appropriate output basis, and pushes the local
        components into ambient space without normalization.
        """

        points = _coerce_points(points, self.ndim, extra)
        sample = self.coordinates(points)
        components = np.asarray(self._call_sample(sample))
        expected_shape = points.shape[:-1] + (3,)
        if components.shape != expected_shape:
            raise ValueError(
                f"{self.__class__.__name__} returned shape {components.shape}, expected {expected_shape}."
            )
        basis = np.asarray(self._output_basis(sample))
        expected_basis_shape = points.shape[:-1] + (3, 3)
        if basis.shape != expected_basis_shape:
            raise ValueError(
                f"{self.__class__.__name__} output basis has shape {basis.shape}, "
                f"expected {expected_basis_shape}."
            )
        return _pushforward_tangent_components(components, basis)

    def eval(self, points, *extra) -> np.ndarray:
        """Evaluate the ansatz and normalize the ambient field pointwise."""

        return normalize_vector(self.call(points, *extra))

    def __call__(self, points, *extra) -> np.ndarray:
        """Alias for :meth:`eval`."""

        return self.eval(points, *extra)

    def in_coordinates(self, coordinates: CoordinateSystemND) -> "AnsatzND":
        """Rebind the ansatz to a different coordinate system of the same dimension."""

        return _CoordinateMappedAnsatzND(self, coordinates)

    def _default_mask_coordinates(self) -> CoordinateSystemND | None:
        """Return the default coordinate system for inline masking, if unambiguous."""

        return self.coordinates

    def mask(self, fn) -> "AnsatzND":
        """Apply an inline scalar mask defined in associated curvilinear coordinates.

        Parameters
        ----------
        fn:
            Callable receiving exactly ``ndim`` positional arrays corresponding
            to the associated curvilinear coordinates in index order. The
            callable must return a scalar or an array broadcastable to the
            spatial shape of the evaluated chart.

        Notes
        -----
        This sugar is intentionally strict for composed ansatze. If the ansatz
        does not carry one unambiguous default coordinate system, a
        ``ValueError`` is raised and the caller must build a mask explicitly via
        ``MaskND.from_function(..., coordinates=...)``.
        """

        coordinates = self._default_mask_coordinates()
        if coordinates is None:
            raise ValueError(
                f"{self.__class__.__name__} does not have an unambiguous associated "
                "coordinate system for inline masking. Build an explicit mask via "
                "`MaskND.from_function(..., coordinates=...)`."
            )
        return self * MaskND.from_function(fn, coordinates=coordinates)

    def shift(self, offset=None, **components) -> "AnsatzND":
        """Shift the bound coordinate system in ambient space."""

        offset = _parse_shift(self.ndim, offset=offset, **components)
        return self.in_coordinates(self.coordinates.shift(offset))

    def __add__(self, other):
        if not isinstance(other, AnsatzND):
            return NotImplemented
        if other.ndim != self.ndim:
            raise ValueError(f"Cannot add ansatze with ndim {self.ndim} and {other.ndim}.")
        return _SumAnsatzND(self, other)

    def __radd__(self, other):
        if _is_zero_scalar(other):
            return self
        if not isinstance(other, AnsatzND):
            return NotImplemented
        return other + self

    def __mul__(self, other):
        from .mask import ensure_masknd

        if isinstance(other, AnsatzND):
            return NotImplemented
        return _MaskedAnsatzND(self, ensure_masknd(other, self.ndim))

    def __rmul__(self, other):
        from .mask import ensure_masknd

        if isinstance(other, AnsatzND):
            return NotImplemented
        return _MaskedAnsatzND(self, ensure_masknd(other, self.ndim))

    def __neg__(self):
        return self * -1.0


class Ansatz2D(AnsatzND):
    """Typed 2D section ansatz with convenience evaluation on ``(x, z)`` grids.

    A 2D ansatz defines local director components in the canonical section
    frame ``(u, mid, v)``. :meth:`call` returns the raw ambient section field
    obtained by pushforward through the bound 2D chart, while :meth:`eval` and
    ``__call__`` normalize that field pointwise.
    """

    _fixed_ndim = 2

    def call(self, x, z=None) -> np.ndarray:
        """Materialize the raw section field on ``(x, z)`` points or point arrays."""

        if z is None:
            return super().call(x)
        return super().call(_stack_points_2d(x, z))

    def eval(self, x, z=None) -> np.ndarray:
        """Evaluate and normalize the section field on ``(x, z)`` inputs."""

        if z is None:
            return super().eval(x)
        return super().eval(_stack_points_2d(x, z))

    def __call__(self, x, z=None) -> np.ndarray:
        """Alias for :meth:`eval` with 2D section calling conventions."""

        return self.eval(x, z)

    def shift(self, x: float = 0.0, z: float = 0.0) -> "AnsatzND":
        """Shift the associated 2D chart in its ambient ``(x, z)`` plane."""

        return self.in_coordinates(self.coordinates.shift((x, z)))


class Ansatz3D(AnsatzND):
    """Typed 3D director ansatz with raw-pushforward semantics.

    ``call(...)`` returns the ambient raw field obtained from local
    coordinate-basis components, while ``eval(...)`` and ``__call__(...)``
    normalize that ambient field pointwise to unit length.
    """

    _fixed_ndim = 3

    def __call__(self, points) -> np.ndarray:
        """Alias for :meth:`eval` on Euclidean 3D sample points."""

        return self.eval(points)

    def shift(self, x: float = 0.0, y: float = 0.0, z: float = 0.0) -> "AnsatzND":
        """Shift the associated 3D chart in ambient Euclidean space."""

        return self.in_coordinates(self.coordinates.shift((x, y, z)))


@dataclass(slots=True)
class _CoordinateMappedAnsatzND(AnsatzND):
    """Private wrapper that reuses one ansatz formula in another chart."""

    ansatz: AnsatzND

    def __init__(self, ansatz: AnsatzND, coordinates: CoordinateSystemND):
        self.ansatz = ansatz
        AnsatzND.__init__(self, ndim=ansatz.ndim, coordinates=coordinates)

    def _call_sample(self, sample: CoordinateSampleND) -> np.ndarray:
        return self.ansatz._call_sample(sample)

    def in_coordinates(self, coordinates: CoordinateSystemND) -> AnsatzND:
        return _CoordinateMappedAnsatzND(self.ansatz, coordinates)


@dataclass(slots=True)
class _MaskedAnsatzND(AnsatzND):
    """Private product ansatz representing ``field * mask``."""

    field: AnsatzND
    scalar_mask: "MaskND"

    def __init__(self, field: AnsatzND, mask: "MaskND"):
        if field.ndim != mask.ndim:
            raise ValueError(f"Field ndim {field.ndim} does not match mask ndim {mask.ndim}.")
        self.field = field
        self.scalar_mask = mask
        AnsatzND.__init__(self, ndim=field.ndim)

    def _call_sample(self, sample: CoordinateSampleND) -> np.ndarray:
        raise RuntimeError("_MaskedAnsatzND.call() should evaluate operands directly on Euclidean points.")

    def call(self, points, *extra) -> np.ndarray:
        points = _coerce_points(points, self.ndim, extra)
        return self.field.call(points) * self.scalar_mask(points)[..., None]

    def in_coordinates(self, coordinates: CoordinateSystemND) -> AnsatzND:
        return _MaskedAnsatzND(
            self.field.in_coordinates(coordinates),
            self.scalar_mask.in_coordinates(coordinates),
        )

    def _default_mask_coordinates(self) -> CoordinateSystemND | None:
        field_coordinates = self.field._default_mask_coordinates()
        mask_coordinates = self.scalar_mask._default_mask_coordinates()
        if field_coordinates is mask_coordinates:
            return field_coordinates
        return None


@dataclass(slots=True)
class _SumAnsatzND(AnsatzND):
    """Private additive ansatz representing a raw sum of two fields."""

    left: AnsatzND
    right: AnsatzND

    def __init__(self, left: AnsatzND, right: AnsatzND):
        if left.ndim != right.ndim:
            raise ValueError(f"Cannot sum ansatze with ndim {left.ndim} and {right.ndim}.")
        self.left = left
        self.right = right
        AnsatzND.__init__(self, ndim=left.ndim)

    def _call_sample(self, sample: CoordinateSampleND) -> np.ndarray:
        raise RuntimeError("_SumAnsatzND.call() should evaluate operands directly on Euclidean points.")

    def call(self, points, *extra) -> np.ndarray:
        points = _coerce_points(points, self.ndim, extra)
        return self.left.call(points) + self.right.call(points)

    def in_coordinates(self, coordinates: CoordinateSystemND) -> AnsatzND:
        return _SumAnsatzND(self.left.in_coordinates(coordinates), self.right.in_coordinates(coordinates))

    def _default_mask_coordinates(self) -> CoordinateSystemND | None:
        left_coordinates = self.left._default_mask_coordinates()
        right_coordinates = self.right._default_mask_coordinates()
        if left_coordinates is right_coordinates:
            return left_coordinates
        return None


class MaskND(ABC):
    """Coordinate-aware scalar mask on ``R^N``.

    Public evaluation receives Euclidean points, evaluates the associated
    coordinate system, and computes a scalar field in those curvilinear
    coordinates. Masks support arithmetic composition, scalar coercion, and
    inline use in ansatz products. Unless explicitly remapped, a mask is bound
    to one coordinate system, and all callable-mask formulas are interpreted in
    that chart.
    """

    __array_priority__ = 1000
    _fixed_ndim: int | None = None

    def __init__(self, ndim: int | None = None, coordinates: CoordinateSystemND | None = None):
        fixed_ndim = self._fixed_ndim
        if ndim is None:
            if fixed_ndim is not None:
                ndim = fixed_ndim
            elif coordinates is not None:
                ndim = coordinates.ndim
            else:
                raise TypeError("`ndim` must be provided for generic masks.")
        ndim = int(ndim)
        if fixed_ndim is not None and ndim != fixed_ndim:
            raise ValueError(f"{self.__class__.__name__} requires ndim={fixed_ndim}, got {ndim}.")
        if ndim <= 0:
            raise ValueError("`ndim` must be positive.")
        if coordinates is None:
            coordinates = EuclideanCoordinates(ndim=ndim)
        self.ndim = ndim
        self.coordinates = _validate_coordinates(coordinates, ndim)

    @abstractmethod
    def _call_sample(self, sample: CoordinateSampleND) -> np.ndarray:
        """Return scalar mask values for a coordinate sample."""

        raise NotImplementedError

    def call(self, points, *extra) -> np.ndarray:
        """Evaluate the scalar mask on Euclidean sample points."""

        points = _coerce_points(points, self.ndim, extra)
        sample = self.coordinates(points)
        result = np.asarray(self._call_sample(sample))
        expected_shape = points.shape[:-1]
        if result.shape != expected_shape:
            raise ValueError(
                f"{self.__class__.__name__} returned shape {result.shape}, expected {expected_shape}."
            )
        return result

    def __call__(self, points, *extra) -> np.ndarray:
        """Alias for :meth:`call`."""

        return self.call(points, *extra)

    def in_coordinates(self, coordinates: CoordinateSystemND) -> "MaskND":
        """Rebind the mask to a different coordinate system of the same dimension."""

        return _CoordinateMappedMaskND(self, coordinates)

    @classmethod
    def from_function(
        cls,
        fn,
        *,
        ndim: int | None = None,
        coordinates: CoordinateSystemND | None = None,
    ) -> "MaskND":
        """Build a scalar mask from a callable on curvilinear coordinates.

        The callable is invoked as ``fn(*coords)`` where ``coords[i]`` is the
        ``i``-th coordinate component of the associated chart. The callable may
        return a scalar or any array broadcastable to the sampled spatial
        shape.
        """

        fixed_ndim = cls._fixed_ndim
        if fixed_ndim is not None:
            if ndim is None:
                ndim = fixed_ndim
            elif int(ndim) != fixed_ndim:
                raise ValueError(f"{cls.__name__} requires ndim={fixed_ndim}, got {ndim}.")
        from .mask import _FunctionMaskND

        return _FunctionMaskND(fn, ndim=ndim, coordinates=coordinates)

    def _default_mask_coordinates(self) -> CoordinateSystemND | None:
        """Return the default coordinate system for further mask composition."""

        return self.coordinates

    def shift(self, offset=None, **components) -> "MaskND":
        """Shift the bound coordinate system in ambient space."""

        offset = _parse_shift(self.ndim, offset=offset, **components)
        return self.in_coordinates(self.coordinates.shift(offset))

    def exp(self) -> "MaskND":
        """Apply ``exp`` pointwise to this mask."""

        return _UnaryMaskND(self, fn=np.exp, name="exp")

    def clip(self, min_value=None, max_value=None) -> "MaskND":
        """Clamp mask values pointwise to the requested interval."""

        return _UnaryMaskND(
            self,
            fn=lambda value: np.clip(value, min_value, max_value),
            name="clip",
        )

    def __add__(self, other):
        from .mask import ensure_masknd

        return _BinaryMaskND(self, ensure_masknd(other, self.ndim), fn=np.add, symbol="+")

    def __radd__(self, other):
        from .mask import ensure_masknd

        if _is_zero_scalar(other):
            return self
        return _BinaryMaskND(ensure_masknd(other, self.ndim), self, fn=np.add, symbol="+")

    def __sub__(self, other):
        from .mask import ensure_masknd

        return _BinaryMaskND(self, ensure_masknd(other, self.ndim), fn=np.subtract, symbol="-")

    def __rsub__(self, other):
        from .mask import ensure_masknd

        return _BinaryMaskND(ensure_masknd(other, self.ndim), self, fn=np.subtract, symbol="-")

    def __mul__(self, other):
        from .mask import ensure_masknd

        if isinstance(other, AnsatzND):
            if other.ndim != self.ndim:
                raise ValueError(f"Field ndim {other.ndim} does not match mask ndim {self.ndim}.")
            return other * self
        return _BinaryMaskND(self, ensure_masknd(other, self.ndim), fn=np.multiply, symbol="*")

    def __rmul__(self, other):
        from .mask import ensure_masknd

        if isinstance(other, AnsatzND):
            if other.ndim != self.ndim:
                raise ValueError(f"Field ndim {other.ndim} does not match mask ndim {self.ndim}.")
            return other * self
        return _BinaryMaskND(ensure_masknd(other, self.ndim), self, fn=np.multiply, symbol="*")

    def __truediv__(self, other):
        from .mask import ensure_masknd

        return _BinaryMaskND(self, ensure_masknd(other, self.ndim), fn=np.divide, symbol="/")

    def __rtruediv__(self, other):
        from .mask import ensure_masknd

        return _BinaryMaskND(ensure_masknd(other, self.ndim), self, fn=np.divide, symbol="/")

    def __pow__(self, power):
        from .mask import ensure_masknd

        return _BinaryMaskND(self, ensure_masknd(power, self.ndim), fn=np.power, symbol="**")

    def __neg__(self):
        return _UnaryMaskND(self, fn=np.negative, name="neg")

    def __pos__(self):
        return self


@dataclass(slots=True)
class _CoordinateMappedMaskND(MaskND):
    """Private wrapper that evaluates an existing mask in a different chart."""

    mask: MaskND

    def __init__(self, mask: MaskND, coordinates: CoordinateSystemND):
        self.mask = mask
        MaskND.__init__(self, ndim=mask.ndim, coordinates=coordinates)

    def _call_sample(self, sample: CoordinateSampleND) -> np.ndarray:
        return self.mask._call_sample(sample)

    def in_coordinates(self, coordinates: CoordinateSystemND) -> MaskND:
        return _CoordinateMappedMaskND(self.mask, coordinates)


@dataclass(slots=True)
class _UnaryMaskND(MaskND):
    """Private unary mask operation evaluated lazily on Euclidean points."""

    mask: MaskND
    fn: Callable[[np.ndarray], np.ndarray]
    name: str = "fn"

    def __init__(self, mask: MaskND, fn, name: str = "fn"):
        self.mask = mask
        self.fn = fn
        self.name = name
        MaskND.__init__(self, ndim=mask.ndim)

    def _call_sample(self, sample: CoordinateSampleND) -> np.ndarray:
        raise RuntimeError("_UnaryMaskND.call() should evaluate operands directly on Euclidean points.")

    def call(self, points, *extra) -> np.ndarray:
        points = _coerce_points(points, self.ndim, extra)
        return np.asarray(self.fn(self.mask(points)))

    def in_coordinates(self, coordinates: CoordinateSystemND) -> MaskND:
        return _UnaryMaskND(self.mask.in_coordinates(coordinates), fn=self.fn, name=self.name)

    def _default_mask_coordinates(self) -> CoordinateSystemND | None:
        return self.mask._default_mask_coordinates()


@dataclass(slots=True)
class _BinaryMaskND(MaskND):
    """Private binary mask operation combining two scalar mask operands."""

    left: MaskND
    right: MaskND
    fn: Callable[[np.ndarray, np.ndarray], np.ndarray]
    symbol: str = "?"

    def __init__(self, left: MaskND, right: MaskND, fn, symbol: str = "?"):
        if left.ndim != right.ndim:
            raise ValueError(f"Cannot combine masks with ndim {left.ndim} and {right.ndim}.")
        self.left = left
        self.right = right
        self.fn = fn
        self.symbol = symbol
        MaskND.__init__(self, ndim=left.ndim)

    def _call_sample(self, sample: CoordinateSampleND) -> np.ndarray:
        raise RuntimeError("_BinaryMaskND.call() should evaluate operands directly on Euclidean points.")

    def call(self, points, *extra) -> np.ndarray:
        points = _coerce_points(points, self.ndim, extra)
        return np.asarray(self.fn(self.left(points), self.right(points)))

    def in_coordinates(self, coordinates: CoordinateSystemND) -> MaskND:
        return _BinaryMaskND(
            self.left.in_coordinates(coordinates),
            self.right.in_coordinates(coordinates),
            fn=self.fn,
            symbol=self.symbol,
        )

    def _default_mask_coordinates(self) -> CoordinateSystemND | None:
        left_coordinates = self.left._default_mask_coordinates()
        right_coordinates = self.right._default_mask_coordinates()
        if left_coordinates is right_coordinates:
            return left_coordinates
        return None
