"""Phase 4 of the v0.3 closed-box redesign — progress + typing tests.

Covers:

  - ``mgf.common.progress.CancellationToken`` (sync, threading-friendly).
  - ``mgf.common.progress.NullReporter`` and ``RecordingReporter``.
  - ``mgf.common.progress.asyncio.AsyncCancellationToken`` (async sibling).
  - ``mgf.common.typing`` Protocol re-exports + isinstance smoke.
  - ``mgf.common.exceptions.CancellationError`` shape.
"""

from __future__ import annotations

import asyncio
import threading
import time

import pytest

from mgf.common.exceptions import (
    AppError,
    CancellationError,
    OperationError,
)
from mgf.common.progress import (
    CancellationToken,
    NullReporter,
    ProgressReporter,
    RecordingReporter,
)
from mgf.common.progress.asyncio import AsyncCancellationToken
from mgf.common.typing import (
    CancellationProtocol,
    LogSinkProtocol,
    ReporterProtocol,
    VaultProtocol,
)

# ---------------------------------------------------------------------------
# CancellationToken (sync)
# ---------------------------------------------------------------------------


class TestCancellationToken:
    def test_initial_state_is_not_cancelled(self) -> None:
        t = CancellationToken()
        assert t.is_cancelled() is False

    def test_cancel_flips_flag(self) -> None:
        t = CancellationToken()
        t.cancel()
        assert t.is_cancelled() is True

    def test_cancel_is_idempotent(self) -> None:
        t = CancellationToken()
        t.cancel()
        t.cancel()  # MUST NOT raise
        assert t.is_cancelled() is True

    def test_wait_returns_immediately_when_cancelled(self) -> None:
        t = CancellationToken()
        t.cancel()
        start = time.monotonic()
        result = t.wait(timeout=5.0)
        elapsed = time.monotonic() - start
        assert result is True
        assert elapsed < 0.1, "wait() should return immediately when already cancelled"

    def test_wait_returns_false_on_timeout(self) -> None:
        t = CancellationToken()
        result = t.wait(timeout=0.05)
        assert result is False

    def test_wait_returns_true_on_cross_thread_cancel(self) -> None:
        t = CancellationToken()

        def _canceller() -> None:
            time.sleep(0.05)
            t.cancel()

        thread = threading.Thread(target=_canceller)
        thread.start()
        try:
            result = t.wait(timeout=2.0)
            assert result is True
        finally:
            thread.join()

    def test_raise_if_cancelled_raises_when_set(self) -> None:
        t = CancellationToken()
        t.cancel()
        with pytest.raises(CancellationError, match="cancelled"):
            t.raise_if_cancelled()

    def test_raise_if_cancelled_no_op_when_not_set(self) -> None:
        t = CancellationToken()
        t.raise_if_cancelled()  # MUST NOT raise

    def test_raise_if_cancelled_custom_message(self) -> None:
        t = CancellationToken()
        t.cancel()
        with pytest.raises(CancellationError, match="step 3"):
            t.raise_if_cancelled("VM clone cancelled at step 3 of 7")

    def test_repr(self) -> None:
        t = CancellationToken()
        assert "active" in repr(t)
        t.cancel()
        assert "cancelled" in repr(t)

    def test_implements_cancellation_protocol(self) -> None:
        t = CancellationToken()
        assert isinstance(t, CancellationProtocol)


# ---------------------------------------------------------------------------
# NullReporter + RecordingReporter
# ---------------------------------------------------------------------------


class TestNullReporter:
    def test_report_is_noop(self) -> None:
        r = NullReporter()
        r.report("anything", current=1, total=10)  # MUST NOT raise

    def test_is_cancelled_false_without_token(self) -> None:
        assert NullReporter().is_cancelled() is False

    def test_is_cancelled_forwards_to_token(self) -> None:
        t = CancellationToken()
        r = NullReporter(token=t)
        assert r.is_cancelled() is False
        t.cancel()
        assert r.is_cancelled() is True

    def test_implements_reporter_protocol(self) -> None:
        assert isinstance(NullReporter(), ReporterProtocol)


class TestRecordingReporter:
    def test_calls_accumulate(self) -> None:
        r = RecordingReporter()
        r.report("step 1", current=1, total=3)
        r.report("step 2", current=2, total=3)
        r.report("step 3", current=3, total=3)
        assert r.calls == [
            ("step 1", 1, 3),
            ("step 2", 2, 3),
            ("step 3", 3, 3),
        ]

    def test_message_only_call(self) -> None:
        """Calling report() with only a message uses default 0/0 ints."""
        r = RecordingReporter()
        r.report("just a message")
        assert r.calls == [("just a message", 0, 0)]

    def test_reset_clears_calls(self) -> None:
        r = RecordingReporter()
        r.report("a")
        r.report("b")
        assert len(r.calls) == 2
        r.reset()
        assert r.calls == []

    def test_is_cancelled_forwards_to_token(self) -> None:
        t = CancellationToken()
        r = RecordingReporter(token=t)
        assert r.is_cancelled() is False
        t.cancel()
        assert r.is_cancelled() is True

    def test_implements_reporter_protocol(self) -> None:
        assert isinstance(RecordingReporter(), ReporterProtocol)


# ---------------------------------------------------------------------------
# Ergonomic alias: ProgressReporter IS ReporterProtocol
# ---------------------------------------------------------------------------


class TestProgressReporterAlias:
    def test_progress_reporter_is_reporter_protocol(self) -> None:
        """``mgf.common.progress.ProgressReporter`` IS
        ``mgf.common.typing.ReporterProtocol`` — same object,
        re-exported for ergonomics."""
        assert ProgressReporter is ReporterProtocol


# ---------------------------------------------------------------------------
# AsyncCancellationToken
# ---------------------------------------------------------------------------


class TestAsyncCancellationToken:
    def test_initial_state_is_not_cancelled(self) -> None:
        t = AsyncCancellationToken()
        assert t.is_cancelled() is False

    def test_cancel_flips_flag(self) -> None:
        t = AsyncCancellationToken()
        t.cancel()
        assert t.is_cancelled() is True

    def test_implements_cancellation_protocol(self) -> None:
        t = AsyncCancellationToken()
        assert isinstance(t, CancellationProtocol)

    def test_repr(self) -> None:
        t = AsyncCancellationToken()
        assert "active" in repr(t)
        t.cancel()
        assert "cancelled" in repr(t)

    @pytest.mark.asyncio
    async def test_wait_returns_immediately_when_cancelled(self) -> None:
        t = AsyncCancellationToken()
        t.cancel()
        result = await t.wait(timeout=5.0)
        assert result is True

    @pytest.mark.asyncio
    async def test_wait_returns_false_on_timeout(self) -> None:
        t = AsyncCancellationToken()
        result = await t.wait(timeout=0.05)
        assert result is False

    @pytest.mark.asyncio
    async def test_wait_returns_true_when_cancel_arrives(self) -> None:
        t = AsyncCancellationToken()

        async def _canceller() -> None:
            await asyncio.sleep(0.05)
            t.cancel()

        cancel_task = asyncio.create_task(_canceller())
        try:
            result = await t.wait(timeout=2.0)
            assert result is True
        finally:
            await cancel_task

    @pytest.mark.asyncio
    async def test_wait_no_timeout_blocks_until_cancel(self) -> None:
        t = AsyncCancellationToken()

        async def _canceller() -> None:
            await asyncio.sleep(0.02)
            t.cancel()

        cancel_task = asyncio.create_task(_canceller())
        try:
            result = await t.wait()  # no timeout → blocks until cancel
            assert result is True
        finally:
            await cancel_task

    def test_raise_if_cancelled_raises_when_set(self) -> None:
        t = AsyncCancellationToken()
        t.cancel()
        with pytest.raises(CancellationError):
            t.raise_if_cancelled()

    def test_raise_if_cancelled_custom_message(self) -> None:
        t = AsyncCancellationToken()
        t.cancel()
        with pytest.raises(CancellationError, match="async step"):
            t.raise_if_cancelled("async step cancelled")


# ---------------------------------------------------------------------------
# CancellationError shape
# ---------------------------------------------------------------------------


class TestCancellationError:
    def test_default_message(self) -> None:
        err = CancellationError()
        assert "cancelled" in str(err)

    def test_custom_message(self) -> None:
        err = CancellationError("VM operation aborted")
        assert "VM operation aborted" in str(err)

    def test_subclass_of_operation_error(self) -> None:
        """Existing CLI top-level handlers that map OperationError →
        exit code catch CancellationError without code change
        (rule EH-04)."""
        assert issubclass(CancellationError, OperationError)

    def test_subclass_of_app_error(self) -> None:
        assert issubclass(CancellationError, AppError)


# ---------------------------------------------------------------------------
# typing module — every Protocol importable + runtime_checkable
# ---------------------------------------------------------------------------


class TestProtocols:
    def test_cancellation_protocol_runtime_checkable(self) -> None:
        # CancellationToken implements it (already tested above).
        # An object MISSING the methods MUST fail isinstance.
        class _Bad:
            pass

        assert not isinstance(_Bad(), CancellationProtocol)

    def test_reporter_protocol_runtime_checkable(self) -> None:
        class _Bad:
            pass

        assert not isinstance(_Bad(), ReporterProtocol)

    def test_log_sink_protocol_importable(self) -> None:
        # LogSinkProtocol is for future GUI helpers; we just verify
        # the import works + runtime_checkable is set.
        class _Sink:
            def append_info(self, message: str) -> None:
                pass

            def append_error(self, message: str) -> None:
                pass

        assert isinstance(_Sink(), LogSinkProtocol)

    def test_vault_protocol_importable(self) -> None:
        # VaultProtocol — concrete impls land in mgf.common.vault
        # in Phase 5. Test that a duck-typed impl satisfies it.
        class _Vault:
            def get(self, key: str) -> str | None:
                return None

            def set(self, key: str, value: str) -> None:
                pass

            def delete(self, key: str) -> None:
                pass

            def list_keys(self) -> list[str]:
                return []

            @property
            def backend(self) -> str:
                return "test"

        assert isinstance(_Vault(), VaultProtocol)


# ---------------------------------------------------------------------------
# Integration: a long-running op accepts the protocols + works
# with both sync and async tokens
# ---------------------------------------------------------------------------


class TestIntegration:
    """The shape consumers will actually use: a function that
    accepts ``CancellationProtocol`` + ``ReporterProtocol`` and
    works with any concrete impl."""

    def test_sync_op_with_token_and_reporter(self) -> None:
        def long_op(
            *,
            token: CancellationProtocol,
            reporter: ReporterProtocol,
        ) -> int:
            count = 0
            for i in range(5):
                token.raise_if_cancelled() if hasattr(
                    token,
                    "raise_if_cancelled",
                ) else None  # protocol surface is minimal
                reporter.report(f"step {i}", current=i + 1, total=5)
                count += 1
            return count

        token = CancellationToken()
        reporter = RecordingReporter()
        result = long_op(token=token, reporter=reporter)
        assert result == 5
        assert len(reporter.calls) == 5

    def test_sync_op_cancelled_mid_flight(self) -> None:
        def long_op(*, token: CancellationToken, reporter: RecordingReporter) -> None:
            for i in range(1000):
                token.raise_if_cancelled()
                reporter.report(f"step {i}")
                # Small sleep so the canceller thread has a window
                # to fire cancel() before this loop completes.
                time.sleep(0.001)

        token = CancellationToken()
        reporter = RecordingReporter()

        def _cancel_after_short() -> None:
            time.sleep(0.05)
            token.cancel()

        thread = threading.Thread(target=_cancel_after_short)
        thread.start()
        try:
            with pytest.raises(CancellationError):
                long_op(token=token, reporter=reporter)
        finally:
            thread.join()

    @pytest.mark.asyncio
    async def test_async_op_cancelled_mid_flight(self) -> None:
        async def long_op(token: AsyncCancellationToken) -> None:
            for _ in range(100):
                token.raise_if_cancelled()
                await asyncio.sleep(0.001)

        token = AsyncCancellationToken()

        async def _cancel_after_short() -> None:
            await asyncio.sleep(0.01)
            token.cancel()

        cancel_task = asyncio.create_task(_cancel_after_short())
        try:
            with pytest.raises(CancellationError):
                await long_op(token)
        finally:
            await cancel_task
