"""Shared ``scoped_operation`` context manager — the one place every
background service opens a correlation scope to do mutating work.

Rule #17 requires every non-chat caller (watcher, heartbeat, daily cycle,
reflection, memory consolidation, cron, webhooks, work queue handlers)
to produce the same accountability shape:

  1. A fresh correlation scope (inherits outer if nested).
  2. A ``trusted_scope`` on the writer so pipeline writes pass the
     runtime guard without threading contract kwargs through every
     call site.
  3. The ``write_vault`` contract action marked authorized via
     ``ContractEnforcer.check()`` (falls back to direct
     ``mark_action_authorized`` on test runners with no contract).
  4. An activity ``<category>_started`` event at entry, a matching
     ``_completed`` or ``_failed`` event at exit.
  5. A decision record at exit with the actor, rationale, and full
     mutation counter snapshot.

Doing this in one helper means new callers can't accidentally skip a
step, and the invariant is locked in by a single test file.

Usage::

    from agntspace.activity.scoped import scoped_operation

    async with scoped_operation(
        writer=self._writer,
        contract=self._contract,
        decisions=self._decisions,
        activity=self._activity,
        actor="ops-supervisor",
        action_type="daily_briefing",
        action_target="journal/agnt/morning-briefing_2026-04-11.md",
        reason="daily_cycle",
        scope_prefix="daily-briefing",
        rationale="Morning briefing for 2026-04-11",
        caller="cron",
    ) as scope:
        briefing = await self._call_agent(task, context, "Morning briefing")
        self._writer.write(scope.target, briefing)

``scope.correlation_id`` gives the causal id so callers can log it too.
``scope.mutations`` is the live counter; callers can read it inside
the block if they need to branch on it.
"""

from __future__ import annotations

import logging
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Any

from agntspace.activity.decisions import (
    ScopeMutations,
    correlation_scope,
    current_correlation_id,
    install_scope_mutations,
    mark_action_authorized,
    new_correlation_id,
    reset_scope_mutations,
)

log = logging.getLogger(__name__)


@dataclass
class ScopedOperationHandle:
    """Live handle returned from ``scoped_operation``.

    Callers inspect ``correlation_id`` for logging and ``mutations``
    for mid-block decisions. ``target`` is the declared write path so
    a caller can do ``writer.write(handle.target, ...)`` without
    duplicating the path.
    """

    correlation_id: str
    mutations: ScopeMutations
    target: str
    actor: str
    action_type: str
    caller: str
    started_iso: str
    # Set by callers that want the exit-time decision record to carry
    # extra payload beyond the default mutation summary.
    extra_details: dict = field(default_factory=dict)
    # Set by callers to override the auto-generated rationale at exit.
    final_rationale: str | None = None


def _authorize_vault_write(contract: Any) -> None:
    """Mark ``write_vault`` authorized in the current scope.

    Goes through ``contract.check()`` when available — that's the
    canonical path that records the audit event and enforces any
    confirm/block rules. Falls back to ``mark_action_authorized``
    directly when there's no contract (tests, minimal installs).
    """
    authorized = False
    if contract is not None and hasattr(contract, "check"):
        try:
            result = contract.check("write_vault")
            authorized = bool(getattr(result, "allowed", False))
        except Exception:
            log.debug("contract.check(write_vault) failed", exc_info=True)
    if not authorized:
        try:
            mark_action_authorized("write_vault")
        except Exception:
            log.debug("mark_action_authorized fallback failed", exc_info=True)


@asynccontextmanager
async def scoped_operation(
    *,
    writer: Any,
    contract: Any | None,
    decisions: Any | None,
    activity: Any | None,
    actor: str,
    action_type: str,
    action_target: str,
    reason: str,
    scope_prefix: str,
    rationale: str = "",
    caller: str = "system",
    skill_context: list[str] | None = None,
) -> AsyncIterator[ScopedOperationHandle]:
    """Open the full observability + governance stack for one operation.

    See module docstring for the responsibilities. All observability
    failures are non-fatal — a broken logger must never break the
    operation itself. The only thing that CAN raise is the caller's
    body; exceptions propagate after a ``failed`` decision is written.
    """

    # 1. Correlation scope: inherit outer if nested, otherwise fresh.
    _existing = current_correlation_id()
    _corr_id = _existing or new_correlation_id(scope_prefix)

    # 2. Mutation counter so callers can read mid-block and the exit
    #    decision has real numbers.
    _mut_token_pair: tuple[ScopeMutations, Any] | None = None

    # 3. Writer trusted_scope — callers inside the block don't need
    #    to pass contract_action/trust_reason on every write.
    _writer_has_trusted_scope = writer is not None and hasattr(writer, "trusted_scope")

    started_iso = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")

    # Emit started event BEFORE the scope opens so the default
    # ActivityLogger (which reads current_correlation_id) picks up the
    # parent's id when nested. Once the scope is entered, _corr_id is
    # visible to anything inside — including the caller's body.
    _safe_activity_log(
        activity,
        category="automation",
        action=f"{action_type}_started",
        summary=f"{action_type} started by {caller}",
        details={
            "actor": actor,
            "target": action_target,
            "caller": caller,
            "correlation_id": _corr_id,
        },
        importance="low",
    )

    with correlation_scope(_corr_id):
        # Authorize write_vault AFTER entering the scope — the
        # authorized-actions set is scope-local and gets reset on entry.
        _authorize_vault_write(contract)

        # Install mutation counter inside the scope so note_vault_write
        # / note_tool_call / note_graph_triple pick it up.
        _mut_counter, _mut_token = install_scope_mutations()
        handle = ScopedOperationHandle(
            correlation_id=_corr_id,
            mutations=_mut_counter,
            target=action_target,
            actor=actor,
            action_type=action_type,
            caller=caller,
            started_iso=started_iso,
        )

        # Apply writer trusted_scope if available. Fall back to a
        # no-op context if the writer was stubbed in tests.
        if _writer_has_trusted_scope:
            _writer_cm = writer.trusted_scope(reason)
        else:
            from contextlib import nullcontext
            _writer_cm = nullcontext()

        try:
            with _writer_cm:
                try:
                    yield handle
                except Exception as exc:
                    # Failure path — record and re-raise so the caller
                    # can still handle it (retry, queue, propagate).
                    _safe_activity_log(
                        activity,
                        category="automation",
                        action=f"{action_type}_failed",
                        summary=f"{action_type} raised {type(exc).__name__}: {str(exc)[:200]}",
                        details={
                            "actor": actor,
                            "target": action_target,
                            "caller": caller,
                            "error": str(exc)[:500],
                            "error_type": type(exc).__name__,
                            "mutations": _mut_counter.summary(),
                            **handle.extra_details,
                        },
                        importance="high",
                    )
                    await _safe_decision_record(
                        decisions,
                        actor=actor,
                        action_type=action_type,
                        action_target=action_target,
                        contract_result="failed",
                        triggered_by=caller,
                        rationale=(
                            handle.final_rationale
                            or f"{action_type} aborted: {type(exc).__name__}: {str(exc)[:120]}"
                        ),
                        details={
                            "actor": actor,
                            "target": action_target,
                            "caller": caller,
                            "reason": reason,
                            "scope_prefix": scope_prefix,
                            "error": str(exc)[:500],
                            "mutations": _mut_counter.summary(),
                            **handle.extra_details,
                        },
                        context_skills=skill_context,
                    )
                    raise
                else:
                    # Success path — record completion.
                    _summary = _mut_counter.summary()
                    _safe_activity_log(
                        activity,
                        category="automation",
                        action=f"{action_type}_completed",
                        summary=f"{action_type} completed by {caller}: {_summary}",
                        details={
                            "actor": actor,
                            "target": action_target,
                            "caller": caller,
                            "mutations": _summary,
                            **handle.extra_details,
                        },
                        importance="medium" if any(_summary.values()) else "low",
                    )
                    await _safe_decision_record(
                        decisions,
                        actor=actor,
                        action_type=action_type,
                        action_target=action_target,
                        contract_action="write_vault",
                        contract_result="allowed",
                        triggered_by=caller,
                        rationale=(
                            handle.final_rationale
                            or rationale
                            or f"{action_type} produced {_summary}"
                        ),
                        details={
                            "actor": actor,
                            "target": action_target,
                            "caller": caller,
                            "reason": reason,
                            "scope_prefix": scope_prefix,
                            "started_at": started_iso,
                            "mutations": _summary,
                            **handle.extra_details,
                        },
                        context_skills=skill_context,
                    )
        finally:
            try:
                reset_scope_mutations(_mut_token)
            except Exception:
                pass


# ── Safe no-throw wrappers around the observability APIs ──
# Every hook is best-effort — a broken ActivityLogger or DecisionLogger
# must never break the underlying operation.


def _safe_activity_log(
    activity: Any | None,
    *,
    category: str,
    action: str,
    summary: str,
    details: dict,
    importance: str,
) -> None:
    if activity is None or not hasattr(activity, "log"):
        return
    try:
        activity.log(category, action, summary, details, importance=importance)
    except Exception:
        log.debug("scoped_operation: activity.log failed", exc_info=True)


async def _safe_decision_record(
    decisions: Any | None,
    **kwargs: Any,
) -> None:
    if decisions is None or not hasattr(decisions, "record"):
        return
    try:
        await decisions.record(**kwargs)
    except Exception:
        log.debug("scoped_operation: decisions.record failed", exc_info=True)
