"""Append-only audit log (spec admin-auth, csv-export FR-5.7).

Every CSV export is personal data leaving the system, so it is logged with who, what
filter, and how many rows. Rejected sign-ins are logged too — a burst of them against
unknown emails is the signal that someone is probing the allowlist.
"""

import uuid
from typing import Any

from sqlalchemy.ext.asyncio import AsyncSession

from app.models import AuditLog


class Actions:
    """Action names, centralised so the log stays greppable."""

    SIGNIN_SUCCESS = "signin.success"
    SIGNIN_REJECTED = "signin.rejected"
    SIGNOUT = "signout"
    GUEST_CREATE = "guest.create"
    GUEST_UPDATE = "guest.update"
    GUEST_DELETE = "guest.delete"
    GUEST_IMPORT = "guest.import"
    #: Guest-initiated, so it carries no admin_user_id — the actor is the token holder.
    GUEST_UNSUBSCRIBE = "guest.unsubscribe"
    RSVP_OVERRIDE = "rsvp.override"
    EXPORT_CSV = "export.csv"
    MESSAGE_SEND = "message.send"
    MESSAGE_RETRY = "message.retry"
    #: An admin took an invitation link to deliver by hand (design D8). The one path by
    #: which a bearer token leaves the system with no delivery record attached to it.
    INVITATION_LINK_COPY = "invitation.link_copy"
    ADMIN_CREATE = "admin.create"
    ADMIN_UPDATE = "admin.update"
    ADMIN_DEACTIVATE = "admin.deactivate"
    #: A first Google sign-in for an unknown verified address created a pending account.
    #: Not a rejection and not a success — the roster's inbox (design D6).
    ADMIN_SELF_REGISTERED = "admin.self_registered"
    ADMIN_ACTIVATE = "admin.activate"
    ADMIN_REJECT = "admin.reject"
    ADMIN_ROLE_CHANGE = "admin.role_change"
    #: Records **that** a temporary password was issued, by whom and for whom. The value
    #: never appears here or in any log line (spec admin-user-management).
    ADMIN_TEMP_PASSWORD = "admin.temp_password"
    #: Self-service, so the actor and the subject are the same account.
    ADMIN_PASSWORD_CHANGE = "admin.password_change"
    EVENT_TRANSFER = "event.transfer"
    WEDDING_UPDATE = "wedding.update"
    CARD_UPLOAD = "card.upload"
    CARD_PUBLISH = "card.publish"
    CARD_UNPUBLISH = "card.unpublish"
    CARD_DELETE = "card.delete"
    EVENT_CREATE = "event.create"
    EVENT_UPDATE = "event.update"
    EVENT_DELETE = "event.delete"
    REMINDER_UPDATE = "reminder.update"


def record(
    session: AsyncSession,
    *,
    action: str,
    admin_user_id: uuid.UUID | None = None,
    actor_email: str | None = None,
    entity_type: str | None = None,
    entity_id: uuid.UUID | None = None,
    before: dict[str, Any] | None = None,
    after: dict[str, Any] | None = None,
    ip: str | None = None,
) -> AuditLog:
    """Queue an audit row on the caller's session.

    Deliberately not committing: the entry lands in the same transaction as the change it
    describes, so an action can never be logged as having happened when it was rolled back.
    """
    entry = AuditLog(
        admin_user_id=admin_user_id,
        actor_email=actor_email,
        action=action,
        entity_type=entity_type,
        entity_id=entity_id,
        before=before,
        after=after,
        ip=ip,
    )
    session.add(entry)
    return entry


def diff(before: dict[str, Any], after: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
    """Reduce a change to only the fields that actually moved.

    Storing whole records would bury the one edited field and duplicate PII across every
    row of the log.
    """
    changed = {k for k in set(before) | set(after) if before.get(k) != after.get(k)}
    return ({k: before.get(k) for k in changed}, {k: after.get(k) for k in changed})
