from contextlib import ExitStack as DoesNotRaise

import numpy as np
import pytest

from supervision import (
    Detections,
    LineZone,
    LineZoneAnnotator,
    LineZoneAnnotatorMulticlass,
)
from supervision.draw.color import Color
from supervision.geometry.core import Point, Position, Vector
from tests.helpers import _create_detections


@pytest.mark.parametrize(
    ("vector", "expected_result", "exception"),
    [
        (
            Vector(start=Point(x=0.0, y=0.0), end=Point(x=0.0, y=0.0)),
            None,
            pytest.raises(ValueError, match="magnitude of the vector"),
        ),
        (
            Vector(start=Point(x=1.0, y=1.0), end=Point(x=1.0, y=1.0)),
            None,
            pytest.raises(ValueError, match="cannot be zero"),
        ),
        (
            Vector(start=Point(x=0.0, y=0.0), end=Point(x=0.0, y=4.0)),
            (
                Vector(start=Point(x=0.0, y=0.0), end=Point(x=-1.0, y=0.0)),
                Vector(start=Point(x=0.0, y=4.0), end=Point(x=1.0, y=4.0)),
            ),
            DoesNotRaise(),
        ),
        (
            Vector(Point(0.0, 0.0), Point(4.0, 0.0)),
            (
                Vector(start=Point(x=0.0, y=0.0), end=Point(x=0.0, y=1.0)),
                Vector(start=Point(x=4.0, y=0.0), end=Point(x=4.0, y=-1.0)),
            ),
            DoesNotRaise(),
        ),
        (
            Vector(Point(0.0, 0.0), Point(3.0, 4.0)),
            (
                Vector(start=Point(x=0, y=0), end=Point(x=-0.8, y=0.6)),
                Vector(start=Point(x=3, y=4), end=Point(x=3.8, y=3.4)),
            ),
            DoesNotRaise(),
        ),
        (
            Vector(Point(0.0, 0.0), Point(4.0, 3.0)),
            (
                Vector(start=Point(x=0, y=0), end=Point(x=-0.6, y=0.8)),
                Vector(start=Point(x=4, y=3), end=Point(x=4.6, y=2.2)),
            ),
            DoesNotRaise(),
        ),
        (
            Vector(Point(0.0, 0.0), Point(3.0, -4.0)),
            (
                Vector(start=Point(x=0, y=0), end=Point(x=0.8, y=0.6)),
                Vector(start=Point(x=3, y=-4), end=Point(x=2.2, y=-4.6)),
            ),
            DoesNotRaise(),
        ),
    ],
)
def test_calculate_region_of_interest_limits(
    vector: Vector,
    expected_result: tuple[Vector, Vector] | None,
    exception: Exception,
) -> None:
    with exception:
        result = LineZone._calculate_region_of_interest_limits(vector=vector)
        assert result == expected_result


@pytest.mark.parametrize(
    ("vector", "xyxy_sequence", "expected_crossed_in", "expected_crossed_out"),
    [
        (  # Vertical line, simple crossing
            Vector(Point(0, 0), Point(0, 10)),
            [
                [4, 4, 6, 6],
                [4 - 10, 4, 6 - 10, 6],
                [4, 4, 6, 6],
                [4 - 10, 4, 6 - 10, 6],
            ],
            [False, False, True, False],
            [False, True, False, True],
        ),
        (  # Vertical line reversed, simple crossing
            Vector(Point(0, 10), Point(0, 0)),
            [
                [4, 4, 6, 6],
                [4 - 10, 4, 6 - 10, 6],
                [4, 4, 6, 6],
                [4 - 10, 4, 6 - 10, 6],
            ],
            [False, True, False, True],
            [False, False, True, False],
        ),
        (  # Horizontal line, simple crossing
            Vector(Point(0, 0), Point(10, 0)),
            [
                [4, 4, 6, 6],
                [4, 4 - 10, 6, 6 - 10],
                [4, 4, 6, 6],
                [4, 4 - 10, 6, 6 - 10],
            ],
            [False, True, False, True],
            [False, False, True, False],
        ),
        (  # Horizontal line reversed, simple crossing
            Vector(Point(10, 0), Point(0, 0)),
            [
                [4, 4, 6, 6],
                [4, 4 - 10, 6, 6 - 10],
                [4, 4, 6, 6],
                [4, 4 - 10, 6, 6 - 10],
            ],
            [False, False, True, False],
            [False, True, False, True],
        ),
        (  # Diagonal line, simple crossing
            Vector(Point(5, 0), Point(0, 5)),
            [
                [0, 0, 2, 2],
                [0 + 10, 0 + 10, 2 + 10, 2 + 10],
                [0, 0, 2, 2],
                [0 + 10, 0 + 10, 2 + 10, 2 + 10],
            ],
            [False, True, False, True],
            [False, False, True, False],
        ),
        (  # Crossing beside - right side
            Vector(Point(0, 0), Point(10, 0)),
            [
                [20, 4, 24, 6],
                [20, 4 - 10, 24, 6 - 10],
                [20, 4, 24, 6],
                [20, 4 - 10, 24, 6 - 10],
            ],
            [False, False, False, False],
            [False, False, False, False],
        ),
        (  # Horizontal line, simple crossing, far away
            Vector(Point(0, 0), Point(10, 0)),
            [
                [4, 1e32, 6, 1e32 + 2],
                [4, -1e32, 6, -1e32 + 2],
                [4, 1e32, 6, 1e32 + 2],
                [4, -1e32, 6, -1e32 + 2],
            ],
            [False, True, False, True],
            [False, False, True, False],
        ),
        (  # Crossing beside - left side
            Vector(Point(0, 0), Point(10, 0)),
            [
                [-20, 4, -24, 6],
                [-20, 4 - 10, -24, 6 - 10],
                [-20, 4, -24, 6],
                [-20, 4 - 10, -24, 6 - 10],
            ],
            [False, False, False, False],
            [False, False, False, False],
        ),
        (  # Move above
            Vector(Point(0, 0), Point(10, 0)),
            [
                [-4, 4, -2, 6],
                [-4 + 20, 4, -2 + 20, 6],
                [-4, 4, -2, 6],
                [-4 + 20, 4, -2 + 20, 6],
            ],
            [False, False, False, False],
            [False, False, False, False],
        ),
        (  # Move below
            Vector(Point(0, 0), Point(10, 0)),
            [
                [-4, -6, -2, -4],
                [-4 + 20, -6, -2 + 20, -4],
                [-4, -6, -2, -4],
                [-4 + 20, -6, -2 + 20, -4],
            ],
            [False, False, False, False],
            [False, False, False, False],
        ),
        (  # Move into line partway
            Vector(Point(0, 0), Point(10, 0)),
            [
                [4, 4, 6, 6],
                [4 + 5, 4, 6 + 5, 6],
                [4, 4, 6, 6],
                [4 + 5, 4, 6 + 5, 6],
            ],
            [False, False, False, False],
            [False, False, False, False],
        ),
        (  # V-shaped crossing from outside limits - not supported.
            Vector(Point(0, 0), Point(10, 0)),
            [[-3, 6, -1, 8], [4, -6, 6, -4], [11, 6, 13, 8]],
            [False, False, False],
            [False, False, False],
        ),
        (  # Diagonal movement, from within limits to outside - not supported
            Vector(Point(0, 0), Point(10, 0)),
            [[4, 1, 6, 3], [11, 1 - 20, 13, 3 - 20]],
            [False, False],
            [False, False],
        ),
        (  # Diagonal movement, from outside limits to within - not supported
            Vector(Point(0, 0), Point(10, 0)),
            [
                [11, 21, 13, 23],
                [4, -3, 6, -1],
            ],
            [False, False],
            [False, False],
        ),
        (  # Diagonal crossing, from outside to outside limits - not supported.
            Vector(Point(0, 0), Point(10, 0)),
            [
                [-4, 4, -2, 8],
                [-4 + 16, -4, -2 + 16, -6],
                [-4, 4, -2, 8],
                [-4 + 16, -4, -2 + 16, -6],
            ],
            [False, False, False, False],
            [False, False, False, False],
        ),
    ],
)
def test_line_zone_one_detection_default_anchors(
    vector: Vector,
    xyxy_sequence: list[list[float]],
    expected_crossed_in: list[bool],
    expected_crossed_out: list[bool],
) -> None:
    line_zone = LineZone(start=vector.start, end=vector.end)

    crossed_in_list = []
    crossed_out_list = []
    for i, bbox in enumerate(xyxy_sequence):
        detections = _create_detections(
            xyxy=[bbox],
            tracker_id=[0],
        )
        crossed_in, crossed_out = line_zone.trigger(detections)
        crossed_in_list.append(crossed_in[0])
        crossed_out_list.append(crossed_out[0])

    assert crossed_in_list == expected_crossed_in, (
        f"expected {expected_crossed_in}, got {crossed_in_list}"
    )
    assert crossed_out_list == expected_crossed_out, (
        f"expected {expected_crossed_out}, got {crossed_out_list}"
    )


@pytest.mark.parametrize(
    (
        "vector",
        "xyxy_sequence",
        "triggering_anchors",
        "expected_crossed_in",
        "expected_crossed_out",
    ),
    [
        (  # Scrape line, left side, corner anchors
            Vector(Point(0, 0), Point(10, 0)),
            [
                [-2, 4, 2, 6],
                [-2, 4 - 10, 2, 6 - 10],
                [-2, 4, 2, 6],
                [-2, 4 - 10, 2, 6 - 10],
            ],
            [
                Position.TOP_LEFT,
                Position.BOTTOM_LEFT,
                Position.TOP_RIGHT,
                Position.BOTTOM_RIGHT,
            ],
            [False, False, False, False],
            [False, False, False, False],
        ),
        (  # Scrape line, left side, right anchors
            Vector(Point(0, 0), Point(10, 0)),
            [
                [-2, 4, 2, 6],
                [-2, 4 - 10, 2, 6 - 10],
                [-2, 4, 2, 6],
                [-2, 4 - 10, 2, 6 - 10],
            ],
            [Position.TOP_RIGHT, Position.BOTTOM_RIGHT],
            [False, True, False, True],
            [False, False, True, False],
        ),
        (  # Scrape line, left side, center anchor (along line point)
            Vector(Point(0, 0), Point(10, 0)),
            [
                [-2, 4, 2, 6],
                [-2, 4 - 10, 2, 6 - 10],
                [-2, 4, 2, 6],
                [-2, 4 - 10, 2, 6 - 10],
            ],
            [Position.CENTER],
            [False, True, False, True],
            [False, False, True, False],
        ),
        (  # Scrape line, left side, center anchor (along line point)
            Vector(Point(0, 0), Point(10, 0)),
            [
                [-2, 4, 2, 6],
                [-2, 4 - 10, 2, 6 - 10],
                [-2, 4, 2, 6],
                [-2, 4 - 10, 2, 6 - 10],
                [-2, 4 - 10, 2, 6 - 10],
            ],
            [Position.CENTER],
            [False, True, False, True, False],
            [False, False, True, False, False],
        ),
        (  # Scrape line, right side, corner anchors
            Vector(Point(0, 0), Point(10, 0)),
            [
                [8, 4, 12, 6],
                [8, 4 - 10, 12, 6 - 10],
                [8, 4, 12, 6],
                [8, 4 - 10, 12, 6 - 10],
            ],
            [
                Position.TOP_LEFT,
                Position.BOTTOM_LEFT,
                Position.TOP_RIGHT,
                Position.BOTTOM_RIGHT,
            ],
            [False, False, False, False],
            [False, False, False, False],
        ),
        (  # Scrape line, right side, left anchors
            Vector(Point(0, 0), Point(10, 0)),
            [
                [8, 4, 12, 6],
                [8, 4 - 10, 12, 6 - 10],
                [8, 4, 12, 6],
                [8, 4 - 10, 12, 6 - 10],
            ],
            [Position.TOP_LEFT, Position.BOTTOM_LEFT],
            [False, True, False, True],
            [False, False, True, False],
        ),
        (  # Scrape line, right side, center anchor (along line point)
            Vector(Point(0, 0), Point(10, 0)),
            [
                [8, 4, 12, 6],
                [8, 4 - 10, 12, 6 - 10],
                [8, 4, 12, 6],
                [8, 4 - 10, 12, 6 - 10],
            ],
            [Position.CENTER],
            [False, True, False, True],
            [False, False, True, False],
        ),
        (  # Simple crossing, one anchor
            Vector(Point(0, 0), Point(10, 0)),
            [
                [4, 4, 6, 6],
                [4, 4 - 10, 6, 6 - 10],
                [4, 4, 6, 6],
                [4, 4 - 10, 6, 6 - 10],
            ],
            [Position.CENTER],
            [False, True, False, True],
            [False, False, True, False],
        ),
        (  # Simple crossing, all box anchors
            Vector(Point(0, 0), Point(10, 0)),
            [
                [4, 4, 6, 6],
                [4, 4 - 10, 6, 6 - 10],
                [4, 4, 6, 6],
                [4, 4 - 10, 6, 6 - 10],
            ],
            [
                Position.CENTER,
                Position.CENTER_LEFT,
                Position.CENTER_RIGHT,
                Position.TOP_CENTER,
                Position.TOP_LEFT,
                Position.TOP_RIGHT,
                Position.BOTTOM_LEFT,
                Position.BOTTOM_CENTER,
                Position.BOTTOM_RIGHT,
            ],
            [False, True, False, True],
            [False, False, True, False],
        ),
    ],
)
def test_line_zone_one_detection(
    vector: Vector,
    xyxy_sequence: list[list[float]],
    triggering_anchors: list[Position],
    expected_crossed_in: list[bool],
    expected_crossed_out: list[bool],
) -> None:
    line_zone = LineZone(
        start=vector.start, end=vector.end, triggering_anchors=triggering_anchors
    )

    crossed_in_list = []
    crossed_out_list = []
    for i, bbox in enumerate(xyxy_sequence):
        detections = _create_detections(
            xyxy=[bbox],
            tracker_id=[0],
        )
        crossed_in, crossed_out = line_zone.trigger(detections)
        crossed_in_list.append(crossed_in[0])
        crossed_out_list.append(crossed_out[0])

    assert crossed_in_list == expected_crossed_in, (
        f"expected {expected_crossed_in}, got {crossed_in_list}"
    )
    assert crossed_out_list == expected_crossed_out, (
        f"expected {expected_crossed_out}, got {crossed_out_list}"
    )


@pytest.mark.parametrize(
    (
        "vector",
        "xyxy_sequence",
        "anchors",
        "expected_crossed_in",
        "expected_crossed_out",
        "exception",
    ),
    [
        (  # One stays, one crosses
            Vector(Point(0, 0), Point(10, 0)),
            [
                [[4, 4, 6, 6], [4, 4, 6, 6]],
                [[4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10]],
                [[4, 4, 6, 6], [4, 4, 6, 6]],
                [[4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10]],
            ],
            [
                Position.TOP_LEFT,
                Position.TOP_RIGHT,
                Position.BOTTOM_LEFT,
                Position.BOTTOM_RIGHT,
            ],
            [[False, False], [False, True], [False, False], [False, True]],
            [[False, False], [False, False], [False, True], [False, False]],
            DoesNotRaise(),
        ),
        (  # Both cross at the same time
            Vector(Point(0, 0), Point(10, 0)),
            [
                [[4, 4, 6, 6], [4, 4, 6, 6]],
                [[4, 4 - 10, 6, 6 - 10], [4, 4 - 10, 6, 6 - 10]],
                [[4, 4, 6, 6], [4, 4, 6, 6]],
                [[4, 4 - 10, 6, 6 - 10], [4, 4 - 10, 6, 6 - 10]],
            ],
            [
                Position.TOP_LEFT,
                Position.TOP_RIGHT,
                Position.BOTTOM_LEFT,
                Position.BOTTOM_RIGHT,
            ],
            [[False, False], [True, True], [False, False], [True, True]],
            [[False, False], [False, False], [True, True], [False, False]],
            DoesNotRaise(),
        ),
    ],
)
def test_line_zone_multiple_detections(
    vector: Vector,
    xyxy_sequence: list[list[list[float]]],
    anchors: list[Position],
    expected_crossed_in: list[list[bool]],
    expected_crossed_out: list[list[bool]],
    exception: Exception,
) -> None:
    with exception:
        line_zone = LineZone(
            start=vector.start, end=vector.end, triggering_anchors=anchors
        )
        crossed_in_list = []
        crossed_out_list = []
        for bboxes in xyxy_sequence:
            detections = _create_detections(
                xyxy=bboxes,
                tracker_id=[i for i in range(0, len(bboxes))],
            )
            crossed_in, crossed_out = line_zone.trigger(detections)
            crossed_in_list.append(list(crossed_in))
            crossed_out_list.append(list(crossed_out))

        assert crossed_in_list == expected_crossed_in
        assert crossed_out_list == expected_crossed_out


@pytest.mark.parametrize(
    (
        "vector",
        "xyxy_sequence",
        "triggering_anchors",
        "minimum_crossing_threshold",
        "expected_crossed_in",
        "expected_crossed_out",
    ),
    [
        (  # Detection lingers around line, all crosses counted
            Vector(Point(0, 0), Point(10, 0)),
            [
                [2, 4, 3, 6],
                [2, 4 - 10, 3, 6],
                [2, 4, 3, 6],
                [2, 4 - 10, 3, 6],
                [2, 4 - 10, 3, 6],
            ],
            [
                Position.TOP_LEFT,
            ],
            1,
            [False, True, False, True, False],
            [False, False, True, False, False],
        ),
        (  # Detection lingers around line, only final cross counted
            Vector(Point(0, 0), Point(10, 0)),
            [
                [2, 4, 3, 6],
                [2, 4 - 10, 3, 6],
                [2, 4, 3, 6],
                [2, 4 - 10, 3, 6],
                [2, 4 - 10, 3, 6],
            ],
            [
                Position.TOP_LEFT,
            ],
            2,
            [False, False, False, False, True],
            [False, False, False, False, False],
        ),
        (  # Detection lingers around line for a long time
            Vector(Point(0, 0), Point(10, 0)),
            [
                [2, 4, 3, 6],
                [2, 4 - 10, 3, 6],
                [2, 4, 3, 6],
                [2, 4 - 10, 3, 6],
                [2, 4, 3, 6],
                [2, 4 - 10, 3, 6],
                [2, 4, 3, 6],
                [2, 4 - 10, 3, 6],
                [2, 4, 3, 6],
                [2, 4 - 10, 3, 6],
                [2, 4, 3, 6],
                [2, 4 - 10, 3, 6],
                [2, 4 - 10, 3, 6],
            ],
            [
                Position.TOP_LEFT,
            ],
            2,
            [False] * 12 + [True],
            [False] * 13,
        ),
        (  # Detection lingers around line, longer cycle
            Vector(Point(0, 0), Point(10, 0)),
            [
                [2, 4, 3, 6],
                [2, 4 - 10, 3, 6],
                [2, 4, 3, 6],
                [2, 4, 3, 6],
                [2, 4, 3, 6],
                [2, 4 - 10, 3, 6],
                [2, 4 - 10, 3, 6],
                [2, 4 - 10, 3, 6],
                [2, 4 - 10, 3, 6],
            ],
            [
                Position.TOP_LEFT,
            ],
            4,
            [False] * 8 + [True],
            [False] * 9,
        ),
    ],
)
def test_line_zone_one_detection_long_horizon(
    vector: Vector,
    xyxy_sequence: list[list[float]],
    triggering_anchors: list[Position],
    minimum_crossing_threshold: int,
    expected_crossed_in: list[bool],
    expected_crossed_out: list[bool],
) -> None:
    line_zone = LineZone(
        start=vector.start,
        end=vector.end,
        triggering_anchors=triggering_anchors,
        minimum_crossing_threshold=minimum_crossing_threshold,
    )

    crossed_in_list = []
    crossed_out_list = []
    for i, bbox in enumerate(xyxy_sequence):
        detections = _create_detections(
            xyxy=[bbox],
            tracker_id=[0],
        )
        crossed_in, crossed_out = line_zone.trigger(detections)
        crossed_in_list.append(crossed_in[0])
        crossed_out_list.append(crossed_out[0])

    assert crossed_in_list == expected_crossed_in, (
        f"expected {expected_crossed_in}, got {crossed_in_list}"
    )
    assert crossed_out_list == expected_crossed_out, (
        f"expected {expected_crossed_out}, got {crossed_out_list}"
    )


@pytest.mark.parametrize(
    (
        "vector",
        "xyxy_sequence",
        "anchors",
        "minimum_crossing_threshold",
        "expected_crossed_in",
        "expected_crossed_out",
        "expected_count_in",
        "expected_count_out",
        "exception",
    ),
    [
        (  # One stays, one crosses, one disappears before crossing
            Vector(Point(0, 0), Point(10, 0)),
            [
                [[4, 4, 6, 6], [4, 4, 6, 6], [4, 4, 6, 6]],
                [[4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10], [4, 4, 6, 6]],
                [[4, 4, 6, 6], [4, 4, 6, 6]],
                [[4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10]],
                [[4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10]],
            ],
            [
                Position.TOP_LEFT,
            ],
            1,
            [
                [False, False, False],
                [False, True, False],
                [False, False],
                [False, True],
                [False, False],
            ],
            [
                [False, False, False],
                [False, False, False],
                [False, True],
                [False, False],
                [False, False],
            ],
            [0, 1, 1, 2, 2],
            [0, 0, 1, 1, 1],
            DoesNotRaise(),
        ),
        (  # One stays, one crosses, one disappears immediately after crossing
            Vector(Point(0, 0), Point(10, 0)),
            [
                [[4, 4, 6, 6], [4, 4, 6, 6], [4, 4, 6, 6]],
                [[4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10], [4, 4, 6, 6]],
                [[4, 4, 6, 6], [4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10]],
                [[4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10]],
                [[4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10]],
            ],
            [
                Position.TOP_LEFT,
            ],
            1,
            [
                [False, False, False],
                [False, True, False],
                [False, False, True],
                [False, True],
                [False, False],
            ],
            [
                [False, False, False],
                [False, False, False],
                [False, True, False],
                [False, False],
                [False, False],
            ],
            [0, 1, 2, 3, 3],
            [0, 0, 1, 1, 1],
            DoesNotRaise(),
        ),
        (  # One stays, one crosses, one disappears before crossing
            Vector(Point(0, 0), Point(10, 0)),
            [
                [[4, 4, 6, 6], [4, 4, 6, 6], [4, 4, 6, 6]],
                [[4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10], [4, 4, 6, 6]],
                [[4, 4, 6, 6], [4, 4, 6, 6]],
                [[4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10]],
                [[4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10]],
            ],
            [
                Position.TOP_LEFT,
            ],
            2,
            [
                [False, False, False],
                [False, False, False],
                [False, False],
                [False, False],
                [False, True],
            ],
            [
                [False, False, False],
                [False, False, False],
                [False, False],
                [False, False],
                [False, False],
            ],
            [0, 0, 0, 0, 1],
            [0, 0, 0, 0, 0],
            DoesNotRaise(),
        ),
        (  # One stays, one crosses, one disappears immediately after crossing
            Vector(Point(0, 0), Point(10, 0)),
            [
                [[4, 4, 6, 6], [4, 4, 6, 6], [4, 4, 6, 6]],
                [[4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10], [4, 4, 6, 6]],
                [[4, 4, 6, 6], [4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10]],
                [[4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10]],
                [[4, 4, 6, 6], [4, 4 - 10, 6, 6 - 10]],
            ],
            [
                Position.TOP_LEFT,
            ],
            2,
            [
                [False, False, False],
                [False, False, False],
                [False, False, False],
                [False, False],
                [False, True],
            ],
            [
                [False, False, False],
                [False, False, False],
                [False, False, False],
                [False, False],
                [False, False],
            ],
            [0, 0, 0, 0, 1],
            [0, 0, 0, 0, 0],
            DoesNotRaise(),
        ),
    ],
)
def test_line_zone_long_horizon_disappearing_detections(
    vector: Vector,
    xyxy_sequence: list[list[list[float] | None]],
    anchors: list[Position],
    minimum_crossing_threshold: int,
    expected_crossed_in: list[list[bool]],
    expected_crossed_out: list[list[bool]],
    expected_count_in: list[int],
    expected_count_out: list[int],
    exception: Exception,
) -> None:
    with exception:
        line_zone = LineZone(
            start=vector.start,
            end=vector.end,
            triggering_anchors=anchors,
            minimum_crossing_threshold=minimum_crossing_threshold,
        )
        crossed_in_list = []
        crossed_out_list = []
        count_in_list = []
        count_out_list = []
        for bboxes in xyxy_sequence:
            detections = _create_detections(
                xyxy=bboxes,
                tracker_id=[i for i in range(0, len(bboxes))],
            )
            crossed_in, crossed_out = line_zone.trigger(detections)
            crossed_in_list.append(list(crossed_in))
            crossed_out_list.append(list(crossed_out))
            count_in_list.append(line_zone.in_count)
            count_out_list.append(line_zone.out_count)

        assert crossed_in_list == expected_crossed_in
        assert crossed_out_list == expected_crossed_out
        assert count_in_list == expected_count_in
        assert count_out_list == expected_count_out


@pytest.mark.parametrize(
    (
        "xyxy_sequence",
        "tracker_id_sequence",
        "class_id_sequence",
        "expected_in_count_per_class",
        "expected_out_count_per_class",
    ),
    [
        pytest.param(
            [
                [[4, 4, 6, 6]],  # frame 0: object 0, class 0, position 4,4,6,6
                [[4, -6, 6, -4]],  # frame 1: object 0, class 0, position 4,-6,6,-4
                [[4, 4, 6, 6]],  # frame 2: object 0, class 0, position 4,4,6,6
                [[4, 4, 6, 6]],  # frame 3: object 0, class 1, position 4,4,6,6
                [[4, -6, 6, -4]],  # frame 4: object 0, class 1, position 4,-6,6,-4
                [[4, 4, 6, 6]],  # frame 5: object 0, class 1, position 4,4,6,6
            ],
            # tracker_id_sequence
            [[0], [0], [0], [0], [0], [0]],
            # class_id_sequence
            [[0], [0], [0], [1], [1], [1]],
            # expected_in_count_per_class
            {0: 1, 1: 1},
            # expected_out_count_per_class
            {0: 1, 1: 1},
            id="single_object_tracker_id_reuse_with_different_classes",
        ),
        pytest.param(
            [
                # frame 0: objects 0&1 cross IN
                [[4, 4, 6, 6], [4, 4, 6, 6]],
                # frame 1
                [[4, -6, 6, -4], [4, -6, 6, -4]],
                # frame 2: objects 0&1 cross OUT
                [[4, 4, 6, 6], [4, 4, 6, 6]],
                # frame 3: objects 2&3 cross IN
                [[4, 4, 6, 6], [4, 4, 6, 6]],
                # frame 4
                [[4, -6, 6, -4], [4, -6, 6, -4]],
                # frame 5: objects 2&3 cross OUT
                [[4, 4, 6, 6], [4, 4, 6, 6]],
            ],
            # tracker_id_sequence
            [[0, 1], [0, 1], [0, 1], [2, 3], [2, 3], [2, 3]],
            # class_id_sequence
            [[0, 1], [0, 1], [0, 1], [4, 5], [4, 5], [4, 5]],
            # expected_in_count_per_class
            {0: 1, 1: 1, 4: 1, 5: 1},
            # expected_out_count_per_class
            {0: 1, 1: 1, 4: 1, 5: 1},
            id="multiple_objects_tracker_id_reuse_with_different_classes",
        ),
    ],
)
def test_line_zone_tracker_id_reuse_with_different_classes(
    xyxy_sequence: list[list[list[float]]],
    tracker_id_sequence: list[list[int]],
    class_id_sequence: list[list[int]],
    expected_in_count_per_class: dict[int, int],
    expected_out_count_per_class: dict[int, int],
) -> None:
    line_zone = LineZone(start=Point(0, 0), end=Point(10, 0))

    for xyxy, tracker_id, class_id in zip(
        xyxy_sequence, tracker_id_sequence, class_id_sequence
    ):
        detections = _create_detections(
            xyxy=xyxy, tracker_id=tracker_id, class_id=class_id
        )
        line_zone.trigger(detections)

    assert line_zone.in_count_per_class == expected_in_count_per_class
    assert line_zone.out_count_per_class == expected_out_count_per_class


def test_line_zone_trigger_does_not_call_np_cross(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    """Guard against reintroducing np.cross, deprecated for 2-D input in NumPy 2.0."""

    def _raise(*args, **kwargs):
        raise AssertionError("np.cross must not be called on 2-D vectors")

    monkeypatch.setattr(np, "cross", _raise)

    line_zone = LineZone(start=Point(0, 0), end=Point(0, 10))
    for xyxy in [[4, 4, 6, 6], [-6, 4, -4, 6]]:
        detections = _create_detections(xyxy=[xyxy], tracker_id=[0])
        crossed_in, crossed_out = line_zone.trigger(detections)

    assert not crossed_in[0]
    assert crossed_out[0]
    assert line_zone.out_count == 1


def test_line_zone_trigger_evicts_stale_crossing_history() -> None:
    """History for tracker IDs absent from the current frame is evicted."""
    line_zone = LineZone(start=Point(0, 0), end=Point(10, 0))
    first_detections = _create_detections(
        xyxy=[[4, 4, 6, 6]], tracker_id=[0], class_id=[1]
    )
    second_detections = _create_detections(
        xyxy=[[4, 4, 6, 6]], tracker_id=[1], class_id=[2]
    )

    line_zone.trigger(first_detections)
    # Trigger twice with second_detections so tracker_id=0 accumulates
    # crossing_history_length absent frames (default=2) and is evicted.
    line_zone.trigger(second_detections)
    line_zone.trigger(second_detections)

    assert set(line_zone.crossing_state_history) == {1}


def test_line_zone_trigger_evicts_stale_crossing_history_on_empty_frames() -> None:
    """Empty frames age out tracker crossing history."""
    line_zone = LineZone(start=Point(0, 0), end=Point(10, 0))
    detections = _create_detections(xyxy=[[4, 4, 6, 6]], tracker_id=[0], class_id=[1])

    line_zone.trigger(detections)
    for _ in range(line_zone.crossing_history_length):
        line_zone.trigger(Detections.empty())

    assert not line_zone.crossing_state_history


def test_line_zone_trigger_evicts_stale_crossing_history_on_class_change() -> None:
    """Class changes must not split a tracker crossing history."""
    line_zone = LineZone(start=Point(0, 0), end=Point(10, 0))
    first_detections = _create_detections(
        xyxy=[[4, 4, 6, 6]], tracker_id=[0], class_id=[1]
    )
    second_detections = _create_detections(
        xyxy=[[4, 4, 6, 6]], tracker_id=[0], class_id=[2]
    )

    line_zone.trigger(first_detections)
    for _ in range(line_zone.crossing_history_length):
        line_zone.trigger(second_detections)

    assert set(line_zone.crossing_state_history) == {0}


def test_line_zone_class_flicker_keeps_crossing_counts_continuous() -> None:
    """A tracker's class change must not suppress a real crossing."""
    line_zone = LineZone(start=Point(0, 0), end=Point(10, 0))
    detections_sequence = [
        _create_detections(xyxy=[[4, 4, 6, 6]], tracker_id=[0], class_id=[0]),
        _create_detections(xyxy=[[4, -6, 6, -4]], tracker_id=[0], class_id=[1]),
        _create_detections(xyxy=[[4, -6, 6, -4]], tracker_id=[0], class_id=[1]),
        _create_detections(xyxy=[[4, 4, 6, 6]], tracker_id=[0], class_id=[1]),
    ]

    crossed_in = []
    crossed_out = []
    for detections in detections_sequence:
        crossed_in_frame, crossed_out_frame = line_zone.trigger(detections)
        crossed_in.append(bool(crossed_in_frame[0]))
        crossed_out.append(bool(crossed_out_frame[0]))

    assert crossed_in == [False, True, False, False]
    assert crossed_out == [False, False, False, True]
    assert line_zone.in_count_per_class == {1: 1}
    assert line_zone.out_count_per_class == {1: 1}
    assert set(line_zone.crossing_state_history) == {0}


def test_line_zone_annotator_multiclass_supports_none_class_id() -> None:
    line_zone = LineZone(start=Point(0, 0), end=Point(0, 10))
    for xyxy in [[4, 4, 6, 6], [-6, 4, -4, 6]]:
        detections = _create_detections(xyxy=[xyxy], tracker_id=[0])
        line_zone.trigger(detections)

    assert line_zone.out_count_per_class == {None: 1}

    frame = np.zeros((100, 100, 3), dtype=np.uint8)
    annotator = LineZoneAnnotatorMulticlass(force_draw_class_ids=False)
    annotated_frame = annotator.annotate(frame=frame.copy(), line_zones=[line_zone])

    assert annotated_frame.shape == frame.shape
    assert not np.array_equal(annotated_frame, frame)


def test_line_zone_label_rotation_uses_pillow_canvas() -> None:
    """Render a rotated BGRA count label through the domain-specific Pillow path."""
    upright = LineZoneAnnotator._make_label_image(
        "out: 7",
        text_scale=0.75,
        text_thickness=1,
        text_padding=4,
        text_color=Color.WHITE,
        text_box_show=True,
        text_box_color=Color.BLACK,
        line_angle_degrees=0,
    )
    label = LineZoneAnnotator._make_label_image(
        "out: 7",
        text_scale=0.75,
        text_thickness=1,
        text_padding=4,
        text_color=Color.WHITE,
        text_box_show=True,
        text_box_color=Color.BLACK,
        line_angle_degrees=37,
    )

    assert label.ndim == 3
    assert label.shape[2] == 4
    assert np.any(label[..., 3])
    assert not np.array_equal(label[..., 3], upright[..., 3])
