"""OperationRegistry + WatchOpStream — transitions, dedup, notify-not-poll."""

from __future__ import annotations

import threading
import time

import pytest
from mgf.vm.core.contracts import (
    OperationHandle,
    OperationKind,
    OperationStatus,
)
from mgf.vm.core.exceptions import DomainNotFound
from mgf.vm.core.ids import OperationId

from mgf.vm.sdk.operations import OperationRegistry, WatchOpStream


def _registry_with_pending(
    op_id: str = "op-1",
    client_op_id: str = "cli-1",
) -> tuple[OperationRegistry, OperationId]:
    reg = OperationRegistry()
    oid = OperationId(op_id)
    reg.submit(oid, OperationKind.VM_CREATE, client_op_id=client_op_id)
    return reg, oid


# -- re-export -------------------------------------------------------------


def test_operation_model_reexported_from_core() -> None:
    from mgf.vm.core import contracts as core

    assert OperationHandle is core.OperationHandle
    assert OperationStatus is core.OperationStatus
    assert OperationKind is core.OperationKind


# -- submission + dedup ----------------------------------------------------


def test_submit_creates_pending_handle() -> None:
    reg, oid = _registry_with_pending()
    handle = reg.get(oid)
    assert handle.status is OperationStatus.PENDING
    assert handle.op_id == oid
    assert handle.kind is OperationKind.VM_CREATE


def test_submit_dedups_on_client_op_id() -> None:
    reg = OperationRegistry()
    first = reg.submit(OperationId("op-1"), OperationKind.VM_START, client_op_id="dup")
    # A second submit with the SAME client_op_id returns the original handle,
    # even with a different op_id — no duplicate op is created.
    second = reg.submit(OperationId("op-2"), OperationKind.VM_STOP, client_op_id="dup")
    assert second.op_id == first.op_id
    assert second.kind is OperationKind.VM_START
    assert len(reg.list()) == 1


def test_distinct_client_op_ids_create_distinct_ops() -> None:
    reg = OperationRegistry()
    reg.submit(OperationId("op-1"), OperationKind.VM_START, client_op_id="a")
    reg.submit(OperationId("op-2"), OperationKind.VM_STOP, client_op_id="b")
    assert len(reg.list()) == 2


# -- transitions -----------------------------------------------------------


def test_mark_running() -> None:
    reg, oid = _registry_with_pending()
    handle = reg.mark_running(oid)
    assert handle.status is OperationStatus.RUNNING
    assert reg.get(oid).status is OperationStatus.RUNNING


def test_mark_completed_carries_result() -> None:
    reg, oid = _registry_with_pending()
    reg.mark_running(oid)
    sentinel = object()
    handle = reg.mark_completed(oid, sentinel)
    assert handle.status is OperationStatus.SUCCEEDED
    assert handle.result is sentinel
    assert handle.finished_at is not None


def test_mark_failed_carries_original_exception_unwrapped() -> None:
    reg, oid = _registry_with_pending()
    reg.mark_running(oid)
    err = DomainNotFound("vm-x")
    handle = reg.mark_failed(oid, err)
    assert handle.status is OperationStatus.FAILED
    # The ORIGINAL core exception, no re-wrapping (api.md §7).
    assert handle.error is err


def test_mark_cancelled() -> None:
    reg, oid = _registry_with_pending()
    handle = reg.mark_cancelled(oid)
    assert handle.status is OperationStatus.CANCELLED
    assert handle.finished_at is not None


def test_get_unknown_op_raises_keyerror() -> None:
    reg = OperationRegistry()
    with pytest.raises(KeyError):
        reg.get(OperationId("nope"))


def test_watch_unknown_op_raises_keyerror() -> None:
    reg = OperationRegistry()
    with pytest.raises(KeyError):
        reg.watch(OperationId("nope"))


# -- WatchOpStream ---------------------------------------------------------


def test_watch_yields_transitions_in_order() -> None:
    reg, oid = _registry_with_pending()
    stream = reg.watch(oid)
    assert isinstance(stream, WatchOpStream)

    seen: list[OperationStatus] = []
    observed = threading.Event()

    def drive() -> None:
        for handle in stream:
            seen.append(handle.status)
            observed.set()

    consumer = threading.Thread(target=drive)
    consumer.start()

    # Lockstep: advance one transition, wait for the consumer to observe it,
    # then advance the next. This proves the stream yields *every* transition
    # in order when the consumer keeps pace (vs. coalescing to the latest).
    reg.mark_running(oid)
    assert observed.wait(timeout=5)
    observed.clear()
    reg.mark_completed(oid, "done")
    consumer.join(timeout=5)

    assert not consumer.is_alive()
    assert seen == [OperationStatus.RUNNING, OperationStatus.SUCCEEDED]


def test_watch_exhausts_after_terminal() -> None:
    reg, oid = _registry_with_pending()
    stream = reg.watch(oid)
    reg.mark_failed(oid, DomainNotFound("x"))
    first = next(stream)
    assert first.status is OperationStatus.FAILED
    with pytest.raises(StopIteration):
        next(stream)


def test_watch_close_is_idempotent_and_exhausts() -> None:
    reg, oid = _registry_with_pending()
    stream = reg.watch(oid)
    stream.close()
    stream.close()
    with pytest.raises(StopIteration):
        next(stream)


def test_watch_notifies_not_polls() -> None:
    """A waiter must wake promptly via Condition.notify, not by spinning.

    We assert the consumer is genuinely *blocked* (not returned, not spinning)
    until the transition lands, then wakes near-immediately after it.
    """
    reg, oid = _registry_with_pending()
    stream = reg.watch(oid)
    woke_at: list[float] = []

    def drive() -> None:
        handle = next(stream)
        woke_at.append(time.monotonic())
        assert handle.status is OperationStatus.RUNNING

    consumer = threading.Thread(target=drive)
    consumer.start()
    # Give the consumer time to enter the wait; if it were polling/returning
    # early it would have appended already.
    time.sleep(0.1)
    assert woke_at == []
    fired_at = time.monotonic()
    reg.mark_running(oid)
    consumer.join(timeout=5)
    assert not consumer.is_alive()
    # Woke within a tight window of the notify — Condition wakeup, not a poll.
    assert woke_at and (woke_at[0] - fired_at) < 1.0


def test_list_returns_all_handles() -> None:
    reg = OperationRegistry()
    reg.submit(OperationId("op-1"), OperationKind.VM_START, client_op_id="a")
    reg.submit(OperationId("op-2"), OperationKind.POOL_CREATE, client_op_id="b")
    statuses = {h.op_id for h in reg.list()}
    assert statuses == {OperationId("op-1"), OperationId("op-2")}
