"""Audit event subscriber (SEC7 EventBus wiring).

Bridges the :class:`EventBus` and :class:`AuditService` by subscribing to
all security-relevant domain event types and persisting them via
``AuditService.record()``.  Follows the Single Responsibility Principle:
the subscriber only translates events into audit entries; it does not own
the audit storage.

Sensitive data in event ``details`` is redacted via
:func:`cleveragents.shared.redaction.redact_dict` before persistence.

Based on:
    - docs/specification.md §Audit Logging (SEC7)
    - Forgejo issue #581
"""

from __future__ import annotations

import structlog

from cleveragents.application.services.audit_service import AuditService
from cleveragents.infrastructure.events.models import DomainEvent
from cleveragents.infrastructure.events.protocol import EventBus
from cleveragents.infrastructure.events.types import EventType
from cleveragents.shared.redaction import redact_dict, redact_value

_logger = structlog.get_logger(__name__)

# Mapping from :class:`EventType` enum members to the underscore-delimited
# ``event_type`` strings accepted by ``AuditService.record()``.
#
# Design note (SPEC-1 / #581): Only security-relevant event types are
# persisted to the audit log.  Non-security events (e.g. PLAN_CREATED,
# ACTOR_INVOKED) flow through the EventBus but are intentionally *not*
# recorded here, keeping the audit table focused on compliance-critical
# operations per docs/specification.md §Audit Logging.
SECURITY_EVENT_MAP: dict[EventType, str] = {
    EventType.PLAN_APPLIED: "plan_applied",
    EventType.PLAN_CANCELLED: "plan_cancelled",
    # NOTE: RESOURCE_MODIFIED has no producing service yet — the tool
    # execution framework does not emit this event.  The subscriber handler
    # is intentionally registered so that audit entries are automatically
    # created once tool-write event emission is implemented.
    EventType.RESOURCE_MODIFIED: "resource_modified",
    EventType.CORRECTION_APPLIED: "correction_applied",
    EventType.CONFIG_CHANGED: "config_changed",
    EventType.ENTITY_DELETED: "entity_deleted",
    EventType.SESSION_CREATED: "session_created",
    # NOTE: AUTH_SUCCESS and AUTH_FAILURE have no producing service yet —
    # server-mode authentication is not implemented.  The subscriber handlers
    # are registered so that audit entries are automatically created once
    # server auth emits these events.
    EventType.AUTH_SUCCESS: "auth_success",
    EventType.AUTH_FAILURE: "auth_failure",
}

#: The set of :class:`EventType` values that are considered security-relevant.
SECURITY_EVENT_TYPES: frozenset[EventType] = frozenset(SECURITY_EVENT_MAP)


class AuditEventSubscriber:
    """Subscribes to security-relevant domain events and persists them via AuditService.

    On construction the subscriber registers a handler for each event type
    in :data:`SECURITY_EVENT_MAP` with the provided *event_bus*.  When an
    event is received the handler:

    1. Extracts ``plan_id``, ``actor_name``, ``project_name``, and
       ``details`` from the :class:`DomainEvent`.
    2. Applies :func:`~cleveragents.shared.redaction.redact_dict` to mask
       secrets in the details dict.
    3. Calls ``audit_service.record()`` with the mapped event type string.

    Args:
        audit_service: The :class:`AuditService` used for persistence.
        event_bus: The :class:`EventBus` to subscribe to.
    """

    def __init__(self, audit_service: AuditService, event_bus: EventBus) -> None:
        self._audit_service = audit_service
        self._event_bus = event_bus
        for event_type in SECURITY_EVENT_TYPES:
            event_bus.subscribe(event_type, self._handle_event)

    def _handle_event(self, event: DomainEvent) -> None:
        """Redact sensitive data and persist an audit log entry.

        Enriches the recorded details with ``session_id`` and
        ``correlation_id`` from the :class:`DomainEvent` when present,
        ensuring full traceability in the audit log.

        Failures are logged but never propagated — audit recording must
        not break the event pipeline.
        """
        audit_event_type = SECURITY_EVENT_MAP.get(event.event_type)
        if audit_event_type is None:
            return

        try:
            raw_details = dict(event.details)

            # SEC-1: Prefer the first-class ``user_identity`` field added
            # in #715.  Fall back to extracting it from the ``details``
            # dict for backward compatibility with callers that still
            # embed the identity there (pre-#715 convention).
            user_identity: str | None = event.user_identity
            if user_identity is None:
                user_identity = raw_details.pop("user_identity", None)
            else:
                # Remove from details to avoid duplication in the
                # persisted payload — the value lives in the dedicated
                # ``user_identity`` audit column.
                raw_details.pop("user_identity", None)

            # N1: Audit persistence must never respect the display-layer
            # ``show_secrets`` flag — force redaction regardless of the
            # global toggle so that secrets are always masked in the
            # persistent audit log.
            redacted_details = redact_dict(raw_details, show_secrets=False)

            # N11: Inject session_id and correlation_id *after* redaction
            # to avoid false-positive pattern matching on ULIDs.
            if event.session_id is not None:
                redacted_details["session_id"] = event.session_id
            # correlation_id always has a value (ULID default_factory),
            # so enrich unconditionally.
            redacted_details["correlation_id"] = event.correlation_id

            # Apply redaction to user_identity to prevent accidental secret
            # leakage (e.g. tokens embedded in identity strings).
            redacted_identity: str | None = (
                redact_value(user_identity) if user_identity is not None else None
            )

            self._audit_service.record(
                event_type=audit_event_type,
                plan_id=event.plan_id,
                project_name=event.project_name,
                actor_name=event.actor_name,
                user_identity=redacted_identity,
                details=redacted_details,
            )
        except Exception as exc:
            _logger.warning(
                "audit_event_recording_failed",
                event_type=audit_event_type,
                plan_id=event.plan_id,
                error_type=type(exc).__name__,
                error_message=redact_value(str(exc)),
            )


__all__ = [
    "SECURITY_EVENT_MAP",
    "SECURITY_EVENT_TYPES",
    "AuditEventSubscriber",
]
