# acmenra_cv/instance/box.py

import logging
import numpy as np
from typing import Union

from acmenra_cv.instance.point import Point


logger = logging.getLogger(__name__)


class Box:
    """
    Represents an axis-aligned 3D bounding box defined by six planes.

    The box is designed to work with normalized coordinates (0.0-1.0) from YOLO
    detection models, supporting 2D image projections and 3D spatial calculations.
    Provides geometric properties (center, dimensions, area, volume) and
    transformation methods (scale, translate) for seamless integration with
    downstream processing pipelines.

    Key features:
    - Normalized coordinate system (0.0-1.0) relative to image dimensions
    - Automatic center and dimension calculations
    - Immutable transformation methods (scale, translate return new instances)
    - Conversion utilities for OpenCV and NumPy operations
    - Consistent interface with Polygon and Obb classes

    Coordinate System Note:
        - X axis: 0.0 = left, 1.0 = right
        - Y axis: 0.0 = top, 1.0 = bottom (increases downward)
        - Z axis: 0.0 = back, 1.0 = front (depth, typically 0.0 for 2D)
        - 'top' refers to minimum Y (closer to image top)
        - 'bottom' refers to maximum Y (closer to image bottom)

    Attributes:
        _left (float): Minimum X-coordinate.
        _right (float): Maximum X-coordinate.
        _top (float): Minimum Y-coordinate (closer to image top).
        _bottom (float): Maximum Y-coordinate (closer to image bottom).
        _front (float): Maximum Z-coordinate.
        _back (float): Minimum Z-coordinate.
    """

    def __init__(self,
                 left: float = 0.0, right: float = 0.0,
                 top: float = 0.0, bottom: float = 0.0,
                 front: float = 0.0, back: float = 0.0): # tested (good)
        """
        Initializes a 3D bounding box with the given boundaries.

        Args:
            left (float): Left boundary (X min). Defaults to 0.0.
            right (float): Right boundary (X max). Defaults to 0.0.
            top (float): Top boundary (Y min, closer to image top). Defaults to 0.0.
            bottom (float): Bottom boundary (Y max, closer to image bottom). Defaults to 0.0.
            front (float): Front boundary (Z max). Defaults to 0.0.
            back (float): Back boundary (Z min). Defaults to 0.0.

        Raises:
            TypeError: If any argument is not a float.

        Note:
            - Does not enforce `left < right`, `top < bottom`, etc.
            - Ensure coordinates are logically ordered before spatial calculations
            - For normalized coordinates, keep values in range [0.0, 1.0]
        """
        self.left = left
        self.right = right
        self.top = top
        self.bottom = bottom
        self.front = front
        self.back = back

    def __eq__(self, other: object) -> bool: # tested (good)
        """
        Checks if two boxes are geometrically equal.

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

        Returns:
            bool: True if boxes have identical boundaries.

        Note:
            Uses exact equality (==), not approximate.
            For floating-point tolerance, implement custom comparison.
        """
        if not isinstance(other, Box):
            return False
        return (self.left == other.left and self.right == other.right and
                self.top == other.top and self.bottom == other.bottom and
                self.front == other.front and self.back == other.back)

    def __str__(self) -> str: # tested (good)
        """
        Returns a human-readable string representation of the box.

        Returns:
            str: Formatted string with boundaries and center coordinates.
        """
        return (f"Box(left={self.left:.3f}, right={self.right:.3f}, "
                f"top={self.top:.3f}, bottom={self.bottom:.3f}, "
                f"center=({self.center.X:.3f}, {self.center.Y:.3f}, {self.center.Z:.3f}))")

    def __repr__(self) -> str: # tested (good)
        """
        Returns a detailed string representation suitable for debugging.

        Returns:
            str: Exact representation that can recreate the object.
        """
        return (f"Box(left={self.left!r}, right={self.right!r}, "
                f"top={self.top!r}, bottom={self.bottom!r}, "
                f"front={self.front!r}, back={self.back!r})")

    @property
    def left(self) -> float: # tested (good)
        """float: Gets the left boundary (X min)."""
        return self._left

    @left.setter
    def left(self, left: float) -> None: # tested (good)
        """Sets the left boundary (X min).

        Args:
            left (float): The left boundary.

        Raises:
            TypeError: If left is not of type float.
        """
        if not isinstance(left, float):
            raise TypeError(f"'left' must be '<float>', but got {type(left).__name__}")
        if not 0.0 <= left <= 1.0:
            raise ValueError(f"'left' must be in [0.0, 1.0], but got {left}")
        self._left = left

    @property
    def right(self) -> float: # tested (good)
        """float: Gets the right boundary (X max)."""
        return self._right

    @right.setter
    def right(self, right: float) -> None: # tested (good)
        """Sets the right boundary (X max).

        Args:
            right (float): The right boundary.

        Raises:
            TypeError: If right is not of type float.
        """
        if not isinstance(right, float):
            raise TypeError(f"'right' must be '<float>', but got {type(right).__name__}")
        if not 0.0 <= right <= 1.0:
            raise ValueError(f"'right' must be in [0.0, 1.0], but got {right}")
        self._right = right

    @property
    def top(self) -> float: # tested (good)
        """float: Gets the top boundary (Y max)."""
        return self._top

    @top.setter
    def top(self, top: float) -> None: # tested (good)
        """Sets the top boundary (Y max).

        Args:
            top (float): The top boundary.

        Raises:
            TypeError: If top is not of type float.
        """
        if not isinstance(top, float):
            raise TypeError(f"'top' must be '<float>', but got {type(top).__name__}")
        if not 0.0 <= top <= 1.0:
            raise ValueError(f"'top' must be in [0.0, 1.0], but got {top}")
        self._top = top

    @property
    def bottom(self) -> float: # tested (good)
        """float: Gets the bottom boundary (Y max)."""
        return self._bottom

    @bottom.setter
    def bottom(self, bottom: float) -> None: # tested (good)
        """Sets the bottom boundary (Y min).

        Args:
            bottom (float): The bottom boundary.

        Raises:
            TypeError: If bottom is not of type float.
        """
        if not isinstance(bottom, float):
            raise TypeError(f"'bottom' must be '<float>', but got {type(bottom).__name__}")
        if not 0.0 <= bottom <= 1.0:
            raise ValueError(f"'bottom' must be in [0.0, 1.0], but got {bottom}")
        self._bottom = bottom

    @property
    def front(self) -> float: # tested (good)
        """float: Gets the front boundary (Z max)."""
        return self._front

    @front.setter
    def front(self, front: float) -> None: # tested (good)
        """Sets the front boundary (Z max).

        Args:
            front (float): The front boundary.

        Raises:
            TypeError: If front is not of type float.
        """
        if not isinstance(front, float):
            raise TypeError(f"'front' must be '<float>', but got {type(front).__name__}")
        if not 0.0 <= front <= 1.0:
            raise ValueError(f"'front' must be in [0.0, 1.0], but got {front}")
        self._front = front

    @property
    def back(self) -> float: # tested (good)
        """float: Gets the back boundary (Z min)."""
        return self._back

    @back.setter
    def back(self, back: float) -> None: # tested (good)
        """Sets the back boundary (Z min).

        Args:
            back (float): The back boundary.

        Raises:
            TypeError: If back is not of type float.
        """
        if not isinstance(back, float):
            raise TypeError(f"'back' must be '<float>', but got {type(back).__name__}")
        if not 0.0 <= back <= 1.0:
            raise ValueError(f"'back' must be in [0.0, 1.0], but got {back}")
        self._back = back

    @property
    def center(self) -> Point: # tested (good)
        """Point: Computes and returns the geometric center of the box.

        The center is recalculated every time it's accessed based on current boundaries.

        Returns:
            Point: The center point of the box.
        """
        return Point.safe(x=(self.left + self.right) / 2.0,
                          y=(self.top + self.bottom) / 2.0,
                          z=(self.front + self.back) / 2.0)

    @property
    def bottom_center(self) -> Point: # tested (good)
        """Point: Computes and returns the geometric center of the box.

        The center is recalculated every time it's accessed based on current boundaries.

        Returns:
            Point: The center point of the box.
        """
        return Point.safe(x=(self.left + self.right) / 2.0,
                          y=self.bottom,
                          z=(self.front + self.back) / 2.0)

    @property
    def width(self) -> float: # tested (good)
        """float: Gets the width of the box (right - left)."""
        return abs(self.right - self.left)

    @property
    def height(self) -> float: # tested (good)
        """float: Gets the height of the box (top - bottom)."""
        return abs(self.top - self.bottom)

    @property
    def depth(self) -> float: # tested (good)
        """float: Gets the depth of the box (front - back)."""
        return abs(self.front - self.back)

    def get_area(self) -> float: # TODO needs rebuil tests
        """float: Gets the 2D area of the box (width × height)."""
        return self.width * self.height

    def get_volume(self) -> float: # TODO needs rebuil tests
        """float: Gets the 3D volume of the box (width × height × depth)."""
        return self.width * self.height * self.depth

    def scale(self, sx: float, sy: float, sz: float = 1.0, normalized: bool = True) -> 'Box': # TODO needs rebuil tests
        """
        Returns a new Box scaled by the given factors.

        Args:
            sx, sy, sz: Scale factors. Accepts int or float.
            normalized: If True (default), clamps result to [0.0, 1.0].
                        If False, returns absolute coordinates without clamping.

        Returns:
            Box: Scaled box. Original instance remains unchanged.

        Note:
            When normalized=True and scale is negative, clamping may collapse
            boundaries. Use normalized=False for pixel-space or ROI transformations.
        """
        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__}")

        if normalized:
            return Box.safe(left=self.left * sx, right=self.right * sx,
                            top=self.top * sy, bottom=self.bottom * sy,
                            front=self.front * sz, back=self.back * sz)
        else:
            return self._from_raw(left=self.left * sx, right=self.right * sx,
                                  top=self.top * sy, bottom=self.bottom * sy,
                                  front=self.front * sz, back=self.back * sz)

    def translate(self, dx: float, dy: float, dz: float = 0.0, normalized: bool = True) -> 'Box': # TODO needs rebuil tests
        """
        Returns a new Box translated by the 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., ROI padding).

        Returns:
            Box: Translated box. 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__}")

        if normalized:
            return Box.safe(left=self.left + dx, right=self.right + dx,
                            top=self.top + dy, bottom=self.bottom + dy,
                            front=self.front + dz, back=self.back + dz)
        else:
            return self._from_raw(left=self.left + dx, right=self.right + dx,
                                  top=self.top + dy, bottom=self.bottom + dy,
                                  front=self.front + dz, back=self.back + dz)

    def to_array(self) -> np.ndarray: # TODO needs rebuil tests
        """Converts the Box to a NumPy array.

        Returns:
            np.ndarray: Array of shape (6,) [left, right, top, bottom, front, back]
        """
        return np.array([self.left, self.right, self.top, self.bottom, self.front, self.back])

    def to_absolute_array(self, width: float, height: float) -> np.ndarray: # TODO needs rebuil tests
        """Converts normalized coordinates (0..1) to absolute pixel coordinates.

        Useful for drawing boxes on original images or extracting ROIs.

        Args:
            width (float): Width of the target image in pixels.
            height (float): Height of the target image in pixels.

        Returns:
            np.ndarray: Array of shape (4, 2) with absolute coordinates:
                [[left, top], [right, top], [right, bottom], [left, bottom]]

        Note:
            For 2D images, Z coordinates (front/back) are ignored.
            All values are rounded to integers for pixel alignment.
        """
        return np.array([[self.left * width, self.top * height],
                         [self.right * width, self.top * height],
                         [self.right * width, self.bottom * height],
                         [self.left * width, self.bottom * height]])

    def to_xyxyn(self) -> tuple[float, float, float, float]: # tested (good)
        """
        Converts the Box to YOLO's normalized xyxy format: [x_min, y_min, x_max, y_max].

        Returns:
            tuple: (left, top, right, bottom) in normalized coordinates [0.0, 1.0].

        Note:
            - Matches Ultralytics YOLO 'xyxyn' output format exactly.
            - Z coordinates (front/back) are intentionally excluded as YOLO operates in 2D.
            - Useful for exporting to annotation tools, training pipelines, or API responses.
        """
        return (self.left, self.top, self.right, self.bottom)

    @classmethod
    def from_xyxyn(cls, xyxyn: Union[list, tuple, np.ndarray], front: float = 0.0, back: float = 0.0) -> 'Box': # tested (good)
        """
        Creates a Box from YOLO's normalized xyxy format: [x_min, y_min, x_max, y_max].

        Args:
            xyxyn: Iterable of 4 normalized floats [left, top, right, bottom].
            front (float): Z max coordinate. Defaults to 0.0.
            back (float): Z min coordinate. Defaults to 0.0.

        Returns:
            Box: New instance with boundaries mapped from YOLO format.

        Raises:
            TypeError: If input is not list, tuple, or np.ndarray.
            ValueError: If input length is not 4.

        Note:
            - Directly maps YOLO [x1, y1, x2, y2] to Box boundaries.
            - Designed for seamless ingestion of YOLOv8/v9/v10 normalized predictions.
            - Z-axis preserved via optional parameters to maintain 3D compatibility.
        """
        if not isinstance(xyxyn, (list, tuple, np.ndarray)):
            raise TypeError(f"Expected list, tuple, or np.ndarray, got {type(xyxyn).__name__}")
        if len(xyxyn) != 4:
            raise ValueError(f"Expected 4 values [left, top, right, bottom], got {len(xyxyn)}")
        left, top, right, bottom = xyxyn
        return cls.safe(left=left, top=top, right=right, bottom=bottom, front=front, back=back)

    def to_xywhn(self) -> tuple[float, float, float, float]: # tested (good)
        """
        Converts the Box to YOLO's normalized xywh format: [x_center, y_center, width, height].

        Returns:
            tuple: (center_x, center_y, width, height) in normalized coordinates.

        Note:
            - Matches Ultralytics YOLO 'xywhn' output format.
            - Width/height are calculated as (right-left) and (bottom-top).
            - Assumes logically ordered boundaries (left <= right, top <= bottom).
        """
        width = self.right - self.left
        height = self.bottom - self.top
        return (self.left + width / 2.0, self.top + height / 2.0,
                width, height)

    @classmethod
    def from_xywhn(cls, xywhn: Union[list, tuple, np.ndarray], front: float = 0.0, back: float = 0.0) -> 'Box': # tested (good)
        """
        Creates a Box from YOLO's normalized xywh format: [x_center, y_center, width, height].

        Args:
            xywhn: Iterable of 4 normalized floats [center_x, center_y, width, height].
            front (float): Z max coordinate. Defaults to 0.0.
            back (float): Z min coordinate. Defaults to 0.0.

        Returns:
            Box: New instance with boundaries calculated from center/size format.

        Raises:
            TypeError: If input is not list, tuple, or np.ndarray.
            ValueError: If input length is not 4.

        Note:
            - Converts center-size representation back to 6-plane boundary format.
            - Compatible with YOLO dataset annotations and model outputs.
            - Handles inverted width/height gracefully if YOLO outputs negative deltas.
        """
        if not isinstance(xywhn, (list, tuple, np.ndarray)):
            raise TypeError(f"Expected list, tuple, or np.ndarray, got {type(xywhn).__name__}")
        if len(xywhn) != 4:
            raise ValueError(f"Expected 4 values [cx, cy, w, h], got {len(xywhn)}")
        center_x, center_y, width, height = xywhn
        return cls.safe(left=center_x - width / 2.0, right=center_x + width / 2.0,
                        top=center_y - height / 2.0, bottom=center_y + height / 2.0,
                        front=front, back=back)

    def to_xyzxyzn(self) -> tuple[float, float, float, float, float, float]: # tested (good)
        """
        Converts the 3D Box to normalized corner format: [x1, y1, z1, x2, y2, z2].

        Returns:
            tuple: (left, top, front, right, bottom, back) in normalized coordinates.

        Note:
            - Matches standard 3D detection formats (KITTI, NuScenes, 3D-YOLO).
            - front = Z max (closer to camera), back = Z min (farther from camera).
            - Fully reversible via from_xyzxyzn().
        """
        return (self.left, self.top, self.front, self.right, self.bottom, self.back)

    @classmethod
    def from_xyzxyzn(cls, xyzxyzn: Union[list, tuple, np.ndarray]) -> 'Box': # tested (good)
        """
        Creates a 3D Box from normalized corner coordinates [x1, y1, z1, x2, y2, z2].

        Args:
            xyzxyzn: Iterable of 6 floats [left, top, front, right, bottom, back].

        Returns:
            Box: New 3D instance.

        Raises:
            TypeError: If input is not list, tuple, or np.ndarray.
            ValueError: If input length is not 6.

        Note:
            - Direct mapping to boundary planes. No assumptions about ordering required.
            - Ideal for 3D annotation tools, depth-aware pipelines, and robotics state export.
        """
        if not isinstance(xyzxyzn, (list, tuple, np.ndarray)):
            raise TypeError(f"Expected list, tuple, or np.ndarray, got {type(xyzxyzn).__name__}")
        if len(xyzxyzn) != 6:
            raise ValueError(f"Expected 6 values [x1, y1, z1, x2, y2, z2], got {len(xyzxyzn)}")
        left, top, front, right, bottom, back = xyzxyzn
        return cls.safe(left=left, top=top, front=front, right=right, bottom=bottom, back=back)

    def to_xyzwhdn(self) -> tuple[float, float, float, float, float, float]: # tested (good)
        """
        Converts the 3D Box to normalized center-size format: [cx, cy, cz, w, h, d].

        Returns:
            tuple: (center_x, center_y, center_z, width, height, depth) in normalized coordinates.

        Note:
            - Standard format for 3D object detection and robotic spatial reasoning.
            - width = right-left, height = bottom-top, depth = front-back.
            - Z-axis follows depth convention: higher Z = closer to camera.
        """
        return (self.center.X, self.center.Y, self.center.Z,
                self.width, self.height, self.depth)

    @classmethod
    def from_xyzwhdn(cls, xyzwhdn: Union[list, tuple, np.ndarray]) -> 'Box': # tested (good)
        """
        Creates a 3D Box from normalized center-size format [cx, cy, cz, w, h, d].

        Args:
            xywhd: Iterable of 6 floats [center_x, center_y, center_z, width, height, depth].

        Returns:
            Box: New 3D instance.

        Raises:
            TypeError: If input is not list, tuple, or np.ndarray.
            ValueError: If input length is not 6.

        Note:
            - Reconstructs 6 boundary planes from center and dimensions.
            - Compatible with 3D model outputs, point-cloud bounding boxes, and simulation engines.
            - Negative width/height/depth are handled gracefully (planes swap automatically).
        """
        if not isinstance(xyzwhdn, (list, tuple, np.ndarray)):
            raise TypeError(f"Expected list, tuple, or np.ndarray, got {type(xyzwhdn).__name__}")
        if len(xyzwhdn) != 6:
            raise ValueError(f"Expected 6 values [cx, cy, cz, w, h, d], got {len(xyzwhdn)}")
        center_x, center_y, center_z, width, height, depth = xyzwhdn
        return cls.safe(left=center_x - width / 2.0, right=center_x + width / 2.0,
                        top=center_y - height / 2.0, bottom=center_y + height / 2.0,
                        front=center_z + depth / 2.0, back=center_z - depth / 2.0)

    @classmethod
    def safe(cls,
             left: float = 0.0, right: float = 0.0,
             top: float = 0.0, bottom: float = 0.0,
             front: float = 0.0, back: float = 0.0,
             min_val: float = 0.0, max_val: float = 1.0) -> 'Box': # tested (good)
        """
        Creates a Box with coordinates clamped to [min_val, max_val] range.

        Bypasses __init__ validation to avoid double-checking and support custom ranges.

        Note:
            - No exceptions raised. Coordinates are clamped independently per axis.
            - Recommended for geometric transformations that may exit normalized space.
        """
        def clamp(v: float) -> float:
            return float(min_val if v < min_val else (max_val if v > max_val else v))

        box = cls.__new__(cls)
        box._left = clamp(left)
        box._right = clamp(right)
        box._top = clamp(top)
        box._bottom = clamp(bottom)
        box._front = clamp(front)
        box._back = clamp(back)
        return box

    @classmethod
    def _from_raw(cls,
                  left: float, right: float,
                  top: float, bottom: float,
                  front: float, back: float) -> 'Box': # tested (good)
        """Internal factory to create Box without __init__ validation or clamping."""
        box = cls.__new__(cls)
        box._left = float(left)
        box._right = float(right)
        box._top = float(top)
        box._bottom = float(bottom)
        box._front = float(front)
        box._back = float(back)
        return box


