"""Reusable ORM mixins — audit-trail, soft-delete, UUID v7 primary keys.

Stability tier: **experimental** (AP-11) — added in 0.4.0 to close
the S-1 + S-3 federation rows
(`mgf-common/docs/inprogress/pre_release_additions.md` §6.3).
Two-consumer signal: PlasmaMapper + inthewords both add the same
shape per-model today.

What ships here
---------------

- :class:`AuditTrailMixin` — drop-in mixin adding ``created_at`` /
  ``updated_at`` / ``deleted_at`` / ``created_by_id`` /
  ``updated_by_id`` columns + event listeners that populate them
  from a thread-local "current user" context.
- :class:`SoftDeleteMixin` — companion mixin that flips
  ``deleted_at`` instead of issuing a real DELETE; provides
  ``.soft_delete()`` + ``.restore()`` helpers. Composes cleanly
  with :class:`AuditTrailMixin` (they share the same column).
- :func:`uuid7` — SHA-aware UUID v7 generator (time-sortable per
  draft-ietf-uuidrev-rfc4122bis). Better than UUID v4 for B-tree
  PK indexes — locality of reference, less page churn.
- :func:`uuid7_pk_column` — convenience: a fully-typed SQLAlchemy
  ``Column[uuid.UUID]`` with ``default=uuid7`` and a
  ``server_default`` placeholder that consumers wire to their
  database's native UUID type via Alembic.

Why these aren't separate features
----------------------------------

The audit trail + UUID v7 PK pattern is **what every modern ORM
model wants by default**. Shipping them as a single mixin module
(rather than three siblings) matches the federation-roadmap
quality gate ("the simplest useful thing the consumer adopts in
one import").

Compose like::

    from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column

    from mgf.sqlalchemy import AuditTrailMixin, SoftDeleteMixin, uuid7

    class Base(DeclarativeBase): ...

    class User(AuditTrailMixin, SoftDeleteMixin, Base):
        __tablename__ = "users"
        id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid7)
        email: Mapped[str] = mapped_column(unique=True)

    # ... then per-request (e.g., in FastAPI middleware):
    from mgf.sqlalchemy import set_audit_user
    with set_audit_user(current_user.id):
        ...  # any commit in this block stamps `created_by_id` / `updated_by_id`

Limits + caveats
----------------

- **Audit-user context is contextvars-based** (per **DP-12** + the
  request-id propagation pattern). Works correctly with async
  + threaded servers. Falls back to ``None`` (column nullable) if
  no user context is set — useful for migrations / batch jobs
  / startup hooks.
- **Soft-delete does NOT install a query filter.** That's a
  per-consumer decision (some consumers want a default exclude;
  others want explicit filtering). The mixin only adds the
  column + the ``.soft_delete()`` / ``.restore()`` methods.
- **UUID v7** is generated in Python — not via Postgres's
  ``gen_random_uuid()``. Reason: portable across Postgres + SQLite
  + MySQL without extension juggling. Sequence quality is good
  (millisecond-precision timestamp + crypto-random tail).

Cross-references
----------------

- **AP-11** — stability tiers.
- **DP-12** — time MUST be UTC at boundaries (audit timestamps
  are tz-aware UTC).
- **DP-13** — deterministic resource release (event-listener
  registration is at-import time; listeners survive engine
  recreation).

Cross-consumer pattern: see
`mgf-common/docs/inprogress/pre_release_additions.md` §6.3
(S-1 + S-3 rows).
"""

from __future__ import annotations

import contextvars
import os
import secrets
import threading
import uuid
from contextlib import contextmanager
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any

from sqlalchemy import DateTime, ForeignKey, Uuid, event
from sqlalchemy.orm import (
    DeclarativeBase,
    Mapped,
    mapped_column,
)

if TYPE_CHECKING:
    from collections.abc import Iterator


__all__ = [
    "AuditTrailMixin",
    "SoftDeleteMixin",
    "current_audit_user",
    "set_audit_user",
    "uuid7",
    "uuid7_pk_column",
]


# ---------------------------------------------------------------------------
# UUID v7 — time-ordered + crypto-random
# ---------------------------------------------------------------------------


def uuid7() -> uuid.UUID:
    """Generate a UUID version 7 (time-ordered).

    Layout per draft-ietf-uuidrev-rfc4122bis §5.7:

    .. code-block:: text

        0                   1                   2                   3
         0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
        +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
        |                           unix_ts_ms                          |
        +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
        |          unix_ts_ms           |  ver  |       rand_a          |
        +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
        |var|                        rand_b                             |
        +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
        |                            rand_b                             |
        +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

    - 48 bits: unix-epoch milliseconds (overflows in year 10889).
    - 4 bits: version (= 7).
    - 12 bits: rand_a (cryptographic random).
    - 2 bits: variant (= 0b10).
    - 62 bits: rand_b (cryptographic random).

    Why over UUID v4: time-prefix gives B-tree locality. Inserts
    cluster at the tail of the index instead of random-walking
    across pages. Measurable insert-throughput win on
    high-write tables (Postgres research: 2-5x depending on
    table size + workload). Reads are unchanged.

    Why not v6: v7 is the modern standard; v6 was an interim
    proposal. Postgres 17+ ships ``uuidv7()`` natively but our
    consumers run a mix of 14 / 15 / 16, and SQLite has none —
    Python-side generation is the portable answer.
    """
    # 48-bit ms timestamp.
    ts_ms = int(datetime.now(UTC).timestamp() * 1000)
    ts_bytes = ts_ms.to_bytes(6, "big")

    # 10 bytes of crypto-random.
    rand_bytes = secrets.token_bytes(10)

    # Assemble the 16-byte UUID.
    raw = bytearray(16)
    raw[0:6] = ts_bytes
    raw[6:16] = rand_bytes

    # Set version (4 bits at byte 6 high nibble) to 7.
    raw[6] = (raw[6] & 0x0F) | 0x70

    # Set variant (2 bits at byte 8 high nibble) to 0b10.
    raw[8] = (raw[8] & 0x3F) | 0x80

    return uuid.UUID(bytes=bytes(raw))


def uuid7_pk_column() -> Mapped[uuid.UUID]:
    """Return a typed SQLAlchemy ``primary_key`` column with
    UUID-v7 default + the dialect-portable ``Uuid`` type.

    Use as::

        class User(Base):
            __tablename__ = "users"
            id: Mapped[uuid.UUID] = uuid7_pk_column()
            ...

    The column carries:

    - ``primary_key=True``
    - ``default=uuid7`` (Python-side default; called on INSERT
      when no value is supplied).
    - Type ``Uuid(as_uuid=True)`` — SQLAlchemy 2's portable
      UUID type. Postgres → ``uuid``; SQLite → ``TEXT`` (UUID
      string); MySQL → ``BINARY(16)``.

    No ``server_default`` is set — the Python default fires on
    INSERT, and consumers wanting database-side defaults (rare:
    parallel-write-from-non-Python systems) can override via
    ``column.kwargs``.
    """
    return mapped_column(
        Uuid(as_uuid=True),
        primary_key=True,
        default=uuid7,
    )


# ---------------------------------------------------------------------------
# Audit-user context — contextvars + thread-local
# ---------------------------------------------------------------------------


_audit_user_var: contextvars.ContextVar[uuid.UUID | None] = contextvars.ContextVar(
    "mgf_sqlalchemy_audit_user",
    default=None,
)


@contextmanager
def set_audit_user(user_id: uuid.UUID | None) -> Iterator[None]:
    """Bind the audit user for the lifetime of the ``with`` block.

    Any commit inside the block stamps :attr:`AuditTrailMixin.created_by_id`
    + :attr:`AuditTrailMixin.updated_by_id` from this user. Nested
    ``with`` blocks shadow; the outer value restores on exit.

    Pass ``None`` to clear (useful for one-off "batch operation —
    no user context" sections inside an otherwise user-scoped
    flow).

    Works correctly with asyncio + threaded servers — the
    contextvar's per-task / per-thread semantics handle the
    propagation.

    Usage in a FastAPI middleware::

        @app.middleware("http")
        async def stamp_audit_user(request, call_next):
            user_id = await resolve_current_user_id(request)
            with set_audit_user(user_id):
                return await call_next(request)
    """
    token = _audit_user_var.set(user_id)
    try:
        yield
    finally:
        _audit_user_var.reset(token)


def current_audit_user() -> uuid.UUID | None:
    """Return the currently-bound audit user, or ``None``.

    Direct read of the contextvar — useful for diagnostic /
    debug paths that want to surface "who's the operator at
    this moment."
    """
    return _audit_user_var.get()


# ---------------------------------------------------------------------------
# AuditTrailMixin — created_at / updated_at / deleted_at + user IDs
# ---------------------------------------------------------------------------


class AuditTrailMixin:
    """Mixin adding audit columns to a SQLAlchemy ORM model.

    Columns added:

    - ``created_at: datetime`` (tz-aware UTC; auto-set on INSERT,
      never updated).
    - ``updated_at: datetime`` (tz-aware UTC; auto-set on INSERT,
      auto-updated on every UPDATE).
    - ``created_by_id: uuid.UUID | None`` (nullable — see
      :func:`set_audit_user`; populated from contextvar at the
      moment of flush).
    - ``updated_by_id: uuid.UUID | None`` (nullable; populated
      from contextvar at every flush — both insert + update).

    Event listeners install at class-creation time via
    ``__init_subclass__``. Each listener is registered ONCE per
    concrete subclass — re-registering on duplicate is a no-op
    (SQLAlchemy's listen() is idempotent at the (target, event,
    fn) level).

    Composes with :class:`SoftDeleteMixin` (sharing
    ``deleted_at`` between the two — only one set of timestamps
    on the model).
    """

    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True),
        nullable=False,
        default=lambda: datetime.now(UTC),
    )
    updated_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True),
        nullable=False,
        default=lambda: datetime.now(UTC),
        onupdate=lambda: datetime.now(UTC),
    )
    created_by_id: Mapped[uuid.UUID | None] = mapped_column(
        Uuid(as_uuid=True),
        nullable=True,
    )
    updated_by_id: Mapped[uuid.UUID | None] = mapped_column(
        Uuid(as_uuid=True),
        nullable=True,
    )

    def __init_subclass__(cls, **kwargs: Any) -> None:
        super().__init_subclass__(**kwargs)
        # Defer event-listener registration until the class is
        # actually mapped (i.e., has __tablename__ + a Base
        # ancestor). Abstract mixin combinations don't get
        # listeners.
        if hasattr(cls, "__tablename__"):
            _install_audit_listeners(cls)


def _install_audit_listeners(cls: type) -> None:
    """Register before_insert + before_update listeners that stamp
    ``created_by_id`` / ``updated_by_id`` from the current
    audit-user contextvar.

    Idempotent — SQLAlchemy's listen() registers (target, event,
    fn) tuples; re-registering the same tuple is a no-op.
    """

    def _stamp_on_insert(_mapper: Any, _conn: Any, target: Any) -> None:
        user_id = _audit_user_var.get()
        if user_id is not None:
            if getattr(target, "created_by_id", None) is None:
                target.created_by_id = user_id
            if getattr(target, "updated_by_id", None) is None:
                target.updated_by_id = user_id

    def _stamp_on_update(_mapper: Any, _conn: Any, target: Any) -> None:
        user_id = _audit_user_var.get()
        if user_id is not None:
            target.updated_by_id = user_id

    event.listen(cls, "before_insert", _stamp_on_insert)
    event.listen(cls, "before_update", _stamp_on_update)


# ---------------------------------------------------------------------------
# SoftDeleteMixin — deleted_at + soft_delete() / restore()
# ---------------------------------------------------------------------------


class SoftDeleteMixin:
    """Mixin adding soft-delete semantics to a SQLAlchemy ORM model.

    Adds:

    - ``deleted_at: datetime | None`` (tz-aware UTC; ``None`` =
      live, populated = soft-deleted).
    - ``deleted_by_id: uuid.UUID | None`` (audit-user stamp at
      delete time; only populated when ``set_audit_user`` is
      active).
    - ``.soft_delete()`` instance method — sets ``deleted_at``
      + ``deleted_by_id`` to now / contextvar.
    - ``.restore()`` instance method — clears them.

    **Does NOT install a query filter.** That's a per-consumer
    decision (some want default-exclude; others want explicit
    filtering). The mixin only adds the column + helpers.

    .. warning::
       Without a filter a plain ``select(Model)`` returns soft-deleted
       rows too — easy to forget, and the leak is silent. Consumers
       wanting default-exclude wire a ``with_loader_criteria`` event so
       EVERY query on the session excludes deleted rows unless
       explicitly opted out::

           from sqlalchemy import event, orm

           @event.listens_for(Session, "do_orm_execute")
           def _exclude_soft_deleted(state: orm.ORMExecuteState) -> None:
               if state.is_select and not state.execution_options.get(
                   "include_deleted", False
               ):
                   state.statement = state.statement.options(
                       orm.with_loader_criteria(
                           SoftDeleteMixin,
                           lambda cls: cls.deleted_at.is_(None),
                           include_aliases=True,
                       )
                   )

       An admin/restore path opts back in via
       ``execution_options={"include_deleted": True}``.

    Composes with :class:`AuditTrailMixin` — they share no
    columns (audit has created/updated; soft-delete has
    deleted). A model can inherit both.
    """

    deleted_at: Mapped[datetime | None] = mapped_column(
        DateTime(timezone=True),
        nullable=True,
        default=None,
    )
    deleted_by_id: Mapped[uuid.UUID | None] = mapped_column(
        Uuid(as_uuid=True),
        nullable=True,
        default=None,
    )

    def soft_delete(self) -> None:
        """Mark this instance soft-deleted.

        Sets ``deleted_at`` to now (UTC). If an audit user is
        bound via :func:`set_audit_user`, sets ``deleted_by_id``
        too.

        Idempotent — calling on an already-soft-deleted instance
        is a no-op (preserves the ORIGINAL ``deleted_at``).
        """
        if self.deleted_at is None:
            self.deleted_at = datetime.now(UTC)
            self.deleted_by_id = _audit_user_var.get()

    def restore(self) -> None:
        """Restore a soft-deleted instance to live state.

        Clears ``deleted_at`` + ``deleted_by_id``. Idempotent
        on already-live instances.
        """
        self.deleted_at = None
        self.deleted_by_id = None


# Suppress unused-import noise in mypy for stub-side imports.
_ = (os, threading, DeclarativeBase, ForeignKey)
