import json
import numpy as np
from abc import ABC, abstractmethod
from dataclasses import dataclass, replace
from datetime import timedelta
from ..calc_manager import LightingCalculator
from ..geometry import SurfaceGrid, VolumeGrid, GridPoint
from ._io import export_plane, export_volume, export_point
from ._plot import plot_plane, plot_volume, plot_point
from ..geometry import RoomDimensions
from ..geometry import Polygon2D
from .._serialization import init_from_dict, deserialize_geometry, migrate_zone_dict, migrate_legacy_zone_geometry
from ..plane_calc_mode import PlaneCalcMode


@dataclass(frozen=True)
class ZoneView:
    zone_id: str
    coords: np.ndarray
    num_points: np.ndarray
    calc_state: tuple
    update_state: tuple
    calctype: str
    fov_vert: int | None = None
    fov_horiz: int | None = None
    vert: bool | None = None
    horiz: bool | None = None
    use_normal: bool | None = None
    basis: np.ndarray | None = None
    view_direction: tuple | None = None
    view_target: tuple | None = None

    def is_plane(self):
        return self.calctype.lower() == "plane"

    def is_volume(self):
        return self.calctype.lower() == "volume"

    def is_point(self):
        return self.calctype.lower() == "point"

    def has_view_mode(self):
        return self.view_direction is not None or self.view_target is not None

    def compute_view_normals(self):
        """Return (N, 3) normalized view direction vectors for each grid point.

        For directional mode: broadcasts the single direction to all points.
        For target mode: computes per-point direction toward the target.
        Returns None if no view mode is active.
        """
        if self.view_direction is not None:
            d = np.asarray(self.view_direction, dtype="float64")
            d = d / np.linalg.norm(d)
            return np.broadcast_to(d, self.coords.shape).copy()
        if self.view_target is not None:
            target = np.asarray(self.view_target, dtype="float64")
            diff = target - self.coords
            norms = np.linalg.norm(diff, axis=1, keepdims=True)
            # Guard against coincident points (target == grid point)
            norms = np.where(norms < 1e-12, 1.0, norms)
            return diff / norms
        return None


@dataclass
class ZoneResult:
    base_values: np.ndarray | None = None
    reflected_values: np.ndarray | None = None

    def init(self):
        self.base_values = None
        self.reflected_values = None
        return self

    @property
    def values(self):
        if self.base_values is None:
            return None
        if self.reflected_values is None:
            return self.base_values
        return self.base_values + self.reflected_values



class CalcZone(ABC):
    """
    Abstract base class representing a calculation zone.

    Subclasses must implement calctype, export(), and plot().
    """

    _grid_cls = None  # subclasses set to VolumeGrid or SurfaceGrid

    def __init__(
        self,
        zone_id=None,
        name=None,
        dose=False,
        hours=None,
        minutes=None,
        seconds=None,
        enabled=True,
        display_mode="heatmap",
        calc_mode=None,
        fov_vert=None,
        fov_horiz=None,
        vert=None,
        horiz=None,
        use_normal=None,
        view_direction=None,
        view_target=None,
    ):
        self._zone_id = zone_id
        self.name = str(zone_id) if name is None else str(name)
        self.dose = False if dose is None else dose
        self._exposure_time = self._parse_exposure_time(hours, minutes, seconds)
        self.enabled = enabled
        self.display_mode = display_mode

        # these will all be calculated after spacing is set, which is set in the subclass
        self._geometry = None
        self.calculator = LightingCalculator()
        self.result = ZoneResult()

        # FOV / view handling
        if view_direction is not None and view_target is not None:
            raise ValueError(
                "view_direction and view_target are mutually exclusive"
            )

        self._view_direction = None
        self._view_target = None
        self.view_direction = view_direction
        self.view_target = view_target

        if calc_mode is not None:
            ct = PlaneCalcMode.from_token(calc_mode)
            spec = ct.spec
            self.horiz = horiz if horiz is not None else spec.horiz
            self.vert = vert if vert is not None else spec.vert
            self.use_normal = use_normal if use_normal is not None else spec.use_normal
            self.fov_vert = fov_vert if fov_vert is not None else spec.fov_vert
            self.fov_horiz = fov_horiz if fov_horiz is not None else spec.fov_horiz
            if view_direction is None and view_target is None:
                self.view_direction = spec.view_direction
                self.view_target = spec.view_target
        else:
            self.horiz = horiz if horiz is not None else False
            self.vert = vert if vert is not None else False
            self.use_normal = use_normal if use_normal is not None else True
            self.fov_vert = fov_vert if fov_vert is not None else 180
            self.fov_horiz = fov_horiz if fov_horiz is not None else 360

    def __getattr__(self, name):
        """
        attribute passthrough to geometry - called if normal lookup fails
        """
        geometry = self.__dict__.get("_geometry", None)
        if geometry is not None and hasattr(geometry, name):
            return getattr(geometry, name)
        raise AttributeError(name)

    def __eq__(self, other):
        if not isinstance(other, CalcZone):
            return NotImplemented
        return self.to_dict() == other.to_dict()

    def _repr_base(self):
        return f"id={self.zone_id!r}, name={self.name!r}, enabled={self.enabled}, dose={self.dose}"

    @property
    def id(self) -> str:
        return self._zone_id

    @property
    def zone_id(self) -> str:
        return self._zone_id

    def _assign_id(self, value: str) -> None:
        """should only be used by Scene"""
        self._zone_id = value

    @property
    @abstractmethod
    def calctype(self) -> str:
        """Return zone type identifier ('Plane', 'Volume', or 'Point')."""
        ...

    @property
    def value_units(self):
        # todo: probably should be called unit_label or something instead
        if self.dose:
            return "mJ/cm²"
        return "uW/cm²"

    @property
    def calc_state(self):
        """Geometry state + whether values exist. The 'has values' flag prevents
        spurious recalc when comparing against a cache saved after computation."""
        if self.geometry is None:
            return ()
        return self.geometry.calc_state + (self.result.base_values is not None,)

    @property
    def update_state(self):
        """if changes, calc zone needs updating but not recalculating"""
        if self.geometry is None:
            return ()
        return self.geometry.update_state + (
            self.fov_vert,
            self.fov_horiz,
            self.vert,
            self.horiz,
            self.use_normal,
            self.view_direction,
            self.view_target,
        )

    @property
    def geometry(self):
        return self._geometry

    @geometry.setter
    def geometry(self, new_geom):
        if self._geometry != new_geom:  # only clear if geometry is different
            if hasattr(self, "result") and self.result is not None:
                self.result.init()
        self._geometry = new_geom

    @property
    def view_direction(self):
        return self._view_direction

    @view_direction.setter
    def view_direction(self, value):
        self._view_direction = value
        if value is not None:
            self._view_target = None

    @property
    def view_target(self):
        return self._view_target

    @view_target.setter
    def view_target(self, value):
        self._view_target = value
        if value is not None:
            self._view_direction = None

    @property
    def view_mode(self) -> str | None:
        """Return the active view mode, or None for normal (surface-normal) mode."""
        if self.view_direction is not None:
            return "directional"
        if self.view_target is not None:
            return "target"
        return None

    @property
    def calc_mode(self) -> str:
        """Derive the calculation mode from current flags.

        Returns the matching PlaneCalcMode value if flags match a known mode,
        otherwise returns "custom".
        """
        return PlaneCalcMode.from_flags(
            horiz=self.horiz,
            vert=self.vert,
            use_normal=self.use_normal,
            fov_vert=float(self.fov_vert),
            fov_horiz=float(self.fov_horiz),
            view_direction=self.view_direction,
            view_target=self.view_target,
        ).value

    @classmethod
    def from_dict(cls, data):
        data = migrate_zone_dict(data)
        if cls._grid_cls is not None:
            data = migrate_legacy_zone_geometry(data, cls._grid_cls)
            data = deserialize_geometry(data, cls._grid_cls)
        return init_from_dict(cls, data)

    def to_dict(self):
        data = {}
        data["zone_id"] = self.zone_id
        data["name"] = self.name
        data["calctype"] = self.calctype
        data["dose"] = self.dose
        data["exposure_time"] = self._exposure_time.total_seconds()
        data["enabled"] = self.enabled
        data["display_mode"] = self.display_mode
        if self.geometry is not None:
            data["geometry"] = self.geometry.to_dict()

        data.update(self._extra_dict())
        return data

    def _extra_dict(self):
        if self.calctype == "Volume":
            return {}
        return {
            "calc_mode": self.calc_mode,
            "fov_vert": self.fov_vert,
            "fov_horiz": self.fov_horiz,
            "use_normal": self.use_normal,
            "vert": self.vert,
            "horiz": self.horiz,
            "view_direction": self.view_direction,
            "view_target": self.view_target,
        }

    def save(self, filename):
        """save zone data to json file"""
        data = self.to_dict()
        with open(filename, "w") as json_file:
            json_file.write(json.dumps(data))

    def to_view(self) -> ZoneView:
        """Return a snapshot of the zone's state for calculation."""
        return ZoneView(
            zone_id=self.zone_id,
            coords=self.geometry.coords,
            num_points=self.geometry.num_points,
            calc_state=self.calc_state,
            update_state=self.update_state,
            calctype=self.calctype,
            fov_vert=self.fov_vert,
            fov_horiz=self.fov_horiz,
            vert=self.vert,
            horiz=self.horiz,
            use_normal=self.use_normal,
            basis=self.basis,
            view_direction=self.view_direction,
            view_target=self.view_target,
        )

    @abstractmethod
    def export(self, fname=None):
        """Export zone values to file."""
        ...

    @abstractmethod
    def plot(self, **kwargs):
        """Plot the zone values."""
        ...

    def copy(self, **kwargs):
        """
        create a fresh copy of this calc zone
        """
        dct = self.to_dict()
        # replace dict with keyword args if any
        for key, val in dct.items():
            new_val = kwargs.get(key, None)
            if new_val is not None:
                dct[key] = new_val
        return type(self).from_dict(dct)

    def set_value_type(self, dose):
        """
        if true get_values() will return in dose (mJ/cm2) over hours
        otherwise get_values() will return in irradiance or fluence (uW/cm2)
        """
        if type(dose) is not bool:
            raise TypeError("Dose must be either True or False")
        self.dose = dose

    def set_dose_time(self, hours=None, minutes=None, seconds=None):
        """Set the exposure time for dose calculation."""
        self._exposure_time = self._parse_exposure_time(hours, minutes, seconds, default=False)

    @property
    def exposure_time(self) -> timedelta:
        return self._exposure_time

    @exposure_time.setter
    def exposure_time(self, value: timedelta):
        if not isinstance(value, timedelta):
            raise TypeError("exposure_time must be a timedelta")
        self._exposure_time = value

    @property
    def hours(self) -> float:
        return self._exposure_time.total_seconds() / 3600

    @property
    def minutes(self) -> float:
        return self._exposure_time.total_seconds() / 60

    @property
    def seconds(self) -> float:
        return self._exposure_time.total_seconds()


    def convert_units(self, old_units, new_units):
        """Convert geometry coordinates without invalidating calculated values."""
        if self._geometry is not None:
            self._geometry = self._geometry._convert_units(old_units, new_units)
            # Update cached calc_state to match new geometry, preventing unnecessary recalc
            if self.calculator.cache.calc_state is not None:
                self.calculator.cache = replace(
                    self.calculator.cache,
                    calc_state=self.calc_state,
                    update_state=self.update_state,
                )

    def set_dimensions(self, x1=None, x2=None, y1=None, y2=None, z1=None, z2=None):
        if self.geometry is not None:
            mins = self._strip_tpl(
                self._coalesce(x1, self.geometry.x1),
                self._coalesce(y1, self.geometry.y1),
                self._coalesce(z1, self.geometry.z1),
            )
            maxs = self._strip_tpl(
                self._coalesce(x2, self.geometry.x2),
                self._coalesce(y2, self.geometry.y2),
                self._coalesce(z2, self.geometry.z2),
            )
            self.geometry = self.geometry.update_dimensions(mins=mins, maxs=maxs)
        return self

    def set_spacing(self, x_spacing=None, y_spacing=None, z_spacing=None):
        """
        set the spacing desired in the dimension
        """
        if self.geometry is not None:
            spacing = self._strip_tpl(
                self._coalesce(x_spacing, self.geometry.x_spacing),
                self._coalesce(y_spacing, self.geometry.y_spacing),
                self._coalesce(z_spacing, self.geometry.z_spacing),
            )
            self.geometry = self.geometry.update(spacing_init=spacing)
        return self

    def set_num_points(self, num_x=None, num_y=None, num_z=None):
        """
        set the number of points desired in a dimension, instead of setting the spacing
        """
        if self.geometry is not None:
            num_points_init = self._strip_tpl(
                self._coalesce(num_x, self.geometry.num_x),
                self._coalesce(num_y, self.geometry.num_y),
                self._coalesce(num_z, self.geometry.num_z),
            )
            self.geometry = self.geometry.update(num_points_init=num_points_init)
        return self

    def set_offset(self, offset):
        if self.geometry is not None:
            self.geometry = self.geometry.update(offset=offset)
        return self

    @staticmethod
    def _strip_tpl(*args):
        return tuple(val for val in args if val is not None)

    @staticmethod
    def _coalesce(value, default):
        return default if value is None else value

    def calculate_values(self, lamps, surfaces=None, enable_occlusion=True,
                         enable_reflectance=True, hard=False):
        """Calculate all the values for all the lamps."""

        if self.enabled:
            zv = self.to_view()

            self.result.base_values = self.calculator.compute(
                lamps=lamps, zv=zv, surfaces=surfaces,
                enable_occlusion=enable_occlusion, hard=hard
            )

            if surfaces and enable_reflectance:
                self.result.reflected_values = self.calculator.compute_reflectance(
                    surfaces=surfaces, zv=zv,
                    enable_occlusion=enable_occlusion, hard=hard
                )
            else:
                self.result.reflected_values = None

        return self.get_values()

    def get_values(self):
        """return dose-adjusted values"""
        if self.result.values is None:
            return None
        if self.dose:
            return self.result.values * self._exposure_time.total_seconds() / 1e3
        return self.result.values

    def get_statistics(self) -> dict | None:
        """Return NaN-aware statistics for calculated values.

        Returns:
            Dict with min, max, mean, std, max_min, avg_min keys,
            or None if uncalculated.
        """
        values = self.get_values()
        if values is None:
            return None
        mn, mx, avg = float(np.min(values)), float(np.max(values)), float(np.mean(values))
        return {
            "min": mn, "max": mx, "mean": avg,
            "std": float(np.std(values)),
            "max_min": mx / mn if mn != 0 else float("inf"),
            "avg_min": avg / mn if mn != 0 else float("inf"),
        }

    @property
    def lamp_cache(self):
        """lamp_values are stored here"""
        return self.calculator.cache.lamp_cache

    @property
    def base_values(self):
        return self.result.base_values

    @property
    def reflected_values(self):
        return self.result.reflected_values

    @property
    def values(self):
        return self.result.values

    @staticmethod
    def _parse_exposure_time(hours=None, minutes=None, seconds=None, default=True):
        """Parse time params into a timedelta. Values are summed."""
        h = hours or 0
        m = minutes or 0
        s = seconds or 0
        for name, val in [("hours", h), ("minutes", m), ("seconds", s)]:
            if not isinstance(val, (int, float)):
                raise TypeError(f"{name} must be numeric")
            if val < 0:
                raise ValueError(f"{name} must be non-negative")
        if h == 0 and m == 0 and s == 0:
            if default:
                return timedelta(hours=8)
            raise ValueError("Specify at least one of hours, minutes, seconds")
        return timedelta(hours=h, minutes=m, seconds=s)


class CalcVol(CalcZone):
    """Volumetric calculation zone for three-dimensional calculations."""

    _grid_cls = VolumeGrid

    def __init__(self, zone_id=None, geometry=None, **kwargs):
        super().__init__(zone_id=zone_id or "CalcVol", **kwargs)
        if geometry is None:
            geometry = VolumeGrid.from_polygon(
                Polygon2D.rectangle(6.0, 4.0), z_height=2.7, offset=True,
            )
        self.geometry = geometry

    @property
    def calctype(self) -> str:
        return "Volume"

    def __repr__(self):
        return f"CalcVol({self._repr_base()}, geometry={self.geometry!r})"

    @classmethod
    def from_dims(
        cls,
        dims: "RoomDimensions",
        spacing: float | None = None,
        num_points: int | None = None,
        offset: bool = True,
        **kwargs,
    ):
        """Create a CalcVol from room dimensions."""
        geometry = VolumeGrid.from_polygon(
            polygon=dims.polygon,
            z_height=dims.z,
            spacing_init=spacing,
            num_points_init=num_points,
            offset=offset,
        )
        return cls(geometry=geometry, **kwargs)

    def nudge_into_bounds(self, room_dims) -> bool:
        """Shift volume zone into room bounds. Returns True if changed."""
        if self.geometry is None:
            return False
        room_poly = room_dims.polygon
        rz = room_dims.z
        origin = np.asarray(self.geometry.origin, float)
        u_hat, v_hat = self.geometry.u_hat, self.geometry.v_hat

        # Check footprint vertices in world space
        dx, dy = 0.0, 0.0
        for s, t in self.geometry.polygon.vertices:
            pt = origin + s * u_hat + t * v_hat
            wx, wy = float(pt[0]), float(pt[1])
            if not room_poly.contains_point_inclusive(wx, wy):
                nx, ny = room_poly.nearest_boundary_point(wx, wy)
                if abs(nx - wx) > abs(dx):
                    dx = nx - wx
                if abs(ny - wy) > abs(dy):
                    dy = ny - wy

        # Clamp Z to [0, rz]
        new_z = max(0.0, origin[2])
        new_depth = min(self.geometry.depth, rz - new_z)

        if (abs(dx) < 1e-9 and abs(dy) < 1e-9
                and new_z == origin[2] and new_depth == self.geometry.depth):
            return False

        self.geometry = self.geometry.update(
            origin=(origin[0] + dx, origin[1] + dy, new_z),
            depth=new_depth,
        )
        return True

    def export(self, fname=None):
        """export values to csv"""
        return export_volume(self, fname=fname)

    def plot(self, **kwargs):
        """plot fluence values as isosurface"""
        return plot_volume(self, **kwargs)

    def plot_volume(self, **kwargs):
        """alias for plot() -- kept in for compatibility"""
        return self.plot(**kwargs)


class CalcPlane(CalcZone):
    """Planar calculation zone for two-dimensional calculations at a specific height."""

    _grid_cls = SurfaceGrid

    def __init__(self, zone_id=None, geometry=None, **kwargs):
        super().__init__(zone_id=zone_id or "CalcPlane", **kwargs)
        if geometry is None:
            geometry = SurfaceGrid.from_polygon(
                Polygon2D.rectangle(6.0, 4.0), height=0, direction=1, offset=True,
            )
        self.geometry = geometry

    @property
    def calctype(self):
        return "Plane"

    def __repr__(self):
        return (
            f"CalcPlane({self._repr_base()}, geometry={self.geometry!r}, "
            f"fov=({self.fov_horiz}h, {self.fov_vert}v), "
            f"flags=(vert={self.vert}, horiz={self.horiz}, use_normal={self.use_normal}))"
        )

    def set_calc_mode(self, value: str):
        """Set all calculation flags from a named calc mode.

        After calling this, individual flags can be overridden, which may
        cause the derived calc_mode to become "custom".
        """
        ct = PlaneCalcMode.from_token(value)
        if ct is PlaneCalcMode.CUSTOM:
            raise ValueError(
                "Cannot set calc_mode to 'custom' — set individual flags instead"
            )
        spec = ct.spec
        self.horiz = spec.horiz
        self.vert = spec.vert
        self.use_normal = spec.use_normal
        self.fov_vert = spec.fov_vert
        self.fov_horiz = spec.fov_horiz
        self.view_direction = spec.view_direction
        self.view_target = spec.view_target
        return self

    @classmethod
    def from_points(
        cls,
        p0,
        pU,
        pV,
        num_points_init=None,
        spacing=None,
        offset=True,
        **kwargs,
    ):
        """define a calc plane from an arbitrary origin, U point, and V point"""
        geometry = SurfaceGrid.from_points(
            p0=p0,
            pU=pU,
            pV=pV,
            spacing_init=spacing,
            num_points_init=num_points_init,
            offset=offset,
        )
        return cls(geometry=geometry, **kwargs)

    @classmethod
    def from_face(
        cls,
        wall: str,
        dims: "RoomDimensions",
        normal_offset: float = 0.0,
        spacing: float | None = None,
        num_points: int | None = None,
        offset: bool = True,
        **kwargs,
    ):
        if wall.lower() not in dims.faces.keys():
            raise KeyError(
                f"{wall} is not a valid wall ID, must be in {dims.faces.keys()}"
            )

        face = dims.faces[wall]
        geometry = face.to_grid(
            normal_offset=normal_offset,
            spacing_init=spacing,
            num_points_init=num_points,
            offset=offset,
        )
        return cls(geometry=geometry, **kwargs)

    @classmethod
    def from_polygon(
        cls,
        polygon: "Polygon2D | list[tuple[float, float]]",
        height: float = 0.0,
        direction: int = 1,
        spacing: float | None = None,
        num_points: int | None = None,
        offset: bool = True,
        **kwargs,
    ):
        """Create a CalcPlane from a polygon at a specified height."""
        if not isinstance(polygon, Polygon2D):
            polygon = Polygon2D(vertices=tuple(tuple(v) for v in polygon))

        geometry = SurfaceGrid.from_polygon(
            polygon=polygon,
            height=height,
            spacing_init=spacing,
            num_points_init=num_points,
            offset=offset,
            direction=direction,
        )
        return cls(geometry=geometry, **kwargs)

    def nudge_into_bounds(self, room_dims) -> bool:
        """Shift plane zone into room bounds. Returns True if changed."""
        if self.geometry is None:
            return False
        room_poly = room_dims.polygon
        rz = room_dims.z

        dx, dy, dz = 0.0, 0.0, 0.0
        for cx, cy, cz in self.geometry.boundary_vertices:
            if not room_poly.contains_point_inclusive(cx, cy):
                nx, ny = room_poly.nearest_boundary_point(cx, cy)
                if abs(nx - cx) > abs(dx):
                    dx = nx - cx
                if abs(ny - cy) > abs(dy):
                    dy = ny - cy
            if cz > rz and (rz - cz) < dz:
                dz = rz - cz
            elif cz < 0 and -cz > dz:
                dz = -cz

        if abs(dx) < 1e-9 and abs(dy) < 1e-9 and abs(dz) < 1e-9:
            return False

        origin = self.geometry.origin
        self.geometry = self.geometry.update(
            origin=(origin[0] + dx, origin[1] + dy, origin[2] + dz)
        )
        return True

    def set_height(self, height):
        self.geometry = self.geometry.update_legacy(height=height)
        return self

    def set_ref_surface(self, ref_surface):
        self.geometry = self.geometry.update_legacy(ref_surface=ref_surface)
        return self

    def set_direction(self, direction):
        self.geometry = self.geometry.update_legacy(direction=direction)
        return self

    def export(self, fname=None):
        """export values to csv"""
        return export_plane(self, fname=fname)

    def plot(self, **kwargs):
        """Plot the image of the radiation pattern"""
        return plot_plane(self, **kwargs)

    def plot_plane(self, **kwargs):
        """alias for plot() -- kept in for compatibility"""
        return self.plot(**kwargs)


class CalcPoint(CalcZone):
    """Single-point calculation zone with FOV/view support."""

    _grid_cls = GridPoint

    def __init__(self, zone_id=None, geometry=None, **kwargs):
        if geometry is None:
            geometry = GridPoint()
        super().__init__(zone_id=zone_id or "CalcPoint", **kwargs)
        self.geometry = geometry

    @classmethod
    def at(cls, position=(0.0, 0.0, 0.0), aim_point=None, **kwargs):
        """Create a CalcPoint at a given position aimed at *aim_point*.

        If *aim_point* is ``None``, defaults to ``(pos_x, pos_y, pos_z + 1)``
        (normal pointing straight up).
        """
        if aim_point is None:
            aim_point = (position[0], position[1], position[2] + 1)
        geometry = GridPoint(position=position, aim_point=aim_point)
        return cls(geometry=geometry, **kwargs)

    @property
    def calctype(self):
        return "Point"

    def __repr__(self):
        return f"CalcPoint({self._repr_base()}, position={self.position}, aim_point={self.geometry.aim_point})"

    def move(self, x=None, y=None, z=None, preserve_aim=False):
        """Move to a new position.

        By default the aim_point shifts with the position (normal stays fixed).
        Set preserve_aim=True to keep the aim_point fixed (normal rotates).
        """
        pos = self.position
        new_pos = (
            pos[0] if x is None else x,
            pos[1] if y is None else y,
            pos[2] if z is None else z,
        )
        self.geometry = self.geometry.with_position(new_pos, preserve_aim=preserve_aim)
        return self

    def aim(self, x=None, y=None, z=None):
        """Aim at a point in cartesian space."""
        old = self.geometry.aim_point
        new_aim = (
            old[0] if x is None else x,
            old[1] if y is None else y,
            old[2] if z is None else z,
        )
        self.geometry = self.geometry.with_aim_point(new_aim)
        return self

    def nudge_into_bounds(self, room_dims) -> bool:
        """Clamp point position and aim into room bounds. Returns True if changed."""
        polygon = room_dims.polygon
        rz = room_dims.z
        changed = False

        px, py, pz = self.position
        # XY clamp
        if not polygon.contains_point_inclusive(px, py):
            px, py = polygon.nearest_boundary_point(px, py)
            changed = True
        # Z clamp
        new_pz = max(0.0, min(pz, rz))
        if new_pz != pz:
            changed = True
            pz = new_pz

        if changed:
            self.move(x=px, y=py, z=pz)

        # Also clamp aim_point
        ax, ay, az = self.geometry.aim_point
        aim_changed = False
        if not polygon.contains_point_inclusive(ax, ay):
            ax, ay = polygon.nearest_boundary_point(ax, ay)
            aim_changed = True
        new_az = max(0.0, min(az, rz))
        if new_az != az:
            aim_changed = True
            az = new_az
        if aim_changed:
            self.aim(x=ax, y=ay, z=az)
            changed = True

        return changed

    def export(self, fname=None):
        """Export point data to CSV."""
        return export_point(self, fname=fname)

    def plot(self, **kwargs):
        """Plot the point in 3D with a normal arrow."""
        return plot_point(self, **kwargs)
