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]):
        """
        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:
        """
        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]:
        """
        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:
        """
        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:
        """
        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 __repr__(self) -> str:
        """
        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}))"

    def __str__(self) -> str:
        """
        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})"

    @property
    def left(self) -> float:
        """
        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:
        """
        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:
        """
        Gets the top 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 min(p.Y for p in self._points)

    @property
    def bottom(self) -> float:
        """
        Gets the bottom 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 max(p.Y for p in self._points)

    @property
    def width(self) -> float:
        """
        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:
        """
        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:
        """
        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]:
        """
        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:
        """
        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':
        """
        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':
        """
        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':
        """
        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_array(self) -> np.ndarray:
        """
        Converts the polygon to a NumPy array of shape (N, 2).

        Coordinates are in the same space as the original points (usually normalized).

        Returns:
            np.ndarray: Array of shape (N, 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 (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])

    @staticmethod
    def from_yolo_xyn(xyn: Union[list, tuple, np.ndarray]) -> 'Polygon':
        """
        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(xyn, (list, tuple)):
            xyn = np.array(xyn)
        elif isinstance(xyn, np.ndarray):
            pass
        else:
            raise TypeError(f"Expected list, tuple, or np.ndarray, got {type(xyn).__name__}")

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

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