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

from acmenra_cv.instance.point import Point


logger = logging.getLogger(__name__)


class Polygon:
    """
    Represents a 2D polygon composed of a sequence of Point objects.

    This class is designed to work with segmentation masks from models like YOLO,
    particularly using normalized polygon coordinates (e.g., `result.masks.xyn`).
    It provides methods for conversion to arrays, coordinate scaling, and creation
    from YOLO output formats.

    The polygon does not enforce closure (i.e., first != last point) — this is left
    to the source data (e.g., YOLO already provides closed polygons).

    Designed for seamless integration with downstream processing pipelines such as:
    - 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 the polygon's vertices (copy).

    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
    """

    def __init__(self, points: List[Point]): # tested (good)
        """
        Initializes a Polygon with a list of points.

        A copy of the input list is stored to prevent external modifications.

        Args:
            points (List[Point]): List of Point objects representing vertices.
                Must contain at least 3 points for a valid polygon.

        Raises:
            TypeError: If any element in points is not an instance of Point.
            ValueError: If points list is empty.

        Note:
            - Points are stored as a shallow copy
            - Original list can be safely modified after initialization
        """
        self._points = points[:]  # копия

    def __len__(self) -> int: # TODO needs reBuid tests
        """
        Returns the number of vertices in the polygon.

        Returns:
            int: Number of points in the polygon.

        Note:
            For areas, typical values range from 3 (triangles) to 10+ (complex shapes).
        """
        return len(self._points)

    def __iter__(self) -> Iterator[Point]: # TODO needs reBuid tests
        """
        Returns an iterator over the polygon's points.

         Allows the polygon 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: # TODO needs reBuid tests
        """
        Gets a point or slice of points by index.

        Supports both integer indexing and slicing. Slices return a new Polygon.

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

        Returns:
            Point: A single point (for int) or a new Polygon (for slice).

        Raises:
            IndexError: If index is out of range.
        """
        return self._points[index]

    def __eq__(self, other: object) -> bool: # TODO needs reBuid tests
        """
        Checks if two polygons are equal.

        Two polygons are considered equal if they have the same number of points
        and all corresponding points have equal coordinates.

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

        Returns:
            bool: True if polygons 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, Polygon):
            return False
        if len(self._points) != len(other._points):
            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 __str__(self) -> str: # tested (good)
        """
        Returns a human-readable string representation of the polygon.

        Returns:
            str: Formatted string with point count and area.
        """
        return f"Polygon(points={len(self._points)}, area={self.get_area():.4f})"

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

        Returns:
            str: Exact representation with point count and center coordinates.
        """
        return f"Polygon(points={len(self._points)}, center=({self.center.X:.3f}, {self.center.Y:.3f}))"


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

        Returns:
            float: Minimum X coordinate among all points.

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

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

        Returns:
            float: Maximum X coordinate among all points.

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

    @property
    def top(self) -> float: # tested (good)
        """
        Gets the top boundary (Y min) of the polygon.

        Returns:
            float: Minimum Y coordinate among all 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: # tested (good)
        """
        Gets the bottom boundary (Y max) of the polygon.

        Returns:
            float: Maximum Y coordinate among all 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: # tested (good)
        """
        Gets the width of the polygon (right - left).

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

        Note:
            Consistent with Box.width and Obb.width for unified interface.
        """
        return self.right - self.left

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

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

        Note:
            Consistent with Box.height and Obb.height for unified interface.
        """
        return self.bottom - self.top

    @property
    def center(self) -> Point: # tested (good)
        """
        Gets the geometric center (centroid) of the polygon.

        Calculated as the average of all vertex coordinates.

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

        Note:
            - Consistent with Box.center and Obb.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) / len(self._points),
                          y=sum(p.Y for p in self._points) / len(self._points),
                          z=sum(p.Z for p in self._points) / len(self._points))

    @property
    def points(self) -> List[Point]: # TODO needs reBuid tests
        """
        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 points.

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

    def get_area(self) -> float: # TODO needs reBuid tests
        """
        Calculates the area of the polygon using the shoelace formula.

        The shoelace formula (also known as Gauss's area formula) computes
        the area of a polygon given the coordinates of its vertices.

        Returns:
            float: Area in normalized square units (0.0-1.0).
                Always non-negative (absolute value).

        Note:
            - For zones, typical areas range from 0.01 to 0.1
            - Area is in normalized coordinates (multiply by image_width × image_height for pixels)
            - Works for both convex and concave polygons
        """
        n = len(self._points)
        area = 0.0
        for i in range(n):
            j = (i + 1) % n
            area += self._points[i].X * self._points[j].Y
            area -= self._points[j].X * self._points[i].Y
        return abs(area) / 2.0

    def scale(self, sx: float, sy: float, sz: float = 1.0, normalized: bool = True) -> 'Polygon': # TODO needs reBuid tests
        """
        Returns a new Polygon 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:
            Polygon: Scaled polygon. 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 Polygon([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) -> 'Polygon': # TODO needs reBuid tests
        """
        Returns a new Polygon 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:
            Polygon: Translated polygon. 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 Polygon([point.translate(dx=dx, dy=dy, dz=dz, normalized=normalized) for point in self._points])

    def smooth(self, n: int) -> 'Polygon': # TODO needs reBuid tests
        """
        Returns a new Polygon with smoothed vertex positions using moving average.

        Each point is replaced by the average of itself and n neighboring points
        on each side. This reduces noise in polygon boundaries from segmentation.

        Args:
            n (int): Number of neighboring points to average on each side.
                Must be in range [0, len(points)/2].

        Returns:
            Polygon: A new Polygon with smoothed points.
                The original polygon is not modified.

        Raises:
            TypeError: If n is not an integer.
            ValueError: If n is negative or exceeds sequence length.

        Note:
            - Useful for smoothing YOLO segmentation masks
            - Higher n = smoother but less accurate boundaries
            - Original polygon remains unchanged (immutable operation)
        """
        if not isinstance(n, int):
            raise TypeError(f"'n' must be '<int>', but got {type(n).__name__}")
        if n < 0:
            raise ValueError(f"'n' must be > 0, got {n}")
        if n > len(self._points):
            raise ValueError(f"'n' must be < sequence length, got {n}")
        if n == 0:
            return Polygon([point for point in self])

        smoothed_points = []
        for i in range(len(self._points)):
            x_sum = sum(self._points[(i + j) % len(self._points)].X for j in range(-n, n + 1))
            y_sum = sum(self._points[(i + j) % len(self._points)].Y for j in range(-n, n + 1))
            z_sum = sum(self._points[(i + j) % len(self._points)].Z for j in range(-n, n + 1))
            smoothed_points.append(Point.safe(x=x_sum / float(2 * n + 1),
                                              y=y_sum / float(2 * n + 1),
                                              z=z_sum / float(2 * n + 1)))

        return Polygon(smoothed_points)

    def to_absolute_array(self, width: float, height: float) -> np.ndarray: # TODO needs reBuid tests
        """
        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 (N, 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])

    def to_xyn(self) -> np.ndarray:  # tested (good)
        """
        Exports polygon to YOLO normalized mask format: shape (N, 2).

        Returns:
            np.ndarray: Array of shape (N, 2) with normalized [x, y] coordinates.

        Note:
            - Direct drop-in for Ultralytics YOLO result export.
            - Z coordinates are intentionally excluded (2D format).
            - Fully reversible via from_xyn().
        """
        return np.array([[p.X, p.Y] for p in self._points], dtype=float)

    def to_xyzn(self) -> np.ndarray:  # tested (good)
        """
        Exports 3D polygon to normalized xyz array: shape (N, 3).

        Returns:
            np.ndarray: Array of shape (N, 3) with normalized [x, y, z] coordinates.

        Note:
            - For depth-aware pipelines, robotics, and 3D annotation tools.
            - Fully reversible via from_xyzn().
        """
        return np.array([[p.X, p.Y, p.Z] for p in self._points], dtype=float)

    def to_xyxyn(self) -> List[float]:  # tested (good)
        """
        Flattens polygon to normalized xy sequence: [x1, y1, x2, y2, ..., xn, yn].

        Returns:
            List[float]: Flat list of normalized coordinates.

        Note:
            - Compatible with COCO segmentation format and JSON exports.
            - Fully reversible via from_xyxyn().
        """
        return [val for p in self._points for val in (p.X, p.Y)]

    def to_xyzxyzn(self) -> List[float]:  # tested (good)
        """
        Flattens 3D polygon to normalized xyz sequence: [x1, y1, z1, ..., xn, yn, zn].

        Returns:
            List[float]: Flat list of normalized 3D coordinates.

        Note:
            - For serialization to JSON/CSV or interoperability with 3D tools.
            - Fully reversible via from_xyzxyzn().
        """
        return [val for p in self._points for val in (p.X, p.Y, p.Z)]

    @classmethod
    def from_xyn(cls, xyn: Union[list, tuple, np.ndarray]) -> 'Polygon':  # tested (good)
        """
        Creates Polygon directly from YOLO normalized mask array: shape (N, 2).

        Args:
            xyn: Array-like of shape (N, 2) with normalized [x, y] coordinates.
                 Accepts list, tuple, or np.ndarray.

        Returns:
            Polygon: New instance with normalized vertices.

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

        Note:
            - Direct drop-in for result.masks.xyn from Ultralytics YOLO.
            - Coordinates are automatically clamped to [0.0, 1.0] via Point.safe().
            - Usage: poly = Polygon.from_xyn(result.masks.xyn[0])
        """
        if not isinstance(xyn, (list, tuple, np.ndarray)):
            raise TypeError(f"Expected list, tuple, or np.ndarray, got {type(xyn).__name__}")

        arr = np.asarray(xyn, dtype=float)
        if arr.ndim != 2 or arr.shape[1] != 2:
            raise ValueError(f"Expected shape (N, 2) for normalized xy pairs, got {arr.shape}")

        return cls([Point.safe(x=float(x), y=float(y)) for x, y in arr])

    @classmethod
    def from_xyzn(cls, xyzn: Union[list, tuple, np.ndarray]) -> 'Polygon': # tested (good)
        """
        Creates 3D Polygon directly from normalized xyz array: shape (N, 3).

        Args:
            xyzn: Array-like of shape (N, 3) with normalized [x, y, z] coordinates.

        Returns:
            Polygon: New 3D instance with normalized vertices.

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

        Note:
            - For depth-aware pipelines, stereo masks, and robotics.
            - Coordinates clamped to [0.0, 1.0] via Point.safe().
            - Usage: poly3d = Polygon.from_xyzn(depth_mask_xyzn)
        """
        if not isinstance(xyzn, (list, tuple, np.ndarray)):
            raise TypeError(f"Expected list, tuple, or np.ndarray, got {type(xyzn).__name__}")

        arr = np.asarray(xyzn, dtype=float)
        if arr.ndim != 2 or arr.shape[1] != 3:
            raise ValueError(f"Expected shape (N, 3) for normalized xyz triplets, got {arr.shape}")

        return cls([Point.safe(x=float(x), y=float(y), z=float(z)) for x, y, z in arr])

    @classmethod
    def from_xyxyn(cls, xyxyn: Union[list, tuple, np.ndarray]) -> 'Polygon': # tested (good)
        """
        Creates Polygon from flat normalized xy sequence: [x1, y1, x2, y2, ..., xn, yn].

        Args:
            xyxyn: Flat list/tuple/array of even length.

        Returns:
            Polygon: New instance with normalized vertices.

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

        Note:
            - Compatible with COCO segmentation format and JSON exports.
            - Automatically reshapes to (N, 2) internally.
        """
        if not isinstance(xyxyn, (list, tuple, np.ndarray)):
            raise TypeError(f"Expected list, tuple, or np.ndarray, got {type(xyxyn).__name__}")

        arr = np.asarray(xyxyn, dtype=float)
        if arr.ndim != 1 or len(arr) % 2 != 0:
            raise ValueError(f"Expected flat array with even length for xy pairs, got shape {arr.shape}")

        arr = arr.reshape(-1, 2)
        return cls([Point.safe(x=float(x), y=float(y)) for x, y in arr])

    @classmethod
    def from_xyzxyzn(cls, xyzxyzn: Union[list, tuple, np.ndarray]) -> 'Polygon': # tested (good)
        """
        Creates 3D Polygon from flat normalized xyz sequence: [x1, y1, z1, ..., xn, yn, zn].

        Args:
            xyzxyzn: Flat list/tuple/array with length divisible by 3.

        Returns:
            Polygon: New 3D instance with normalized vertices.

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

        Note:
            - For serialization to JSON/CSV or interoperability with 3D tools.
            - Automatically reshapes to (N, 3) internally.
        """
        if not isinstance(xyzxyzn, (list, tuple, np.ndarray)):
            raise TypeError(f"Expected list, tuple, or np.ndarray, got {type(xyzxyzn).__name__}")

        arr = np.asarray(xyzxyzn, dtype=float)
        if arr.ndim != 1 or len(arr) % 3 != 0:
            raise ValueError(f"Expected flat array with length divisible by 3 for xyz triplets, got shape {arr.shape}")

        arr = arr.reshape(-1, 3)
        return cls([Point.safe(x=float(x), y=float(y), z=float(z)) for x, y, z in arr])
