import math
import logging
from typing import Optional, List, Tuple


logger = logging.getLogger(__name__)


class Point:
    """
    Represents a point in 3D space with X, Y, and Z coordinates.

    The Point class supports 3D coordinates, with Z defaulting to 0.0 for 2D use cases.
    Each coordinate is validated to ensure it is of type float upon assignment.
    Provides methods to calculate Euclidean distance to another point and convert
    the point to various representations (tuple, list).

    Designed for seamless integration with downstream processing pipelines such as:
    - Polygon, Obb, and Box geometric operations
    - Trajectory tracking and motion analysis
    - Coordinate transformations (scale, translate)
    - Spatial calculations for ADAS and detection systems

    All coordinates are maintained in normalized space (0.0-1.0) relative to the
    original image dimensions, making instances resolution-independent.

    Attributes:
        X (float): The X coordinate of the point (0.0-1.0).
        Y (float): The Y coordinate of the point (0.0-1.0).
        Z (float): The Z coordinate of the point (0.0-1.0, default 0.0).

    Note:
        - All coordinates use normalized space (0.0-1.0)
        - Z coordinate defaults to 0.0 for 2D images
        - Coordinates are validated on assignment (raises ValueError if out of range)
        - Use safe() for automatic clamping without exceptions
    """

    def __init__(self, x: float, y: float, z: float = 0.0):
        """
        Initializes a Point with the given X, Y, and optional Z coordinates.

        Args:
            x (float): The X coordinate. Must be in range [0.0, 1.0].
            y (float): The Y coordinate. Must be in range [0.0, 1.0].
            z (float, optional): The Z coordinate. Defaults to 0.0.
                Must be in range [0.0, 1.0].

        Raises:
            TypeError: If x, y, or z are not of type float.
            ValueError: If x, y, or z are outside range [0.0, 1.0].

        Note:
            - For automatic clamping without exceptions, use safe()
            - Z coordinate is typically 0.0 for 2D image processing
            - All coordinates are stored as private attributes (_x, _y, _z)
        """
        self.X = x
        self.Y = y
        self.Z = z

    def __str__(self) -> str:
        """
        Returns a human-readable string representation of the point.

        Returns:
            str: String in the format 'Point(X=x, Y=y, Z=z)'.
        """
        return f"Point(X={self.X}, Y={self.Y}, Z={self.Z})"

    def __repr__(self) -> str:
        """
        Returns a detailed string representation suitable for debugging and reproduction.

        The returned string can be used to recreate the object in code.

        Returns:
            str: String representation that includes the class name and coordinates.
        """
        return f"Point(x={self.X!r}, y={self.Y!r}, z={self.Z!r})"

    def __eq__(self, other: object) -> bool:
        """
        Checks if two points are equal.

        Two points are considered equal if all three coordinates (X, Y, Z)
        have exactly equal values.

        Args:
            other (object): Other object to compare.

        Returns:
            bool: True if points have same coordinates, False otherwise.

        Note:
            - Uses exact equality (==), not approximate
            - For floating point comparison with tolerance, use custom logic
            - Z coordinate is included in comparison
        """
        if not isinstance(other, Point):
            return False
        return self.X == other.X and self.Y == other.Y and self.Z == other.Z

    @property
    def X(self) -> float:
        """
        float: Gets the X coordinate of the point.

        Returns:
            float: X coordinate in range [0.0, 1.0].

        Note:
            - X represents horizontal position (0=left, 1=right)
            - Validated on assignment (must be float in [0.0, 1.0])
        """
        return self._x

    @X.setter
    def X(self, x: float) -> None:
        """
        Sets the X coordinate of the point.

        Args:
            x (float): The X coordinate. Must be in range [0.0, 1.0].

        Raises:
            TypeError: If x is not of type float.
            ValueError: If x is outside range [0.0, 1.0].

        Note:
            - Use safe() for automatic clamping without exceptions
            - Validation ensures coordinate stays in normalized range
        """
        if not isinstance(x, float):
            raise TypeError(f"'X' must be '<float>', but got {type(x).__name__}")
        if not 0.0 <= x <= 1.0:
            raise ValueError(f"'X' must be in [0.0, 1.0], but got {x}")
        self._x = x

    @property
    def Y(self) -> float:
        """
        float: Gets the Y coordinate of the point.

        Returns:
            float: Y coordinate in range [0.0, 1.0].

        Note:
            - Y represents vertical position (0=top, 1=bottom in image coordinates)
            - Validated on assignment (must be float in [0.0, 1.0])
        """
        return self._y

    @Y.setter
    def Y(self, y: float) -> None:
        """
        Sets the Y coordinate of the point.

        Args:
            y (float): The Y coordinate. Must be in range [0.0, 1.0].

        Raises:
            TypeError: If y is not of type float.
            ValueError: If y is outside range [0.0, 1.0].

        Note:
            - Use safe() for automatic clamping without exceptions
            - Validation ensures coordinate stays in normalized range
        """
        if not isinstance(y, float):
            raise TypeError(f"'Y' must be '<float>', but got {type(y).__name__}")
        if not 0.0 <= y <= 1.0:
            raise ValueError(f"'Y' must be in [0.0, 1.0], but got {y}")
        self._y = y

    @property
    def Z(self) -> float:
        """
        float: Gets the Z coordinate of the point.

        Returns:
            float: Z coordinate in range [0.0, 1.0].

        Note:
            - Z represents depth (typically 0.0 for 2D images)
            - Validated on assignment (must be float in [0.0, 1.0])
        """
        return self._z

    @Z.setter
    def Z(self, z: float) -> None:
        """
        Sets the Z coordinate of the point.

        Args:
            z (float): The Z coordinate. Must be in range [0.0, 1.0].

        Raises:
            TypeError: If z is not of type float.
            ValueError: If z is outside range [0.0, 1.0].

        Note:
            - Use safe() for automatic clamping without exceptions
            - Validation ensures coordinate stays in normalized range
        """
        if not isinstance(z, float):
            raise TypeError(f"'Z' must be '<float>', but got {type(z).__name__}")
        if not 0.0 <= z <= 1.0:
            raise ValueError(f"'Z' must be in [0.0, 1.0], but got {z}")
        self._z = z

    def get_distance(self, point: 'Point') -> float:
        """
        Calculates the Euclidean distance to another point.

        Uses the standard Euclidean distance formula:
            distance = sqrt((x2-x1)² + (y2-y1)² + (z2-z1)²)

        Args:
            point (Point): The other point to calculate distance to.

        Returns:
            float: The Euclidean distance between this point and the given point.
                Always non-negative (0.0 or greater).

        Note:
            - Distance is in normalized coordinates (0.0-1.0)
            - For pixel distance, multiply by image dimensions
            - Z coordinate is included in calculation
        """
        dx = point.X - self.X
        dy = point.Y - self.Y
        dz = point.Z - self.Z
        return math.sqrt(dx * dx + dy * dy + dz * dz)

    def scale(self, sx: float, sy: float, sz: float = 1.0, normalized: bool = True) -> 'Point':
        """
        Scales point coordinates by given factors.

        Args:
            sx, sy, sz: Scale factors. Accepts any real number (int or float).
                        Positive: standard scaling.
                        Negative: reflection/mirroring across origin.
                        Zero: projects/collapses coordinate to 0.
            normalized: If True (default), clamps result to [0.0, 1.0].
                        If False, returns absolute coordinates without clamping.

        Returns:
            Point: Scaled point. Original instance remains unchanged.

        Note:
            When normalized=True and scale is negative, clamping may collapse
            reflected coordinates to 0.0 or 1.0. Use normalized=False for
            transformations that intentionally exit the [0.0, 1.0] range.
        """
        for val, name in [(sx, 'sx'), (sy, 'sy'), (sz, 'sz')]:
            if isinstance(val, bool) or not isinstance(val, (int, float)):
                raise TypeError(f"'{name}' must be numeric, got {type(val).__name__}")

        nx, ny, nz = self.X * sx, self.Y * sy, self.Z * sz
        return Point.safe(nx, ny, nz) if normalized else self._from_raw(nx, ny, nz)

    def translate(self, dx: float, dy: float, dz: float = 0.0, normalized: bool = True) -> 'Point':
        """
        Translates point by given offsets.

        Args:
            dx, dy, dz: Translation offsets (can be negative/positive).
            normalized: If True (default), clamps result to [0.0, 1.0].
                        If False, allows coordinates to exceed bounds (e.g., for ROI expansion).

        Returns:
            Point: Translated point. Original instance remains unchanged.
        """
        for val, name in [(dx, 'dx'), (dy, 'dy'), (dz, 'dz')]:
            if isinstance(val, bool) or not isinstance(val, (int, float)):
                raise TypeError(f"'{name}' must be numeric, got {type(val).__name__}")

        nx, ny, nz = self.X + dx, self.Y + dy, self.Z + dz
        return Point.safe(nx, ny, nz) if normalized else self._from_raw(nx, ny, nz)

    def to_tuple(self) -> tuple:
        """
        Converts the point to a tuple of (X, Y, Z).

        Returns:
            Tuple[float, float, float]: A tuple containing (X, Y, Z) coordinates.

        Note:
            - Z coordinate is included (unlike some 2D representations)
            - Useful for hashable representation (tuples are hashable)
        """
        return (self.X, self.Y, self.Z)

    def to_list(self) -> List[float]:
        """
        Converts the point to a list of its coordinates.

        Returns:
            List[float]: A list in the format [X, Y, Z].

        Note:
            - Z coordinate is included
            - List is mutable (unlike tuple)
            - Useful for NumPy array conversion
        """
        return [self.X, self.Y, self.Z]

    @classmethod
    def safe(cls, x: float, y: float, z: float = 0.0, min_val: float = 0.0, max_val: float = 1.0) -> 'Point':
        """
        Creates a Point with coordinates clamped to [min_val, max_val] range.

        Safely constrains coordinates to specified range without raising exceptions.
        This method bypasses __init__ validation to support custom coordinate systems
        (e.g., pixel space, extended ROI) where values may legitimately exceed [0.0, 1.0].

        Args:
            x (float): X coordinate to clamp.
            y (float): Y coordinate to clamp.
            z (float, optional): Z coordinate to clamp. Defaults to 0.0.
            min_val (float, optional): Minimum allowed value. Defaults to 0.0.
            max_val (float, optional): Maximum allowed value. Defaults to 1.0.

        Returns:
            Point: New Point instance with clamped coordinates.

        Note:
            - Directly assigns to private attributes (_x, _y, _z) to bypass strict [0.0, 1.0] checks
            - No exceptions raised, guaranteed to return a valid Point instance
            - Recommended for processing external/untrusted data or geometric transformations
        """
        point = cls.__new__(cls)
        point._x = float(min_val if x < min_val else (max_val if x > max_val else x))
        point._y = float(min_val if y < min_val else (max_val if y > max_val else y))
        point._z = float(min_val if z < min_val else (max_val if z > max_val else z))
        return point

    @classmethod
    def _from_raw(cls, x: float, y: float, z: float = 0.0) -> 'Point':
        """Internal factory to create Point without __init__ validation or clamping."""
        pt = cls.__new__(cls)
        pt._x = float(x)
        pt._y = float(y)
        pt._z = float(z)
        return pt