"""Unit tests for ``mgf.sqlalchemy._mixins`` — audit-trail + soft-delete
+ UUID v7 (v0.4.0)."""

from __future__ import annotations

import time
import uuid
from datetime import UTC, datetime, timedelta

import pytest
from sqlalchemy import create_engine, select
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column

from mgf.sqlalchemy import (
    AuditTrailMixin,
    SoftDeleteMixin,
    current_audit_user,
    set_audit_user,
    uuid7,
    uuid7_pk_column,
)

# ---------------------------------------------------------------------------
# UUID v7
# ---------------------------------------------------------------------------


class TestUuid7:
    def test_returns_uuid_instance(self) -> None:
        u = uuid7()
        assert isinstance(u, uuid.UUID)

    def test_version_is_7(self) -> None:
        u = uuid7()
        # Version is the high nibble of byte 6.
        assert u.version == 7

    def test_variant_is_rfc4122(self) -> None:
        u = uuid7()
        # Variant bits are top 2 of byte 8: 0b10 = "RFC 4122".
        assert u.variant == uuid.RFC_4122

    def test_time_ordered(self) -> None:
        # Generated-later UUID has bytes lexicographically > generated-earlier.
        # (At ms resolution; same-ms UUIDs only ordered within their random tail.)
        before = uuid7()
        time.sleep(0.005)  # 5ms
        after = uuid7()
        assert after.bytes > before.bytes

    def test_uniqueness_basic(self) -> None:
        # Generate a batch; expect no collisions.
        ids = {uuid7() for _ in range(1000)}
        assert len(ids) == 1000


# ---------------------------------------------------------------------------
# uuid7_pk_column — used in a real model
# ---------------------------------------------------------------------------


class TestUuid7PkColumn:
    def test_column_used_in_model(self) -> None:
        class Base(DeclarativeBase): ...

        class Thing(Base):
            __tablename__ = "things_for_uuid7_test"
            id: Mapped[uuid.UUID] = uuid7_pk_column()
            name: Mapped[str] = mapped_column()

        # Sanity: the column is configured as primary_key + default.
        col = Thing.__table__.c.id
        assert col.primary_key
        # SQLAlchemy wraps the callable; verify the default produces
        # a UUIDv7 (calling it should yield a v7 UUID).
        assert col.default is not None
        produced = col.default.arg(None)  # SQLAlchemy passes the context; we ignore it
        assert isinstance(produced, uuid.UUID)
        assert produced.version == 7

    def test_inserts_get_unique_ids(self, tmp_path) -> None:
        # End-to-end: use SQLite to verify the default actually fires.
        class Base(DeclarativeBase): ...

        class Thing(Base):
            __tablename__ = "things_e2e"
            id: Mapped[uuid.UUID] = uuid7_pk_column()
            name: Mapped[str] = mapped_column()

        engine = create_engine(f"sqlite:///{tmp_path / 'test.db'}")
        try:
            Base.metadata.create_all(engine)

            with Session(engine) as session:
                a, b, c = Thing(name="a"), Thing(name="b"), Thing(name="c")
                session.add_all([a, b, c])
                session.flush()
                ids = {a.id, b.id, c.id}
                assert len(ids) == 3
                for tid in ids:
                    assert isinstance(tid, uuid.UUID)
                    assert tid.version == 7
        finally:
            engine.dispose()


# ---------------------------------------------------------------------------
# AuditTrailMixin — columns + listener stamping
# ---------------------------------------------------------------------------


class _AuditBase(DeclarativeBase):
    pass


class _AuditModel(AuditTrailMixin, _AuditBase):
    __tablename__ = "audit_model"
    id: Mapped[uuid.UUID] = uuid7_pk_column()
    name: Mapped[str] = mapped_column()


@pytest.fixture
def audit_engine(tmp_path):
    engine = create_engine(f"sqlite:///{tmp_path / 'audit.db'}")
    _AuditBase.metadata.create_all(engine)
    try:
        yield engine
    finally:
        # DP-13: dispose explicitly. SQLAlchemy Engines hold pooled
        # connections; without dispose() they leak to teardown, and
        # under Python 3.13 + mgf-common 0.39's hardened excepthook
        # the unraisable ResourceWarning escalates to a test failure.
        engine.dispose()


class TestAuditTrailMixin:
    def test_columns_present(self) -> None:
        cols = _AuditModel.__table__.c
        assert "created_at" in cols
        assert "updated_at" in cols
        assert "created_by_id" in cols
        assert "updated_by_id" in cols

    def test_created_at_set_on_insert(self, audit_engine) -> None:
        before = datetime.now(UTC)
        with Session(audit_engine) as session:
            obj = _AuditModel(name="x")
            session.add(obj)
            session.flush()
            session.refresh(obj)
        after = datetime.now(UTC)

        # SQLite drops tz info on round-trip — coerce.
        created = obj.created_at
        if created.tzinfo is None:
            created = created.replace(tzinfo=UTC)
        assert before - timedelta(seconds=1) <= created <= after + timedelta(seconds=1)

    def test_updated_at_changes_on_update(self, audit_engine) -> None:
        with Session(audit_engine) as session:
            obj = _AuditModel(name="x")
            session.add(obj)
            session.flush()
            session.refresh(obj)
            first_updated = obj.updated_at

            time.sleep(0.01)
            obj.name = "y"
            session.flush()
            session.refresh(obj)
            assert obj.updated_at != first_updated

    def test_created_by_id_stamped_from_contextvar(self, audit_engine) -> None:
        user_id = uuid7()
        with Session(audit_engine) as session, set_audit_user(user_id):
            obj = _AuditModel(name="x")
            session.add(obj)
            session.flush()
            session.refresh(obj)
            assert obj.created_by_id == user_id
            assert obj.updated_by_id == user_id

    def test_updated_by_id_stamped_on_update(self, audit_engine) -> None:
        creator = uuid7()
        updater = uuid7()
        with Session(audit_engine) as session:
            with set_audit_user(creator):
                obj = _AuditModel(name="x")
                session.add(obj)
                session.flush()
            session.refresh(obj)
            assert obj.updated_by_id == creator

            with set_audit_user(updater):
                obj.name = "y"
                session.flush()
            session.refresh(obj)
            assert obj.updated_by_id == updater
            # created_by_id unchanged.
            assert obj.created_by_id == creator

    def test_no_audit_user_leaves_columns_null(self, audit_engine) -> None:
        # No `with set_audit_user(...)`; columns stay NULL.
        with Session(audit_engine) as session:
            obj = _AuditModel(name="x")
            session.add(obj)
            session.flush()
            session.refresh(obj)
            assert obj.created_by_id is None
            assert obj.updated_by_id is None


# ---------------------------------------------------------------------------
# set_audit_user — contextmanager semantics
# ---------------------------------------------------------------------------


class TestSetAuditUser:
    def test_default_is_none(self) -> None:
        # Outside any `with` block, no audit user.
        assert current_audit_user() is None

    def test_bind_and_unbind(self) -> None:
        user = uuid7()
        with set_audit_user(user):
            assert current_audit_user() == user
        assert current_audit_user() is None

    def test_nested_bindings(self) -> None:
        outer = uuid7()
        inner = uuid7()
        with set_audit_user(outer):
            assert current_audit_user() == outer
            with set_audit_user(inner):
                assert current_audit_user() == inner
            assert current_audit_user() == outer

    def test_explicit_none_clears(self) -> None:
        outer = uuid7()
        with set_audit_user(outer), set_audit_user(None):
            assert current_audit_user() is None


# ---------------------------------------------------------------------------
# SoftDeleteMixin
# ---------------------------------------------------------------------------


class _SoftBase(DeclarativeBase):
    pass


class _SoftModel(AuditTrailMixin, SoftDeleteMixin, _SoftBase):
    __tablename__ = "soft_model"
    id: Mapped[uuid.UUID] = uuid7_pk_column()
    name: Mapped[str] = mapped_column()


@pytest.fixture
def soft_engine(tmp_path):
    engine = create_engine(f"sqlite:///{tmp_path / 'soft.db'}")
    _SoftBase.metadata.create_all(engine)
    try:
        yield engine
    finally:
        # DP-13 — see audit_engine for rationale.
        engine.dispose()


class TestSoftDeleteMixin:
    def test_columns_present(self) -> None:
        cols = _SoftModel.__table__.c
        assert "deleted_at" in cols
        assert "deleted_by_id" in cols

    def test_initial_state_is_live(self, soft_engine) -> None:
        with Session(soft_engine) as session:
            obj = _SoftModel(name="x")
            session.add(obj)
            session.flush()
            session.refresh(obj)
            assert obj.deleted_at is None
            assert obj.deleted_by_id is None

    def test_soft_delete_sets_columns(self, soft_engine) -> None:
        user = uuid7()
        with Session(soft_engine) as session, set_audit_user(user):
            obj = _SoftModel(name="x")
            session.add(obj)
            session.flush()
            obj.soft_delete()
            session.flush()
            session.refresh(obj)
            assert obj.deleted_at is not None
            assert obj.deleted_by_id == user

    def test_soft_delete_idempotent(self, soft_engine) -> None:
        with Session(soft_engine) as session:
            obj = _SoftModel(name="x")
            session.add(obj)
            session.flush()
            obj.soft_delete()
            first_deleted_at = obj.deleted_at
            time.sleep(0.01)
            obj.soft_delete()  # second call — no-op
            assert obj.deleted_at == first_deleted_at

    def test_restore(self, soft_engine) -> None:
        with Session(soft_engine) as session, set_audit_user(uuid7()):
            obj = _SoftModel(name="x")
            session.add(obj)
            session.flush()
            obj.soft_delete()
            session.flush()
            assert obj.deleted_at is not None

            obj.restore()
            session.flush()
            session.refresh(obj)
            assert obj.deleted_at is None
            assert obj.deleted_by_id is None

    def test_no_default_query_filter(self, soft_engine) -> None:
        # The mixin does NOT install a default filter; soft-deleted
        # rows ARE returned by plain queries. This is documented
        # behaviour — consumers wire their own filter if they want
        # exclude-by-default.
        with Session(soft_engine) as session:
            live = _SoftModel(name="live")
            dead = _SoftModel(name="dead")
            session.add_all([live, dead])
            session.flush()
            dead.soft_delete()
            session.flush()

            all_rows = session.scalars(select(_SoftModel)).all()
            assert len(all_rows) == 2
