from datetime import datetime, timedelta, timezone
from typing import Sequence
from uuid import uuid4

import pytest
from prefect_redis.ordering import (
    MAX_DEPTH_OF_PRECEDING_EVENT,
    CausalOrdering,
    EventArrivedEarly,
)

from prefect.server.events.schemas.events import ReceivedEvent, Resource
from prefect.types import DateTime


@pytest.fixture
def start_of_test() -> DateTime:
    return datetime.now(timezone.utc)


@pytest.fixture
def resource() -> Resource:
    return Resource({"prefect.resource.id": "any.thing"})


@pytest.fixture
def event_one(
    start_of_test: DateTime,
    resource: Resource,
) -> ReceivedEvent:
    return ReceivedEvent(
        resource=resource,
        event="event.one",
        occurred=start_of_test + timedelta(seconds=1),
        received=start_of_test + timedelta(seconds=1),
        id=uuid4(),
        follows=None,
    )


@pytest.fixture
def event_two(event_one: ReceivedEvent) -> ReceivedEvent:
    return ReceivedEvent(
        event="event.two",
        id=uuid4(),
        follows=event_one.id,
        resource=event_one.resource,
        occurred=event_one.occurred + timedelta(seconds=1),
        received=event_one.received + timedelta(seconds=1, milliseconds=1),
    )


@pytest.fixture
def event_three_a(event_two: ReceivedEvent) -> ReceivedEvent:
    return ReceivedEvent(
        event="event.three.a",
        id=uuid4(),
        follows=event_two.id,
        resource=event_two.resource,
        occurred=event_two.occurred + timedelta(seconds=1),
        received=event_two.received + timedelta(seconds=1, milliseconds=1),
    )


@pytest.fixture
def event_three_b(event_two: ReceivedEvent) -> ReceivedEvent:
    return ReceivedEvent(
        event="event.three.b",
        id=uuid4(),
        follows=event_two.id,
        resource=event_two.resource,
        occurred=event_two.occurred + timedelta(seconds=2),
        received=event_two.received + timedelta(seconds=2, milliseconds=1),
    )


@pytest.fixture
def in_proper_order(
    event_one: ReceivedEvent,
    event_two: ReceivedEvent,
    event_three_a: ReceivedEvent,
    event_three_b: ReceivedEvent,
) -> Sequence[ReceivedEvent]:
    return [event_one, event_two, event_three_a, event_three_b]


@pytest.fixture
def in_jumbled_order(
    event_one: ReceivedEvent,
    event_two: ReceivedEvent,
    event_three_a: ReceivedEvent,
    event_three_b: ReceivedEvent,
) -> Sequence[ReceivedEvent]:
    return [event_two, event_three_a, event_one, event_three_b]


@pytest.fixture
def backwards(
    event_one: ReceivedEvent,
    event_two: ReceivedEvent,
    event_three_a: ReceivedEvent,
    event_three_b: ReceivedEvent,
) -> Sequence[ReceivedEvent]:
    return [event_three_b, event_three_a, event_two, event_one]


@pytest.fixture(params=["in_proper_order", "in_jumbled_order", "backwards"])
def example(request: pytest.FixtureRequest) -> Sequence[ReceivedEvent]:
    return request.getfixturevalue(request.param)


@pytest.fixture
def causal_ordering() -> CausalOrdering:
    return CausalOrdering(scope="unit-tests")


async def test_ordering_is_correct(
    causal_ordering: CausalOrdering,
    in_proper_order: Sequence[ReceivedEvent],
    example: Sequence[ReceivedEvent],
):
    processed = []

    async def evaluate(event: ReceivedEvent, depth: int = 0) -> None:
        async with causal_ordering.preceding_event_confirmed(
            evaluate, event, depth=depth
        ):
            processed.append(event)

    example = list(example)
    while example:
        try:
            await evaluate(example.pop(0))
        except EventArrivedEarly:
            continue

    assert processed == in_proper_order


@pytest.fixture
def worst_case(event_one: ReceivedEvent) -> list[ReceivedEvent]:
    causal_order = []

    # The worst case scenario for exceeding the depth of the preceding event is to have
    # a long chain of events that are all linked to the same preceding event and then
    # for that sequence to arrive in reverse order.  The depth of resolving followers
    # will be the length of that chain.  It's +1 here so that we go over the limit.

    previous = event_one

    for i in range(MAX_DEPTH_OF_PRECEDING_EVENT + 1):
        this_one = ReceivedEvent(
            event=f"event.{i}",
            resource=previous.resource,
            occurred=previous.occurred + timedelta(seconds=1),
            id=uuid4(),
            follows=previous.id,
        )

        causal_order.append(this_one)
        previous = this_one

    return list(reversed(causal_order))


async def test_recursion_is_contained(
    causal_ordering: CausalOrdering,
    event_one: ReceivedEvent,
    worst_case: list[ReceivedEvent],
    caplog: pytest.LogCaptureFixture,
):
    async def evaluate(event: ReceivedEvent, depth: int = 0) -> None:
        async with causal_ordering.preceding_event_confirmed(
            evaluate, event, depth=depth
        ):
            pass

    while worst_case:
        try:
            await evaluate(worst_case.pop(0))
        except EventArrivedEarly:
            continue

    # TODO - should this raise instead?
    with caplog.at_level("INFO"):
        await evaluate(event_one)

    assert (
        f"reached its max depth of {MAX_DEPTH_OF_PRECEDING_EVENT} followers processed"
        in caplog.text
    )


async def test_only_looks_to_a_certain_horizon(
    causal_ordering: CausalOrdering,
    event_one: ReceivedEvent,
    event_two: ReceivedEvent,
):
    # backdate the events so they happened before the lookback period
    event_one.received -= timedelta(days=1)
    event_two.received -= timedelta(days=1)

    processed = []

    async def evaluate(event: ReceivedEvent, depth: int = 0) -> None:
        async with causal_ordering.preceding_event_confirmed(
            evaluate, event, depth=depth
        ):
            processed.append(event)

    # will not raise EventArrivedEarly because we're outside the range we can look back
    await evaluate(event_two)
    await evaluate(event_one)

    assert processed == [event_two, event_one]


async def test_returns_lost_followers_in_occurred_order(
    causal_ordering: CausalOrdering,
    event_two: ReceivedEvent,
    event_three_a: ReceivedEvent,
    event_three_b: ReceivedEvent,
    monkeypatch: pytest.MonkeyPatch,
):
    processed = []

    async def evaluate(event: ReceivedEvent, depth: int = 0) -> None:
        async with causal_ordering.preceding_event_confirmed(
            evaluate, event, depth=depth
        ):
            processed.append(event)

    example = [event_three_a, event_three_b, event_two]
    while example:
        try:
            await evaluate(example.pop(0))
        except EventArrivedEarly:
            continue

    assert processed == []

    # setting to a negative duration here simulates moving into the future
    monkeypatch.setattr(
        "prefect_redis.ordering.PRECEDING_EVENT_LOOKBACK",
        timedelta(minutes=-1),
    )

    # because event one never arrived, these are all lost followers
    lost_followers = await causal_ordering.get_lost_followers()
    assert lost_followers == [event_two, event_three_a, event_three_b]


async def test_two_instances_do_not_interfere(
    event_one: ReceivedEvent,
    event_two: ReceivedEvent,
):
    # A partial test that two instances of the same class do not interfere with each
    # other.  This does not test every piece of functionality, but illustrates that
    # prefixes are used.

    ordering_one = CausalOrdering(scope="one")
    ordering_two = CausalOrdering(scope="two")

    await ordering_one.record_event_as_seen(event_one)
    assert await ordering_one.event_has_been_seen(event_one)
    assert not await ordering_two.event_has_been_seen(event_one)

    await ordering_two.record_event_as_seen(event_one)
    assert await ordering_one.event_has_been_seen(event_one)
    assert await ordering_two.event_has_been_seen(event_one)

    await ordering_one.record_follower(event_two)
    assert await ordering_one.get_followers(event_one) == [event_two]
    assert await ordering_two.get_followers(event_one) == []

    await ordering_two.record_follower(event_two)
    assert await ordering_one.get_followers(event_one) == [event_two]
    assert await ordering_two.get_followers(event_one) == [event_two]

    await ordering_one.forget_follower(event_two)
    assert await ordering_one.get_followers(event_one) == []
    assert await ordering_two.get_followers(event_one) == [event_two]

    await ordering_two.forget_follower(event_two)
    assert await ordering_one.get_followers(event_one) == []
    assert await ordering_two.get_followers(event_one) == []


async def test_atomic_completion_finds_parked_followers(
    causal_ordering: CausalOrdering,
    event_one: ReceivedEvent,
    event_two: ReceivedEvent,
):
    """Test that the atomic completion finds followers that parked before completion.

    This tests the fix for the race condition where followers could be orphaned
    if they parked between the leader's DEL processing and SMEMBERS followers.
    With the Lua script fix, SET seen + SMEMBERS followers + DEL processing
    happen atomically, preventing this race.
    """
    # First, have the follower park itself (simulating it arriving before leader finished)
    await causal_ordering.record_follower(event_two)

    # Verify the follower is parked
    followers = await causal_ordering.get_followers(event_one)
    assert event_two in followers

    # Now have the leader complete using the atomic completion
    # This uses complete_event_and_get_followers internally via the context manager
    async with causal_ordering.event_is_processing(event_one) as completion:
        # The completion object will be populated with followers after the context exits
        pass

    # The completion should have found the parked follower
    assert len(completion.followers) == 1
    assert completion.followers[0].id == event_two.id


async def test_claimed_follower_not_dropped(
    causal_ordering: CausalOrdering,
    event_one: ReceivedEvent,
    event_two: ReceivedEvent,
):
    """Test that a follower claimed by leader isn't dropped due to double-check cleanup.

    This is a regression test for the scenario where:
    1. Follower parks itself
    2. Leader's Lua script runs: SET seen, SMEMBERS+DEL followers (claims follower)
    3. Follower's double-check sees seen=True, sismember=False (claimed)
    4. Follower should NOT delete its event data - leader needs it

    Without the fix, the follower would delete event:{id} before the leader
    could read it via followers_by_id(), causing silent data loss.
    """
    # Step 1: Follower parks itself
    await causal_ordering.record_follower(event_two)

    # Verify event data exists
    event_key = causal_ordering._key(f"event:{event_two.id}")
    assert await causal_ordering.redis.exists(event_key) == 1

    # Step 2: Leader completes, claiming the follower
    await causal_ordering.record_event_as_processing(event_one)
    followers = await causal_ordering.complete_event_and_get_followers(event_one)

    # Verify leader got the follower
    assert len(followers) == 1
    assert followers[0].id == event_two.id

    # Step 3: Simulate follower's double-check path
    # In the real code, this happens in wait_for_leader when follower sees:
    # - seen=True (leader completed)
    # - sismember=False (Lua DELeted followers set, so follower was claimed)
    seen = await causal_ordering.event_has_been_seen(event_one)
    assert seen is True

    still_in_followers = await causal_ordering.redis.sismember(
        causal_ordering._key(f"followers:{event_one.id}"), str(event_two.id)
    )
    assert still_in_followers == 0  # Claimed by leader's Lua script

    # Step 4: Verify event data is STILL available for the leader
    # The fix ensures we don't delete event:{id} in the claimed branch
    assert await causal_ordering.redis.exists(event_key) == 1

    # Leader can still read the event data
    event_json = await causal_ordering.redis.get(event_key)
    assert event_json is not None


async def test_followers_processed_by_leader_are_marked_as_seen(
    causal_ordering: CausalOrdering,
    event_one: ReceivedEvent,
    event_two: ReceivedEvent,
    event_three_a: ReceivedEvent,
):
    """Test that when a leader processes parked followers, those followers are
    marked as seen so that downstream events can find them.

    This is a regression test for a bug where followers processed by leaders via
    handler(waiter) were never marked as seen because they bypassed
    event_is_processing. Downstream events following those followers would park
    and become orphaned until PRECEDING_EVENT_LOOKBACK expiration.
    """
    processed = []

    async def evaluate(event: ReceivedEvent, depth: int = 0) -> None:
        async with causal_ordering.preceding_event_confirmed(
            evaluate, event, depth=depth
        ):
            processed.append(event)

    # Send events out of order: event_two and event_three_a arrive before event_one
    # event_two follows event_one, event_three_a follows event_two
    for event in [event_two, event_three_a]:
        try:
            await evaluate(event)
        except EventArrivedEarly:
            pass

    # Nothing processed yet — both are parked
    assert processed == []

    # Now event_one arrives — it should process event_one, then event_two as a
    # follower, then event_three_a as event_two's follower
    await evaluate(event_one)

    assert event_one in processed
    assert event_two in processed
    assert event_three_a in processed

    # The key assertion: event_two should be marked as seen so that event_three_a
    # (which follows event_two) could find it
    assert await causal_ordering.event_has_been_seen(event_two) is True
    # event_one is also seen (set by the Lua script in event_is_processing)
    assert await causal_ordering.event_has_been_seen(event_one) is True
