import logging
import math

import numpy as np
from typing import List, Iterator, Union, Optional

from acmenra_cv.instance.point import Point


logger = logging.getLogger(__name__)


class Obb:
    """
    Represents an Oriented Bounding Box (OBB) — a rotated rectangle defined by 4 corner points.

    This class is designed to work with YOLO OBB detection models (YOLOv8/v11 OBB).
    It combines features from both Box (geometric properties) and Polygon (point iteration).

    Key differences from Polygon:
    - Exactly 4 points (enforced)
    - Points must be in clockwise order: [top-left, top-right, bottom-right, bottom-left]
    - Provides Box-like properties: center, width, height, angle, area
    - Provides Box-like boundaries: left, right, top, bottom (axis-aligned bounding box)

    Key differences from Box:
    - Supports rotation (angle)
    - Defined by 4 corner points, not 6 planes
    - 2D only (no Z-axis)

    Designed for seamless integration with downstream processing pipelines such as:
    - ADAS zone analysis and collision detection
    - Visualization and rendering utilities
    - Geometric transformations (scale, translate)
    - Business logic for event detection

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

    Attributes:
        _points (List[Point]): Internal storage of exactly 4 corner points (copy).
        _angle (Optional[float]): Cached rotation angle in degrees (computed on demand).

    Note:
        - All coordinates use normalized space (0.0-1.0)
        - Points are stored as a copy to prevent external modifications
        - Z coordinate is supported but typically 0.0 for 2D images
        - Points should be in clockwise order for correct angle calculation
    """

    def __init__(self, points: List[Point]):
        """
        Initializes an Obb with exactly 4 corner points.

        A copy of the input list is stored to prevent external modifications.
        Points should be in clockwise order: [top-left, top-right, bottom-right, bottom-left]

        Args:
            points (List[Point]): List of exactly 4 Point objects representing corners.

        Raises:
            TypeError: If any element in points is not an instance of Point.
            ValueError: If points does not contain exactly 4 points.

        Note:
            - For best results, points should form a valid rectangle (parallel opposite sides)
            - However, this is not enforced — any 4-point quadrilateral is accepted
            - Points are stored as a shallow copy
        """
        if not isinstance(points, list) or len(points) != 4:
            raise ValueError(
                f"'points' must be a list of exactly 4 Points, got {len(points) if isinstance(points, list) else type(points).__name__}")

        for i, point in enumerate(points):
            if not isinstance(point, Point):
                raise TypeError(f"'points[{i}]' must be '<Point>', but got {type(point).__name__}")

        self._points = points[:]  # копия
        self._angle: Optional[float] = None

    def __len__(self) -> int:
        """
        Returns the number of vertices in the OBB (always 4).

        Returns:
            int: Always returns 4.

        Note:
            OBB always has exactly 4 corner points by definition.
        """
        return 4

    def __iter__(self) -> Iterator[Point]:
        """
        Returns an iterator over the OBB's points in clockwise order.

        Allows the OBB to be used in loops and comprehensions.

        Returns:
            Iterator[Point]: Iterator yielding each Point in order.
        """
        return iter(self._points)

    def __getitem__(self, index: int) -> Point:
        """
        Gets a point by index.

        Args:
            index (int): Index (0-3). Negative indices supported (-1 = last point).

        Returns:
            Point: A single point.

        Raises:
            IndexError: If index is out of range (0-3).

        Note:
            Slicing not supported (always 4 points).
        """
        return self._points[index]

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

        Two OBBs are considered equal if they have the same 4 points
        and all corresponding points have equal coordinates.

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

        Returns:
            bool: True if OBBs have same points in same order.

        Note:
            This does NOT account for:
            - Different starting points (rotated vertex order)
            - Reverse order (clockwise vs counter-clockwise)
            - Floating point precision issues

            For geometric equality, use custom comparison logic.
        """
        if not isinstance(other, Obb):
            return False
        return all(p1.X == p2.X and p1.Y == p2.Y and p1.Z == p2.Z for p1, p2 in zip(self._points, other._points))

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

        Returns:
            str: Exact representation with center and angle.
        """
        return f"Obb(points=4, center=({self.center.X:.3f}, {self.center.Y:.3f}), angle={self.angle:.1f}°)"

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

        Returns:
            str: Formatted string with center, dimensions, and angle.
        """
        return f"Obb(center=({self.center.X:.3f}, {self.center.Y:.3f}), width={self.width:.3f}, height={self.height:.3f}, angle={self.angle:.1f}°)"

    @property
    def points(self) -> List[Point]:
        """
        Returns a shallow copy of the internal list of points.

        This prevents external code from modifying the internal state directly.

        Returns:
            List[Point]: A copy of the list of 4 points.

        Note:
            - Modifying the returned list does not affect the OBB
            - For direct access, use iteration or __getitem__
        """
        return self._points[:]

    @property
    def left(self) -> float:
        """
        Gets the left boundary (X min) of the axis-aligned bounding box.

        Returns:
            float: Minimum X coordinate among all 4 points.

        Note:
            Useful for bounding box calculations and collision detection.
        """
        return min(p.X for p in self._points)

    @property
    def right(self) -> float:
        """
        Gets the right boundary (X max) of the axis-aligned bounding box.

        Returns:
            float: Maximum X coordinate among all 4 points.

        Note:
            Useful for bounding box calculations and collision detection.
        """
        return max(p.X for p in self._points)

    @property
    def top(self) -> float:
        """
        Gets the top boundary (Y max) of the axis-aligned bounding box.

        Returns:
            float: Maximum Y coordinate among all 4 points.

        Note:
            In image coordinates, Y increases downward (0=top, 1=bottom).
        """
        return min(p.Y for p in self._points)

    @property
    def bottom(self) -> float:
        """
        Gets the bottom boundary (Y min) of the axis-aligned bounding box.

        Returns:
            float: Minimum Y coordinate among all 4 points.

        Note:
            In image coordinates, Y increases downward (0=top, 1=bottom).
        """
        return max(p.Y for p in self._points)

    @property
    def width(self) -> float:
        """
        Gets the width of the OBB (longer side).

        Calculated as the average of the two horizontal sides.

        Returns:
            float: Width in normalized coordinates (0.0-1.0).

        Note:
            Consistent with Box.width and Polygon.width for unified interface.
        """
        side1 = math.sqrt((self._points[1].X - self._points[0].X) ** 2 +  (self._points[1].Y - self._points[0].Y) ** 2)
        side2 = math.sqrt((self._points[2].X - self._points[3].X) ** 2 + (self._points[2].Y - self._points[3].Y) ** 2)
        return (side1 + side2) / 2.0

    @property
    def height(self) -> float:
        """
        Gets the height of the OBB (shorter side).

        Calculated as the average of the two vertical sides.

        Returns:
            float: Height in normalized coordinates (0.0-1.0).

        Note:
            Consistent with Box.height and Polygon.height for unified interface.
        """
        side1 = math.sqrt((self._points[3].X - self._points[0].X) ** 2 + (self._points[3].Y - self._points[0].Y) ** 2)
        side2 = math.sqrt((self._points[2].X - self._points[1].X) ** 2 +  (self._points[2].Y - self._points[1].Y) ** 2)
        return (side1 + side2) / 2.0

    @property
    def center(self) -> Point:
        """
        Gets the geometric center (centroid) of the OBB.

        Calculated as the average of all 4 corner points.

        Returns:
            Point: Center point with coordinates (avg_x, avg_y, avg_z).

        Note:
            - Consistent with Box.center and Polygon.center for unified interface
            - For trajectory tracking, use bottom_center instead
            - Z coordinate is typically 0.0 for 2D images
        """
        return Point.safe(x=sum(p.X for p in self._points) / 4.0,
                          y=sum(p.Y for p in self._points) / 4.0,
                          z=sum(p.Z for p in self._points) / 4.0)

    @property
    def bottom_center(self) -> Point:
        """
        Gets the bottom center point of the OBB.

        Calculated as (center.X, bottom, 0.0).

        Returns:
            Point: Bottom center point for trajectory tracking.

        Note:
            - Consistent with Box.bottom_center and Polygon.bottom_center
            - Used for trajectory tracking in systems
            - Represents the point closest to the bottom of the image
        """
        return Point.safe(x=self.center.X, y=self.bottom, z=0.0)

    @property
    def angle(self) -> float:
        """
        Gets the rotation angle of the OBB in degrees.

        The angle is calculated from the top edge (top-left to top-right).
        Range: -90° to +90° (0° = horizontal, +90° = vertical clockwise)

        Returns:
            float: Rotation angle in degrees.

        Note:
            - Angle is cached after first calculation for performance
            - For axis-aligned boxes, angle will be close to 0°
            - Positive angle = clockwise rotation
        """
        if self._angle is not None:
            return self._angle

        angle_rad = math.atan2(self._points[1].Y - self._points[0].Y,
                               self._points[1].X - self._points[0].X)
        angle_deg = math.degrees(angle_rad)

        if angle_deg > 90:
            angle_deg -= 180
        elif angle_deg < -90:
            angle_deg += 180

        self._angle = angle_deg
        return self._angle

    @property
    def area(self) -> float:
        """
        Gets the area of the OBB.

        Calculated as width × height.

        Returns:
            float: Area in normalized square units (0.0-1.0).

        Note:
            - Consistent with Polygon.get_area() for unified interface
            - For OBB, this is more accurate than shoelace formula
        """
        return self.width * self.height

    def get_area(self) -> float:
        """
        Calculates the area of the OBB.

        Alias for area property (for consistency with Polygon).

        Returns:
            float: Area in normalized square units (0.0-1.0).
        """
        return self.area

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

        Args:
            sx, sy, sz: Scale factors. Accepts 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:
            Obb: Scaled OBB. Original instance remains unchanged.

        Note:
            Delegates to Point.scale() for consistent coordinate handling.
            When normalized=True and scale is negative, clamping may collapse boundaries.
        """
        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__}")

        return Obb([point.scale(sx=sx, sy=sy, sz=sz, normalized=normalized) for point in self._points])

    def translate(self, dx: float, dy: float, dz: float = 0.0, normalized: bool = True) -> 'Obb':
        """
        Returns a new Obb 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:
            Obb: Translated OBB. 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__}")

        return Obb([point.translate(dx=dx, dy=dy, dz=dz, normalized=normalized) for point in self._points])


    def to_array(self) -> np.ndarray:
        """
        Converts the OBB to a NumPy array of shape (4, 2).

        Coordinates are in the same space as the original points (usually normalized).
        Points are in clockwise order: [top-left, top-right, bottom-right, bottom-left]

        Returns:
            np.ndarray: Array of shape (4, 2), where each row is [x, y].

        Note:
            - Z coordinates are not included (2D output)
            - Useful for OpenCV operations
        """
        return np.array([[point.X, point.Y] for point in self._points])

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

        Useful for extracting ROIs or drawing on original images.

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

        Returns:
            np.ndarray: Array of absolute coordinates, shape (4, 2).
                Values are in pixel coordinates (0 to width/height).

        Note:
            - Z coordinates are not included (2D output)
            - Values are not rounded (keep as float for sub-pixel accuracy)
        """
        return np.array([[point.X * width, point.Y * height] for point in self._points])

    @staticmethod
    def from_yolo_obb(xyxyxyxyn: Union[list, tuple, np.ndarray]) -> 'Obb':
        """
        Creates a Polygon from YOLO's masks.xyn output.

        YOLO returns normalized polygon coordinates as a list/array of shape (N, 2).
        This method converts each [x, y] into a Point and builds a Polygon.

        Args:
            xyn (Union[list, tuple, np.ndarray]): Normalized polygon points from YOLO,
                shape (N, 2), where each point is [x, y] in range [0, 1].

        Returns:
            Polygon: A new Polygon instance with normalized Point objects.

        Raises:
            TypeError: If input is not list, tuple, or np.ndarray.
            ValueError: If input shape is not (N, 2).

        Note:
            - Coordinates are expected to be normalized (0.0-1.0)
            - Polygon closure is not enforced (YOLO provides closed polygons)
            - For segmentation masks, use result.masks.xyn
        """
        if isinstance(xyxyxyxyn, (list, tuple)):
            xyn = np.array(xyxyxyxyn)
        elif isinstance(xyxyxyxyn, np.ndarray):
            xyn = xyxyxyxyn
        else:
            raise TypeError(f"Expected list, tuple, or np.ndarray, got {type(xyxyxyxyn).__name__}")

        if xyn.ndim != 2 or xyn.shape[1] != 2:
            raise ValueError(f"Expected array of shape (4, 2), got {xyn.shape}")

        return Obb([Point.safe(x=float(xi), y=float(yi)) for xi, yi in xyn])

