# 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, get_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 min, closer to image top)."""
        return self._top

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

        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, closer to image bottom)."""
        return self._bottom

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

        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, closer to camera)."""
        return self._front

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

        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, farther from camera)."""
        return self._back

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

        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 the bottom-center point of the box.

        Used for trajectory tracking (represents the "foot" of the object).
        Recalculated every time it's accessed based on current boundaries.

        Returns:
            Point: The bottom-center point (X center, Y bottom, Z center).
        """
        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 (bottom - top)."""
        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:
            xyzwhdn: 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


