"""Best-effort observer helpers — the canonical "never raises" wrapper.

Several VManager subsystems run user-supplied or external code in a loop
where a single failure must NOT take down the rest of the iteration:

* the scheduler tick loop (``scheduler/runner.py::ScheduleRunner.fire_one``),
* the alert evaluator (``monitor/alerts.py::AlertEvaluator.evaluate_once``),
* the drift-audit notifier dispatch (``security/isolation/drift.py``),
* the host-doctor apply loop (``environment/doctor/orchestrator.py::apply``).

Before this module landed, every site rolled its own
``try / except Exception / log.warning(..., exc_info=True) / continue``
block. The duplicated shape made rule **TS-09** ("every best-effort
observer MUST have a test that injects a raising callable and asserts
the loop continues") hard to enforce mechanically: each site needed its
own pin, and the log shape varied subtly across sites.

This module exposes a single :func:`best_effort` context manager that
embodies the contract once. Sites that previously hand-rolled the
pattern compose it as::

    with best_effort(
        log=LOG,
        message="alert %s: notifier error; continuing",
        message_args=(rule.name,),
        extra={"rule_name": rule.name},
    ) as outcome:
        self.notifier.notify(firing)

    if not outcome.ok:
        # optional per-site bookkeeping (e.g. failed.append(...))
        ...

The contract:

* **`Exception` is caught and swallowed.** The block emits a WARNING with
  ``exc_info=True`` (full traceback) and stamps ``outcome.ok = False``.
  Callers read ``outcome.ok`` to decide on bool-return / per-site lists /
  ``continue`` semantics.
* **`BaseException` is NOT caught.** ``KeyboardInterrupt`` and
  ``SystemExit`` propagate normally — operator-driven shutdown is never
  an "observer" failure.
* **Logging shape is uniform.** Sites pass a ``message`` (printf-style,
  with optional ``message_args`` for ``%s`` expansion) and an ``extra``
  mapping. The standards require ``exc_info=True`` + structured fields
  (rule **LG-05**) and this helper enforces both at once.

Sites that need finer-grained per-stage handling (e.g.
``hooks/executor.py::fire_one`` with separate messages for argv-prep
vs subprocess-run vs non-zero-exit) intentionally do NOT use this
helper — flattening would lose signal. Those remain hand-rolled and
keep their per-site test pins.
"""

from __future__ import annotations

from contextlib import contextmanager
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
    import logging
    from collections.abc import Iterator, Mapping

__all__ = ["BestEffortOutcome", "best_effort"]


@dataclass
class BestEffortOutcome:
    """Mutable outcome stamped by :func:`best_effort`.

    The block sets :attr:`failed` to ``True`` on any caught
    :class:`Exception`. :attr:`ok` is the negation, exposed as a
    convenience because every call site reads it that way.

    The trailing ``error`` slot carries the caught exception (if any)
    so caller-side bookkeeping that wants the message — e.g. the
    host-doctor's ``failed: list[tuple[id, str]]`` — does not need a
    second ``except`` block.
    """

    failed: bool = False
    error: BaseException | None = field(default=None, repr=False)

    @property
    def ok(self) -> bool:
        """Convenience: ``not failed``. Returns True iff the block ran cleanly."""
        return not self.failed


@contextmanager
def best_effort(
    *,
    log: logging.Logger,
    message: str,
    message_args: tuple[Any, ...] = (),
    extra: Mapping[str, Any] | None = None,
) -> Iterator[BestEffortOutcome]:
    """Run a block, swallowing :class:`Exception` and logging at WARNING.

    Parameters
    ----------
    log
        Logger to write the WARNING line to.
    message
        printf-style message string (e.g. ``"alert %s: notifier error"``).
        Standards rule **LG-08** prefers lazy %-formatting over f-strings
        for log calls.
    message_args
        Positional arguments substituted into ``message`` (passed straight
        to :meth:`logging.Logger.warning`).
    extra
        Optional structured fields per rule **LG-09**. Wrapped in a fresh
        ``dict`` before logging so caller-supplied mappings are not
        mutated by the logging machinery.

    Yields
    ------
    BestEffortOutcome
        The outcome handle. Read ``outcome.ok`` after the ``with`` block
        for a True/False result. ``outcome.error`` carries the caught
        exception (or ``None`` on success).
    """
    outcome = BestEffortOutcome()
    try:
        yield outcome
    except Exception as exc:
        outcome.failed = True
        outcome.error = exc
        log.warning(
            message,
            *message_args,
            exc_info=True,
            extra=dict(extra) if extra else None,
        )
