from collections.abc import Generator, Iterable, Sequence
from functools import cached_property
from itertools import chain
from math import atan2, sqrt
from typing import Any, NamedTuple

import cv2
import numpy as np

from .utils import deprecated


class BoundingBoxCoordinates(NamedTuple):
    bottom: int
    left: int
    top: int
    right: int


class Blob:
    """Represents a segmented animal(s) defined with a contour in a specific frame."""

    contour: np.ndarray
    """The coordinates of the contour defining self with shape [n_points, 2]"""

    episode: int

    id_image_index: int
    """Index of the identification image position in the HDF5 file"""

    is_an_individual: bool
    """Flag indicating self represents a single animal."""

    next: tuple["Blob", ...]
    """The :class:`Blob` s from the next frame that overlap with self"""

    previous: tuple["Blob", ...]
    """The :class:`Blob` s from the previous frame that overlap with self"""

    frame_number: int
    """The index of the frame where self belongs to"""

    bbox_img_id: str

    seems_like_individual: bool
    """Unicity condition or not huge area"""

    used_for_training_crossings: bool = False
    """Flag indicating if the blob has been used to train the
    crossing CNN"""

    was_a_crossing: bool = False
    """Flag indicating whether the blob was created after splitting a
    crossing blob during the crossings interpolation process"""

    identity: int | None = None
    """Identity of the blob assigned during the identification process"""

    fragment_identifier: int = -1
    """Indicates the index of the Fragment that contains the blob,
    -1 means no associated Fragment"""

    pixels_are_from_eroded_blob: bool = False

    user_generated_identities: list[int | None] = None  # type: ignore
    """List of identities of the blob some of which might have been give
    by a user during the validation process"""

    user_generated_centroids: list[tuple[float, float] | None] = None  # type: ignore
    """List of centroids generated by the user during the validation
    processes"""

    identity_corrected_solving_jumps: int | None = None
    """Identity of the blob after correcting impossible velocity jumps"""

    identities_corrected_closing_gaps: list | None = None
    """Identity of the blob after crossings interpolation"""

    interpolated_centroids: list | None = None

    added_by_user: bool = False

    forced_crossing: bool = False
    """Indicates if the crossing attribute has been forced by set_individual_with_identity_0_as_crossings()"""

    exclusive_roi: int = -1
    """Exclusive ROI where the blob belongs to"""

    identity_certainty: float = np.nan
    """Confidence of the identity assigned to the blob"""

    def __init__(
        self,
        contour: np.ndarray,
        frame_number: int = -1,
        bbox_img_id: str = "",
        pixels_are_from_eroded_blob: bool = False,
    ) -> None:
        self.set_contour(contour)
        self.frame_number = frame_number
        self.bbox_img_id = bbox_img_id
        if pixels_are_from_eroded_blob:
            self.pixels_are_from_eroded_blob = pixels_are_from_eroded_blob
            # TODO fix this, some eroded blobs are not classified as
            # individuals/crossings and idtrackerai crashes
            self.is_an_individual = True
            self.forced_crossing = True

        self.next = ()
        self.previous = ()

    @property
    def n_next(self) -> int:
        return len(self.next)

    @property
    def n_previous(self) -> int:
        return len(self.previous)

    @cached_property
    def convexHull(self) -> np.ndarray:
        """Convex hull of the contour computed with :func:`cv2.convexHull`"""
        return cv2.convexHull(self.contour)

    @cached_property
    def area(self) -> float:
        """Area of the contour computed with :func:`cv2.contourArea`"""
        return cv2.contourArea(self.contour)

    @cached_property
    def bbox_corners(self) -> BoundingBoxCoordinates:
        """A NamedTuple of the bottom, left, top and right values of the bounding box"""
        return BoundingBoxCoordinates(*self.contour.min(0), *self.contour.max(0))

    @property
    def extension(self) -> float:
        """Extension measured as the length of the diagonal of the bounding box"""
        width, height = np.ptp(self.contour, axis=0)
        return sqrt(width**2 + height**2)

    @cached_property
    def centroid(self) -> tuple[float, float]:
        M = cv2.moments(self.contour)
        try:
            return M["m10"] / M["m00"], M["m01"] / M["m00"]
        except ZeroDivisionError:
            return tuple(self.contour.mean(0))

    @property
    def orientation(self) -> float:
        M = cv2.moments(self.contour)
        try:
            x = M["m10"] / M["m00"]
            y = M["m01"] / M["m00"]
            a = M["m20"] / M["m00"] - x * x
            b = 2 * (M["m11"] / M["m00"] - x * y)
            c = M["m02"] / M["m00"] - y * y
            return 0.5 * atan2(b, a - c)
        except ZeroDivisionError:
            return 0

    def set_contour(self, contour: np.ndarray) -> None:
        if contour.ndim == 3 and contour.shape[1] == 1:
            # OpenCV returns contours as (n_points, 1, 2)
            contour = contour[:, 0]
        self.contour = contour.astype(np.int32, copy=False)

    def __getstate__(self) -> dict[str, Any]:
        out = self.__dict__.copy()
        # clear cached_properties before pickling
        out.pop("convexHull", None)
        out.pop("centroid", None)
        out.pop("area", None)
        out.pop("bbox_corners", None)
        return out

    @property
    def is_a_crossing(self) -> bool:
        """Flag indicating whether the blob represents two or more animals
        together. It is the negative of :attr:`is_an_individual`."""
        return not self.is_an_individual

    @cached_property
    def has_multiple_previous(self) -> bool:
        """Flag indicating if the blob has multiple blobs in its past or future
        overlapping history

        This method is used to check whether the blob is a crossing.

        Returns
        -------
        bool
            If True the blob splits into two or multiple overlapping blobs in
            its "past" or "future" history, depending on the parameter
            "direction"."""
        previous = self
        analyzed_blobs: "list[Blob]" = [previous]
        while previous.n_previous == 1:
            previous = previous.previous[0]
            if "has_multiple_previous" in previous.__dict__:
                # previous has already the answer
                result = previous.has_multiple_previous
                break
            analyzed_blobs.append(previous)
            if previous.n_previous > 1:
                result = True
                break
        else:
            result = False

        for blob in analyzed_blobs:
            blob.has_multiple_previous = result
        return result

    @cached_property
    def has_multiple_next(self) -> bool:
        """Flag indicating if the blob has multiple blobs in its past or future
        overlapping history

        This method is used to check whether the blob is a crossing.

        Returns
        -------
        bool
            If True the blob splits into two or multiple overlapping blobs in
            its "past" or "future" history, depending on the parameter
            "direction".
        """

        next = self
        analyzed_blobs: "list[Blob]" = [next]
        while next.n_next == 1:
            next = next.next[0]
            if "has_multiple_next" in next.__dict__:
                # previous has already the answer
                result = next.has_multiple_next
                break
            analyzed_blobs.append(next)
            if next.n_next > 1:
                result = True
                break
        else:
            result = False

        for blob in analyzed_blobs:
            blob.has_multiple_next = result
        return result

    @cached_property
    def has_a_next_crossing(self) -> bool:
        """Flag indicating if the blob has a crossing in its future overlapping history

        Returns
        -------
        bool
            If True the blob has a crossing in its "future" history
        """
        next = self.next[0]
        analyzed_blobs: "list[Blob]" = [next]
        while next.n_next == 1:
            next = next.next[0]
            if "has_a_next_crossing" in next.__dict__:
                # previous has already the answer
                result = next.has_a_next_crossing
                break
            analyzed_blobs.append(next)
            if next.n_previous > 1 and not next.seems_like_individual:
                result = True
                break
        else:
            result = False

        for blob in analyzed_blobs:
            blob.has_a_next_crossing = result
        return result

    @cached_property
    def has_a_previous_crossing(self) -> bool:
        """Flag indicating if the blob has a crossing in its past overlapping history

        Returns
        -------
        bool
            If True the blob has a crossing in its "past" history
        """

        previous = self.previous[0]
        analyzed_blobs: "list[Blob]" = [previous]
        while previous.n_previous == 1:
            previous = previous.previous[0]
            if "has_a_previous_crossing" in previous.__dict__:
                # previous has already the answer
                result = previous.has_a_previous_crossing
                break
            analyzed_blobs.append(previous)
            if previous.n_next > 1 and not previous.seems_like_individual:
                result = True
                break
        else:
            result = False

        for blob in analyzed_blobs:
            blob.has_a_previous_crossing = result
        return result

    def is_a_sure_individual(self) -> bool:
        """Flag indicating that the blob is a sure individual according to
        some heuristics and it can be used to train the crossing detector CNN.
        """
        return (
            self.seems_like_individual
            and self.n_previous == 1
            and self.n_next == 1
            and self.previous[0].n_next == 1
            and self.next[0].n_previous == 1
            and self.has_a_previous_crossing
            and self.has_a_next_crossing
        )

    def is_a_sure_crossing(self) -> bool:
        """Flag indicating that the blob is a sure crossing according to
        some heuristics and it can be used to train the crossing detector CNN.

        Returns
        -------
        bool
        """
        if self.seems_like_individual:
            return False
        if self.n_previous > 1 or self.n_next > 1:
            return True
        return self.has_multiple_previous and self.has_multiple_next

    def overlaps_with(self, other: "Blob") -> bool:
        """Computes whether the pixels in `self` intersect with the pixels in
        `other`

        Parameters
        ----------
        other : <Blob object>
            An instance of the class Blob

        Returns
        -------
        bool
            True if the lists of pixels of both blobs have non-empty
            intersection
        """

        if (
            self.bbox_corners.top < other.bbox_corners.bottom
            or other.bbox_corners.top < self.bbox_corners.bottom
            or self.bbox_corners.right < other.bbox_corners.left
            or other.bbox_corners.right < self.bbox_corners.left
        ):
            # the bounding boxes do not overlap
            return False

        # Check for every point in `other`'s contour
        points = other.contour.astype(float)
        for point in chain(points[0::3], points[1::3], points[2::3]):
            if self.contour_contains_point(point):
                return True

        # Check for every point in `self`'s contour
        points = self.contour.astype(float)
        return any(
            other.contour_contains_point(point)
            for point in chain(points[0::3], points[1::3], points[2::3])
        )

    def contour_contains_point(self, point: tuple[float, float]) -> bool:
        return cv2.pointPolygonTest(self.contour, point, False) >= 0

    def contains_point(self, point: tuple[float, float]) -> bool:
        if not (
            point[0] >= self.bbox_corners.bottom
            and point[0] <= self.bbox_corners.top
            and point[1] >= self.bbox_corners.left
            and point[1] <= self.bbox_corners.right
        ):
            return False
        return self.contour_contains_point(point)

    def now_points_to(self, other: "Blob") -> None:
        """Given two consecutive blob objects updates their respective
        overlapping histories

        Parameters
        ----------
        other : Blob
            An instance of the class Blob
        """
        self.next = self.next + (other,)
        other.previous = other.previous + (self,)

    def square_distance_to(self, other: "Blob|tuple|list|np.ndarray") -> float:
        """Returns the squared distance from the centroid of self to `other` or the centroid of `other` if it is a Blob.

        Parameters
        ----------
        other : Blob or tuple
            An instance of the class Blob or a tuple (x,y)

        Returns
        -------
        float
            Squared distance between centroids
        """
        if isinstance(other, Blob):
            return ((np.asarray(self.centroid) - np.asarray(other.centroid)) ** 2).sum()

        if isinstance(other, (tuple, list, np.ndarray)):
            return ((np.asarray(self.centroid) - np.asarray(other)) ** 2).sum()

        raise ValueError

    def distance_to(self, other: "Blob|tuple|list|np.ndarray") -> float:
        return sqrt(self.square_distance_to(other))

    def distance_from_countour_to(self, point: Sequence[float]) -> float:
        """Returns the distance between `point` and the closest
        point in the contour of the blob.

        Parameters
        ----------
        point : tuple
            (x,y)

        Returns
        -------
        float
            Smallest distance between `point` and the contour of the blob.
        """
        return abs(cv2.pointPolygonTest(self.contour, point, True))

    @property
    def assigned_identities(self) -> list[None] | list[int] | list[int | None]:
        """Identities assigned to the blob during the tracking process"""
        if self.identities_corrected_closing_gaps is not None:
            return self.identities_corrected_closing_gaps
        if self.identity_corrected_solving_jumps is not None:
            return [self.identity_corrected_solving_jumps]
        return [self.identity]

    @property
    def assigned_centroids(self) -> list[tuple[float, float]]:
        """Centroids assigned to the blob during the tracking process.

        It considers the default centroid of the blob at segmentation time
        or new centroids added to the blob during the interpolation of the
        crossings.

        Returns
        -------
        list
            List of pairs (x, y) indicating the position of each individual
            in the blob.
        """
        if self.interpolated_centroids:
            return self.interpolated_centroids
        return [self.centroid]

    @property
    def final_centroids(self) -> Generator[tuple[float, float], None, None]:
        """List of the animal/s centroid/s in the blob, considering the
        potential centroids that might have been added by the user during
        the validation.

        By default the centroid will be the center of mass of the blob of
        pixels defined by the blob. It can be different if the user modified
        the default centroid during validation or generated more centroids.

        Returns
        -------
        list
            List of tuples (x, y) indicating the centroids of the blob.
        """
        return (c for c in self.all_final_centroids if c != (-1, -1))

    @property
    def all_final_centroids(self) -> list:
        if self.user_generated_centroids:
            # Note that sometimes len(user_generated_centroids) >
            # len(assigned_centroids)
            final_centroids = []
            for i, centroid in enumerate(self.user_generated_centroids):
                if centroid is not None or i >= len(self.assigned_centroids):
                    final_centroids.append(centroid)
                else:
                    final_centroids.append(self.assigned_centroids[i])
            return final_centroids
        return self.assigned_centroids

    @property
    def final_identities(self) -> Generator[int | None, None, None]:
        return (id for id in self.all_final_identities if id != -1)

    @property
    def all_final_identities(self) -> list:
        """Identities of the blob after the tracking process and after
        potential modifications by the users during the validation procedure.
        """
        if self.user_generated_identities:
            # Note that sometimes len(user_generated_identities)
            # > len(assigned_identities)
            final_identities = []
            # None means the same as assigned, 0 means no id, -1 means no centroid
            for i, user_generated_identity in enumerate(self.user_generated_identities):
                if user_generated_identity is not None or i >= len(
                    self.assigned_identities
                ):
                    final_identities.append(user_generated_identity)
                else:
                    final_identities.append(self.assigned_identities[i])
            return final_identities
        return self.assigned_identities

    @property
    def final_ids_and_centroids(
        self,
    ) -> Iterable[tuple[int | None, tuple[float, float]]]:
        return zip(self.final_identities, self.final_centroids)

    def get_image_for_identification(
        self, img_size: int, bbox_img: np.ndarray, resolution_reduction: float
    ) -> np.ndarray:
        """Generates the image used to train and evaluate the crossing detector CNN and the identification model.

        Parameters
        ----------
        img_size : int
            Size of the identification image. The number of channels is always 1 as images in color are still not considered.
        bbox_img : np.ndarray
            Bounding box image of the blob. This is the image extracted directly from the bounding box, with any size and without the background subtracted.
        resolution_reduction : float
            Resolution reduction to apply when creating the identification image

        Returns
        -------
        ndarray
            Square image with black background used to train the crossings detector and the identification model.
        """

        mask = self.get_bbox_mask()

        mask = cv2.dilate(mask, np.ones((3, 3), np.uint8), iterations=1)

        masked_bbox_image = bbox_img * mask
        bbox_img_height, bbox_img_width = masked_bbox_image.shape
        img_size2 = img_size % 2 + img_size // 2

        center_x = int(
            self.centroid[0] - self.bbox_corners.bottom + 1
        )  # bbox_image_pad

        center_y = int(self.centroid[1] - self.bbox_corners.left + 1)  # bbox_image_pad

        d1 = center_x**2 + center_y**2
        d2 = center_x**2 + (bbox_img_height - center_y) ** 2
        d3 = (bbox_img_width - center_x) ** 2 + center_y**2
        d4 = (bbox_img_width - center_x) ** 2 + (bbox_img_height - center_y) ** 2
        diag = int(sqrt(max((d1, d2, d3, d4))))
        diag = max(diag, img_size2)
        id_img = np.zeros((2 * diag, 2 * diag), np.uint8)
        id_img[
            diag - center_y : diag + bbox_img_height - center_y,
            diag - center_x : diag + bbox_img_width - center_x,
        ] = masked_bbox_image

        M = cv2.getRotationMatrix2D(
            (diag, diag), self.orientation * 180 / np.pi - 45, resolution_reduction
        )

        # old method
        id_img = cv2.warpAffine(
            src=id_img,
            M=M,
            dsize=(diag + img_size2, diag + img_size2),
            borderMode=cv2.BORDER_CONSTANT,
            flags=cv2.INTER_CUBIC,
        )
        return id_img[-img_size:, -img_size:]

        # TODO proposed future method
        # id_img = cv2.warpAffine(
        #     src=id_img,
        #     M=M,
        #     dsize=(diag + img_size, diag + img_size),
        #     borderMode=cv2.BORDER_CONSTANT,
        #     flags=cv2.INTER_CUBIC,
        # )

        # # we build the offset like this to have the minimal ones on the
        # # beginning of the array and be preferably selected by max()
        # origins = [0]
        # for offset in range(img_size2 // 2):
        #     origins += (diag - img_size2 + offset, diag - img_size2 - offset)

        # origin = max(
        #     origins,
        #     key=lambda origin: np.count_nonzero(
        #         id_img[origin : origin + img_size, origin : origin + img_size]
        #     ),
        # )

        # return id_img[origin : origin + img_size, origin : origin + img_size]

    def get_bbox_mask(self) -> np.ndarray:
        """Computes the binary mask with the same size as the bounding box image with ones where the blob is.

        Returns
        -------
        np.ndarray
            Binary np.ndarray image
        """
        base = np.zeros(
            (
                self.bbox_corners.right
                - self.bbox_corners.left
                + 2,  # 2 bbox_image_pads
                self.bbox_corners.top
                - self.bbox_corners.bottom
                + 2,  # 2 bbox_image_pads
            ),
            np.uint8,
        )
        return cv2.fillPoly(
            img=base,
            pts=[self.contour],
            color=[1],
            offset=(
                1 - self.bbox_corners.bottom,  # bbox_image_pad
                1 - self.bbox_corners.left,  # bbox_image_pad
            ),
        )

    def update_centroid(
        self,
        old_centroid: tuple[float, float],
        new_centroid: tuple[float, float],
        identity: int,
    ) -> None:
        """[Validation] Updates the centroid of the blob.

        Parameters
        ----------
        old_centroid : tuple
            Centroid to be updated
        new_centroid : tuple
            Coordinates of the new centroid
        identity : int
            Identity of the centroid to be updated
        """

        self.init_validator_variables()
        index, centroid, dist = self.index_and_centroid_closer_to(
            old_centroid, identity
        )

        self.user_generated_centroids[index] = new_centroid
        self.user_generated_identities[index] = identity

    def init_validator_variables(self) -> None:
        if self.user_generated_centroids is None:
            self.user_generated_centroids: list[tuple[float, float] | None] = [
                None
            ] * len(list(self.final_centroids))
        if self.user_generated_identities is None:
            self.user_generated_identities = [None] * len(list(self.final_identities))

    def index_and_centroid_closer_to(
        self, centroid: tuple, identity: int | None
    ) -> tuple[int, tuple[float, float], float]:
        candidates: list[tuple[int, tuple[float, float], float]] = []
        for indx, (_id, _centroid) in enumerate(
            zip(self.all_final_identities, self.all_final_centroids)
        ):
            if identity not in (None, _id):
                continue
            dist = (_centroid[0] - centroid[0]) ** 2 + (_centroid[1] - centroid[1]) ** 2
            candidates.append((indx, _centroid, dist))
        if not candidates:
            raise ValueError("Centroid not found")

        return min(candidates, key=lambda x: x[2])

    def remove_centroid(self, identity: int, centroid: tuple) -> None:
        """[Validation] Deletes a centroid of the blob.

        Parameters
        ----------
        identity : int
            Identity of the centroid to be deleted
        centroid : tuple
            Centroid to be deleted from the blob
        """

        self.init_validator_variables()
        index, centroid, dist = self.index_and_centroid_closer_to(centroid, identity)

        self.user_generated_centroids[index] = (-1, -1)
        self.user_generated_identities[index] = -1

    def add_centroid(self, centroid: tuple[float, float], identity: int | None) -> None:
        """[Validation] Adds a centroid with a given identity to the blob.

        This method is used in the validation GUI. It is useful to add
        centroids for crossing blobs that are missing some centroids, or to
        individual blobs that should have been classified as crossings and
        are also missing some centroids.

        Parameters
        ----------
        centroid : tuple
            Centroid to be added to the blob
        identity : int
            Identity of the centroid to be added
        """

        self.init_validator_variables()

        self.user_generated_centroids.append(centroid)
        self.user_generated_identities.append(identity)

    def update_identity(
        self,
        old_identity: int | None,
        new_identity: int | None,
        close_to_centroid: tuple[float, float],
    ) -> tuple[float, float]:
        """[Validation] Updates the identity of the blob.

        This method is used during the validation GUI.
        It populates the private attributes `user_generated_identities`
        and `user_generated_centroids`.

        Parameters
        ----------
        new_identity : int
            new value for the identity of the blob
        old_identity : int
            old value of the identity of the blob. It must be specified when the
            blob has multiple identities already assigned.
        centroid : tuple
            centroid which identity must be updated.
        """
        self.init_validator_variables()

        index, centroid, dist = self.index_and_centroid_closer_to(
            close_to_centroid, old_identity
        )

        if new_identity == -1:
            # centroid removals are indicated with new_identity = -1
            # If the identity is removed, the corresponding centroid has to be removed too
            centroid = (-1, -1)

        self.user_generated_identities[index] = new_identity
        self.user_generated_centroids[index] = centroid
        return centroid

    def propagate_identity(
        self,
        old_identity: int | None,
        new_identity: int | None,
        centroid: tuple[float, float],
    ) -> tuple[int, int]:
        """[Validation] Propagates the new identity to next and previous blobs.

        This method called in the validation GUI when the used updates the
        identity of a given blob.

        Returns the frame range where identity changes occurred

        Parameters
        ----------
        old_identity : int
            Previous identity of the blob
        new_identity : int
            New identity of the blob
        centroid : tuple
            [description]
        """

        blobs_stack: list[tuple[Blob, tuple[float, float]]]
        first_frame_modified = self.frame_number
        last_frame_modified = self.frame_number

        blobs_stack = [(next_blob, centroid) for next_blob in self.next]
        while blobs_stack:
            current, previous_centroid = blobs_stack.pop()
            if current.fragment_identifier != self.fragment_identifier:
                continue
            try:
                new_centroid = current.update_identity(
                    old_identity, new_identity, previous_centroid
                )
            except ValueError:  # centroid not found on "current"
                continue
            blobs_stack += [(next_blob, new_centroid) for next_blob in current.next]
            last_frame_modified = current.frame_number

        blobs_stack += [(prev_blob, centroid) for prev_blob in self.previous]
        while blobs_stack:
            current, previous_centroid = blobs_stack.pop()
            if current.fragment_identifier != self.fragment_identifier:
                continue

            try:
                new_centroid = current.update_identity(
                    old_identity, new_identity, previous_centroid
                )
            except ValueError:  # centroid not found on "current"
                continue
            blobs_stack += [(prev_blob, new_centroid) for prev_blob in current.previous]
            first_frame_modified = current.frame_number

        return first_frame_modified, last_frame_modified

    @property
    def summary(self) -> Sequence[str]:
        return (
            (("Individual" if self.is_an_individual else "Crossing") + " Blob")
            + (" (forced)" if self.forced_crossing else ""),
            f"{len(self.contour)} vertices in contour of {self.area:.0f} px area",
            f"In fragment {self.fragment_identifier}",
            f"Linked to {self.n_previous} previous blobs",
            f"Linked to {self.n_next} next blobs",
            ("Used" if self.used_for_training_crossings else "Not used")
            + " for training crossings",
            f"Seems like individual: {self.seems_like_individual}",
            f"Pixels are from eroded blob: {self.pixels_are_from_eroded_blob}",
            f"Predicted identity: {self.identity}",
            f"Corrected solving jumps {self.identity_corrected_solving_jumps}",
            f"Corrected solving gaps: {self.identities_corrected_closing_gaps}",
            f"assigned identities: {self.assigned_identities}",
            f"assigned centroids: {repr_of_list_of_points(self.assigned_centroids)}",
            f"user identities: {self.user_generated_identities}",
            f"user centroids: {repr_of_list_of_points(self.user_generated_centroids)}",
            f"final identities: {list(self.final_identities)}",
            f"final centroids: {repr_of_list_of_points(self.final_centroids)}",
            f"Predicted identity certainty: {self.identity_certainty:.2%}",
        )

    # Deprecated properties for backward compatibility

    @property
    @deprecated(version="6.0.0", reason="Use :meth:`bbox_corners` instead")
    def bbox_in_frame_coordinates(self):
        x0, y0, x1, y1 = self.bbox_corners
        return (x0, y0), (x1, y1)

    @property
    @deprecated(version="6.0.0", reason="Use :meth:`extension` instead")
    def estimated_body_length(self):
        return self.extension

    @deprecated(
        version="6.0.0", reason="Use :meth:`contains_point` instead", action="error"
    )
    def bbox_contains_point(self): ...

    @property
    @deprecated(
        version="6.0.0",
        reason="Use :meth:`all_final_identities` or :meth:`all_final_centroids` instead",
        action="error",
    )
    def all_final_ids_and_centroids(self): ...

    @property
    @deprecated(
        version="6.0.0",
        reason="Check if :meth:`user_generated_identities` is not None instead",
    )
    def has_been_modified(self):
        return self.user_generated_identities is not None

    @property
    @deprecated(version="6.0.0", reason="Use :meth:`summary` instead")
    def properties(self):
        return self.summary


def repr_of_list_of_points(list_of_points) -> str:
    if list_of_points is None:
        return "None"
    list_of_str = [
        f"({point[0]:.1f}, {point[1]:.1f})" if point is not None else "None"
        for point in list_of_points
    ]
    return f"[{', '.join(list_of_str)}]"
