"""``mgf.common.progress`` — cancellation + progress primitives.

See: ``docs/reference/progress.md``.

Closes FEEDBACK API-03 (the lift). Lifted from vm-vmanager-app's
``vmanager.common.progress`` so every future ``mgf-common``
component (SSH, retry, service-manager, async I/O) cooperates
with cancellation through the same primitive — no
adapter / bridge layer between consumer code + library code.

Public surface:

  - :class:`CancellationToken` — sync, threading-friendly
    cancellation token. ``cancel() / is_cancelled() / wait(timeout)
    / raise_if_cancelled()``. Implements
    :class:`mgf.common.typing.CancellationProtocol`.

  - :class:`ProgressReporter` — alias for
    :class:`mgf.common.typing.ReporterProtocol` (the Protocol
    canonical-defined in ``mgf.common.typing``; re-exported here
    for ergonomic ``def f(reporter: ProgressReporter): ...``
    annotations).

  - :class:`NullReporter` — no-op concrete impl of
    ``ProgressReporter``. Use when progress updates are not
    relevant to the call site but the underlying op requires a
    reporter argument.

  - :class:`RecordingReporter` — test double. Accumulates
    ``report()`` calls in ``.calls`` for assertions.

For the asyncio variant (``await wait()`` instead of blocking),
see :mod:`mgf.common.progress.asyncio` — sibling submodule per
the sync-first / async-adapter rule (CLOSED_BOX_REDESIGN.md
§14.1).

Drop-in usage::

    from mgf.common.progress import CancellationToken, NullReporter
    from mgf.common.typing import ReporterProtocol

    def long_op(*, token: CancellationToken, reporter: ReporterProtocol) -> None:
        for i, item in enumerate(items):
            token.raise_if_cancelled()  # or: if token.is_cancelled(): break
            do_thing(item)
            reporter.report(f"processed {i+1}/{len(items)}", current=i+1, total=len(items))

    # Caller — sync:
    token = CancellationToken()
    long_op(token=token, reporter=NullReporter())

    # Caller — Qt GUI:
    # ... QThread-side: build a Qt-bridge reporter that emits signals.
"""

from __future__ import annotations

import threading

from mgf.common.exceptions import CancellationError
from mgf.common.typing import CancellationProtocol, ReporterProtocol

# Ergonomic alias. ProgressReporter is the protocol; the canonical
# definition lives in ``mgf.common.typing.ReporterProtocol`` per
# the §4.9 closed-box-redesign decision. Re-exporting here means
# consumers can write ``from mgf.common.progress import
# ProgressReporter`` (the ergonomic name) without giving up the
# single-source-of-truth property.
ProgressReporter = ReporterProtocol


class CancellationToken:
    """Thread-safe cooperative cancellation token.

    Signal-oriented:

      - :meth:`cancel` flips an internal flag (idempotent).
      - :meth:`is_cancelled` is a cheap read for the inner-loop
        check (single threading.Event read; ~50 ns).
      - :meth:`wait` blocks until cancelled OR timeout; returns
        ``True`` if cancelled.
      - :meth:`raise_if_cancelled` raises
        :class:`mgf.common.exceptions.CancellationError`
        when set — convenience for inner loops that prefer the
        exception-driven shape.

    Implements :class:`mgf.common.typing.CancellationProtocol`.

    Threading: thread-safe. Backed by :class:`threading.Event`
    so multiple threads can poll / cancel concurrently without
    a separate lock.

    For asyncio code, use the async sibling at
    :class:`mgf.common.progress.asyncio.AsyncCancellationToken`
    — same surface plus ``await wait()``.
    """

    __slots__ = ("_event",)

    def __init__(self) -> None:
        self._event = threading.Event()

    def cancel(self) -> None:
        """Signal cancellation. Idempotent — calling twice is a no-op."""
        self._event.set()

    def is_cancelled(self) -> bool:
        """Cheap read. Safe to call in tight loops (~50 ns per call)."""
        return self._event.is_set()

    def wait(self, timeout: float | None = None) -> bool:
        """Block until cancelled or timeout.

        Returns ``True`` if cancelled, ``False`` if the timeout
        expired without cancellation. ``timeout=None`` blocks
        indefinitely.
        """
        return self._event.wait(timeout)

    def raise_if_cancelled(self, message: str | None = None) -> None:
        """Raise :class:`CancellationError` if ``cancel()`` was called.

        Convenience for the inner-loop pattern::

            for item in items:
                token.raise_if_cancelled()
                do_thing(item)

        The default message is ``"operation cancelled by user"``;
        consumers MAY pass a more specific one (e.g.,
        ``"VM clone cancelled at step 3 of 7"``) for the
        user-facing error.
        """
        if self._event.is_set():
            raise CancellationError(message or "operation cancelled by user")

    def __repr__(self) -> str:
        state = "cancelled" if self._event.is_set() else "active"
        return f"CancellationToken({state})"


class NullReporter:
    """No-op :class:`ProgressReporter`. Use when progress is
    not relevant to the caller but the underlying op requires
    a reporter argument.

    Threading: thread-safe (stateless apart from the optional
    cancellation token).

    Construct with ``token=...`` to forward
    :meth:`is_cancelled` to a real cancellation token; otherwise
    ``is_cancelled`` always returns ``False``.
    """

    __slots__ = ("_token",)

    def __init__(self, token: CancellationProtocol | None = None) -> None:
        self._token = token

    def report(self, message: str, current: int = 0, total: int = 0) -> None:
        """No-op."""

    def is_cancelled(self) -> bool:
        """Forward to the underlying token; ``False`` if no token."""
        return self._token.is_cancelled() if self._token is not None else False


class RecordingReporter:
    """Test double for :class:`ProgressReporter`.

    Accumulates every ``report()`` call in :attr:`calls` so tests
    can assert progress shape ("called report N times", "third
    call's message contained X", etc.).

    Threading: NOT thread-safe — tests typically drive a single
    op through it. Wrap in a lock if used cross-thread.

    Example::

        reporter = RecordingReporter()
        long_op(token=CancellationToken(), reporter=reporter)
        assert len(reporter.calls) == 5
        assert "processed 5/5" in reporter.calls[-1][0]
    """

    __slots__ = ("_token", "calls")

    def __init__(self, token: CancellationProtocol | None = None) -> None:
        self.calls: list[tuple[str, int, int]] = []
        """List of ``(message, current, total)`` tuples — one per
        :meth:`report` call. Tests assert against this directly."""
        self._token = token

    def report(self, message: str, current: int = 0, total: int = 0) -> None:
        """Append the call to :attr:`calls`."""
        self.calls.append((message, current, total))

    def is_cancelled(self) -> bool:
        """Forward to the underlying token; ``False`` if no token."""
        return self._token.is_cancelled() if self._token is not None else False

    def reset(self) -> None:
        """Clear the recorded calls. Useful for sub-tests."""
        self.calls.clear()


__all__ = [
    "CancellationToken",
    "NullReporter",
    "ProgressReporter",
    "RecordingReporter",
]
