"""Direct async endpoint tests and endpoint utilities."""

from __future__ import annotations

from types import SimpleNamespace
from unittest.mock import AsyncMock

import pytest
from fastapi import APIRouter, HTTPException
from fastapi.routing import APIRoute
from pydantic import BaseModel, create_model
from sqlalchemy.exc import IntegrityError
from sqlmodel import Field, Relationship, SQLModel
from sqlmodel.ext.asyncio.session import AsyncSession
from tests.conftest import Book, SessionFactory

from auen import (
    CrudRouterBuilder,
    FilterConfig,
    FilterFieldConfig,
    HooksConfig,
    Operation,
    PaginationConfig,
    derive_schemas,
)
from auen._async_endpoints import (
    _build_nested_create_schema,
    _build_nested_update_schema,
    _resolve_relation_fk_field,
)
from auen._endpoints import RouterContext, _build_filter_model
from auen.config import NestedRelationConfig, NestedWriteConfig
from auen.exceptions import (
    ConfigurationError,
    ConflictError,
    ForbiddenError,
    NotFoundError,
)
from auen.types import SelectQuery, User


class EndpointCovAuthor(SQLModel, table=True):
    __tablename__ = "endpoint_cov_author"
    id: int | None = Field(default=None, primary_key=True)
    name: str
    bio: str | None = None


class EndpointCovBook(SQLModel, table=True):
    __tablename__ = "endpoint_cov_book"
    id: int | None = Field(default=None, primary_key=True)
    title: str
    isbn: str
    author_id: int | None = Field(default=None, foreign_key="endpoint_cov_author.id")
    author: EndpointCovAuthor = Relationship()


class EndpointCovOther(SQLModel, table=True):
    __tablename__ = "endpoint_cov_other"
    id: int | None = Field(default=None, primary_key=True)
    label: str


class EndpointCovAllowAll:
    def can_create(self, user: User, obj_in: BaseModel) -> bool:
        return True

    def can_read(self, user: User, db_obj: SQLModel) -> bool:
        return True

    def can_update(self, user: User, db_obj: SQLModel, obj_in: BaseModel) -> bool:
        return True

    def can_delete(self, user: User, db_obj: SQLModel) -> bool:
        return True

    def filter_list_query(
        self, user: User, query: SelectQuery[SQLModel]
    ) -> SelectQuery[SQLModel]:
        return query


class EndpointCovDenyUpdate(EndpointCovAllowAll):
    def can_update(self, user: User, db_obj: SQLModel, obj_in: BaseModel) -> bool:
        return False


class EndpointCovDenyCreate(EndpointCovAllowAll):
    def can_create(self, user: User, obj_in: BaseModel) -> bool:
        return False


class EndpointCovRelationPolicyDenyCreate(EndpointCovAllowAll):
    def can_create(self, user: User, obj_in: BaseModel) -> bool:
        return False


class EndpointCovRelationPolicyDenyUpdate(EndpointCovAllowAll):
    def can_update(self, user: User, db_obj: SQLModel, obj_in: BaseModel) -> bool:
        return False


def _get_route(router: APIRouter, path: str, method: str) -> APIRoute:
    prefix = router.prefix or ""
    full_path = f"{prefix}{path}"
    for route in router.routes:
        if (
            isinstance(route, APIRoute)
            and route.path == full_path
            and method in route.methods
        ):
            return route
    msg = f"Route {method} {full_path} not found"
    raise AssertionError(msg)


async def test_direct_endpoints_roundtrip(get_session: SessionFactory) -> None:
    router = (
        CrudRouterBuilder.for_model(Book, get_session)
        .with_pagination(PaginationConfig(include_total=True))
        .with_operations(
            {
                Operation.CREATE,
                Operation.READ,
                Operation.UPDATE,
                Operation.DELETE,
                Operation.LIST,
                Operation.BULK_CREATE,
                Operation.BULK_UPDATE,
                Operation.BULK_DELETE,
            }
        )
        .build()
    )

    schemas = derive_schemas(Book)
    create_ep = _get_route(router, "/", "POST").endpoint
    list_ep = _get_route(router, "/", "GET").endpoint
    read_ep = _get_route(router, "/{id}", "GET").endpoint
    update_ep = _get_route(router, "/{id}", "PATCH").endpoint
    delete_ep = _get_route(router, "/{id}", "DELETE").endpoint
    bulk_create_ep = _get_route(router, "/bulk", "POST").endpoint
    bulk_update_ep = _get_route(router, "/bulk", "PATCH").endpoint
    bulk_delete_ep = _get_route(router, "/bulk", "DELETE").endpoint

    async for session in get_session():
        created = await create_ep(
            obj_in=schemas.create(title="A", isbn="1"),
            session=session,
            user=None,
        )
        created_id = created.id
        await create_ep(
            obj_in=schemas.create(title="B", isbn="2"),
            session=session,
            user=None,
        )

        payload = await list_ep(
            session=session,
            user=None,
            offset=0,
            limit=10,
            sort=None,
            filter_params=None,
        )
        assert payload["total"] >= 2

        found = await read_ep(
            id=created_id,
            session=session,
            user=None,
        )
        assert found.id == created_id

        updated = await update_ep(
            id=created_id,
            obj_in=schemas.update(pages=123),
            session=session,
            user=None,
        )
        assert updated.pages == 123

        bulk_created = await bulk_create_ep(
            objects_in=[
                schemas.create(title="C", isbn="3"),
                schemas.create(title="D", isbn="4"),
            ],
            session=session,
            user=None,
        )
        bulk_ids = [obj.id for obj in bulk_created]
        # Send back ReadSchema-shaped objects
        bulk_update = await bulk_update_ep(
            items_in=[
                schemas.read(id=bulk_ids[0], title="C2", isbn="3", pages=None),
            ],
            session=session,
            user=None,
        )
        assert bulk_update[0].title == "C2"

        # Bulk delete uses identifier schema
        from pydantic import create_model as _cm

        BookIdentifier = _cm("BookIdentifier", __base__=BaseModel, id=(int, ...))
        await bulk_delete_ep(
            items_in=[BookIdentifier(id=bulk_ids[1])],
            session=session,
            user=None,
        )

        await delete_ep(id=created_id, session=session, user=None)


async def test_create_forbidden(get_session: SessionFactory) -> None:
    class DenyCreate:
        def can_create(self, user: User, obj_in: BaseModel) -> bool:
            return False

        def can_read(self, user: User, db_obj: SQLModel) -> bool:
            return True

        def can_update(self, user: User, db_obj: SQLModel, obj_in: BaseModel) -> bool:
            return True

        def can_delete(self, user: User, db_obj: SQLModel) -> bool:
            return True

        def filter_list_query(
            self, user: User, query: SelectQuery[SQLModel]
        ) -> SelectQuery[SQLModel]:
            return query

    schemas = derive_schemas(Book)
    router = (
        CrudRouterBuilder.for_model(Book, get_session)
        .with_policy(DenyCreate())
        .with_operations({Operation.CREATE})
        .build()
    )
    create_ep = _get_route(router, "/", "POST").endpoint

    async for session in get_session():
        with pytest.raises(ForbiddenError):
            await create_ep(
                obj_in=schemas.create(title="Nope", isbn="X"),
                session=session,
                user=None,
            )


async def test_create_after_hook(get_session: SessionFactory) -> None:
    schemas = derive_schemas(Book)
    called = []

    async def after_create(session: AsyncSession, obj: Book, user: User) -> None:
        called.append(obj.title)

    router = (
        CrudRouterBuilder.for_model(Book, get_session)
        .with_hooks(HooksConfig(after_create=after_create))
        .with_operations({Operation.CREATE})
        .build()
    )
    create_ep = _get_route(router, "/", "POST").endpoint

    async for session in get_session():
        await create_ep(
            obj_in=schemas.create(title="Hooked", isbn="H"),
            session=session,
            user=None,
        )

    assert called == ["Hooked"]


def test_build_filter_model_skips_missing_fields() -> None:
    cfg = FilterConfig(fields={"missing": FilterFieldConfig()})
    model = _build_filter_model(Book, cfg)
    assert model.model_fields == {}


async def test_list_without_total_returns_items(get_session: SessionFactory) -> None:
    schemas = derive_schemas(Book)
    router = (
        CrudRouterBuilder.for_model(Book, get_session)
        .with_pagination(PaginationConfig(include_total=False))
        .with_operations({Operation.CREATE, Operation.LIST})
        .build()
    )
    create_ep = _get_route(router, "/", "POST").endpoint
    list_ep = _get_route(router, "/", "GET").endpoint
    async for session in get_session():
        await create_ep(
            obj_in=schemas.create(title="NoTotal", isbn="NT"),
            session=session,
            user=None,
        )
        payload = await list_ep(
            session=session,
            user=None,
            offset=0,
            limit=10,
            sort=None,
            filter_params=None,
        )
        assert isinstance(payload, list)


async def test_read_not_found_and_forbidden(get_session: SessionFactory) -> None:
    class DenyRead:
        def can_create(self, user: User, obj_in: BaseModel) -> bool:
            return True

        def can_read(self, user: User, db_obj: SQLModel) -> bool:
            return False

        def can_update(self, user: User, db_obj: SQLModel, obj_in: BaseModel) -> bool:
            return True

        def can_delete(self, user: User, db_obj: SQLModel) -> bool:
            return True

        def filter_list_query(
            self, user: User, query: SelectQuery[SQLModel]
        ) -> SelectQuery[SQLModel]:
            return query

    schemas = derive_schemas(Book)
    router = (
        CrudRouterBuilder.for_model(Book, get_session)
        .with_policy(DenyRead())
        .with_operations({Operation.CREATE, Operation.READ})
        .build()
    )
    create_ep = _get_route(router, "/", "POST").endpoint
    read_ep = _get_route(router, "/{id}", "GET").endpoint
    async for session in get_session():
        created = await create_ep(
            obj_in=schemas.create(title="Denied", isbn="DR"),
            session=session,
            user=None,
        )
        with pytest.raises(ForbiddenError):
            await read_ep(id=created.id, session=session, user=None)
        with pytest.raises(NotFoundError):
            await read_ep(id=9999, session=session, user=None)


async def test_update_branches(get_session: SessionFactory) -> None:
    class DenyUpdate:
        def can_create(self, user: User, obj_in: BaseModel) -> bool:
            return True

        def can_read(self, user: User, db_obj: SQLModel) -> bool:
            return True

        def can_update(self, user: User, db_obj: SQLModel, obj_in: BaseModel) -> bool:
            return False

        def can_delete(self, user: User, db_obj: SQLModel) -> bool:
            return True

        def filter_list_query(
            self, user: User, query: SelectQuery[SQLModel]
        ) -> SelectQuery[SQLModel]:
            return query

    schemas = derive_schemas(Book)
    called = []

    async def before_update(session: AsyncSession, obj: SQLModel, user: User) -> None:
        called.append("before")

    async def after_update(session: AsyncSession, obj: SQLModel, user: User) -> None:
        called.append("after")

    router = (
        CrudRouterBuilder.for_model(Book, get_session)
        .with_hooks(HooksConfig(before_update=before_update, after_update=after_update))
        .with_operations({Operation.CREATE, Operation.UPDATE})
        .build()
    )
    create_ep = _get_route(router, "/", "POST").endpoint
    update_ep = _get_route(router, "/{id}", "PATCH").endpoint
    async for session in get_session():
        created = await create_ep(
            obj_in=schemas.create(title="Up", isbn="U"),
            session=session,
            user=None,
        )
        result = await update_ep(
            id=created.id,
            obj_in=schemas.update(title="Up2"),
            session=session,
            user=None,
        )
        assert result.title == "Up2"
        assert called == ["before", "after"]

    router_forbidden = (
        CrudRouterBuilder.for_model(Book, get_session)
        .with_policy(DenyUpdate())
        .with_operations({Operation.CREATE, Operation.UPDATE})
        .build()
    )
    create_forbidden = _get_route(router_forbidden, "/", "POST").endpoint
    update_forbidden = _get_route(router_forbidden, "/{id}", "PATCH").endpoint
    async for session in get_session():
        created = await create_forbidden(
            obj_in=schemas.create(title="DeniedUp", isbn="DU"),
            session=session,
            user=None,
        )
        with pytest.raises(ForbiddenError):
            await update_forbidden(
                id=created.id,
                obj_in=schemas.update(title="x"),
                session=session,
                user=None,
            )
        with pytest.raises(NotFoundError):
            await update_forbidden(
                id=9999,
                obj_in=schemas.update(title="x"),
                session=session,
                user=None,
            )


async def test_delete_branches(get_session: SessionFactory) -> None:
    class DenyDelete:
        def can_create(self, user: User, obj_in: BaseModel) -> bool:
            return True

        def can_read(self, user: User, db_obj: SQLModel) -> bool:
            return True

        def can_update(self, user: User, db_obj: SQLModel, obj_in: BaseModel) -> bool:
            return True

        def can_delete(self, user: User, db_obj: SQLModel) -> bool:
            return False

        def filter_list_query(
            self, user: User, query: SelectQuery[SQLModel]
        ) -> SelectQuery[SQLModel]:
            return query

    schemas = derive_schemas(Book)
    called = []

    async def before_delete(session: AsyncSession, obj: SQLModel, user: User) -> None:
        called.append("before")

    async def after_delete(session: AsyncSession, obj: SQLModel, user: User) -> None:
        called.append("after")

    router = (
        CrudRouterBuilder.for_model(Book, get_session)
        .with_hooks(HooksConfig(before_delete=before_delete, after_delete=after_delete))
        .with_operations({Operation.CREATE, Operation.DELETE})
        .build()
    )
    create_ep = _get_route(router, "/", "POST").endpoint
    delete_ep = _get_route(router, "/{id}", "DELETE").endpoint
    async for session in get_session():
        created = await create_ep(
            obj_in=schemas.create(title="Del", isbn="D"),
            session=session,
            user=None,
        )
        await delete_ep(id=created.id, session=session, user=None)
        assert called == ["before", "after"]

    router_forbidden = (
        CrudRouterBuilder.for_model(Book, get_session)
        .with_policy(DenyDelete())
        .with_operations({Operation.CREATE, Operation.DELETE})
        .build()
    )
    create_forbidden = _get_route(router_forbidden, "/", "POST").endpoint
    delete_forbidden = _get_route(router_forbidden, "/{id}", "DELETE").endpoint
    async for session in get_session():
        created = await create_forbidden(
            obj_in=schemas.create(title="DeniedDel", isbn="DD"),
            session=session,
            user=None,
        )
        with pytest.raises(ForbiddenError):
            await delete_forbidden(id=created.id, session=session, user=None)
        with pytest.raises(NotFoundError):
            await delete_forbidden(id=9999, session=session, user=None)


async def test_bulk_create_forbidden(get_session: SessionFactory) -> None:
    class DenyCreate:
        def can_create(self, user: User, obj_in: BaseModel) -> bool:
            return False

        def can_read(self, user: User, db_obj: SQLModel) -> bool:
            return True

        def can_update(self, user: User, db_obj: SQLModel, obj_in: BaseModel) -> bool:
            return True

        def can_delete(self, user: User, db_obj: SQLModel) -> bool:
            return True

        def filter_list_query(
            self, user: User, query: SelectQuery[SQLModel]
        ) -> SelectQuery[SQLModel]:
            return query

    router = (
        CrudRouterBuilder.for_model(Book, get_session)
        .with_policy(DenyCreate())
        .with_operations({Operation.BULK_CREATE})
        .build()
    )
    bulk_create_ep = _get_route(router, "/bulk", "POST").endpoint
    async for session in get_session():
        with pytest.raises(ForbiddenError):
            await bulk_create_ep(
                objects_in=[derive_schemas(Book).create(title="T", isbn="I")],
                session=session,
                user=None,
            )


async def test_bulk_update_forbidden_and_not_found(
    get_session: SessionFactory,
) -> None:
    class DenyUpdate:
        def can_create(self, user: User, obj_in: BaseModel) -> bool:
            return True

        def can_read(self, user: User, db_obj: SQLModel) -> bool:
            return True

        def can_update(self, user: User, db_obj: SQLModel, obj_in: BaseModel) -> bool:
            return False

        def can_delete(self, user: User, db_obj: SQLModel) -> bool:
            return True

        def filter_list_query(
            self, user: User, query: SelectQuery[SQLModel]
        ) -> SelectQuery[SQLModel]:
            return query

    schemas = derive_schemas(Book)
    router = (
        CrudRouterBuilder.for_model(Book, get_session)
        .with_operations({Operation.CREATE, Operation.BULK_UPDATE})
        .build()
    )
    create_ep = _get_route(router, "/", "POST").endpoint
    bulk_update_ep = _get_route(router, "/bulk", "PATCH").endpoint

    BulkUpdateItem = create_model(
        "BulkUpdateForbidden",
        __base__=BaseModel,
        id=(int, ...),
        title=(str | None, None),
    )

    async for session in get_session():
        await create_ep(
            obj_in=schemas.create(title="Bulk", isbn="B"),
            session=session,
            user=None,
        )
        with pytest.raises(NotFoundError):
            await bulk_update_ep(
                items_in=[BulkUpdateItem(id=9999, title="X")],
                session=session,
                user=None,
            )

    router_forbidden = (
        CrudRouterBuilder.for_model(Book, get_session)
        .with_policy(DenyUpdate())
        .with_operations({Operation.CREATE, Operation.BULK_UPDATE})
        .build()
    )
    create_ep2 = _get_route(router_forbidden, "/", "POST").endpoint
    bulk_update_forbidden = _get_route(router_forbidden, "/bulk", "PATCH").endpoint
    async for session in get_session():
        created = await create_ep2(
            obj_in=schemas.create(title="Bulk2", isbn="B2"),
            session=session,
            user=None,
        )
        with pytest.raises(ForbiddenError):
            await bulk_update_forbidden(
                items_in=[BulkUpdateItem(id=created.id, title="X")],
                session=session,
                user=None,
            )


async def test_bulk_delete_branches(get_session: SessionFactory) -> None:
    schemas = derive_schemas(Book)
    router = (
        CrudRouterBuilder.for_model(Book, get_session)
        .with_operations({Operation.CREATE, Operation.BULK_DELETE})
        .build()
    )
    create_ep = _get_route(router, "/", "POST").endpoint
    bulk_delete_ep = _get_route(router, "/bulk", "DELETE").endpoint

    from pydantic import create_model as _cm

    BookIdentifier = _cm("BookIdentifier", __base__=BaseModel, id=(int, ...))

    async for session in get_session():
        created = await create_ep(
            obj_in=schemas.create(title="SB", isbn="SB1"),
            session=session,
            user=None,
        )
        await bulk_delete_ep(
            items_in=[BookIdentifier(id=created.id)], session=session, user=None
        )
        with pytest.raises(NotFoundError):
            await bulk_delete_ep(
                items_in=[BookIdentifier(id=9999)], session=session, user=None
            )


async def test_bulk_delete_forbidden(get_session: SessionFactory) -> None:
    class DenyDelete:
        def can_create(self, user: User, obj_in: BaseModel) -> bool:
            return True

        def can_read(self, user: User, db_obj: SQLModel) -> bool:
            return True

        def can_update(self, user: User, db_obj: SQLModel, obj_in: BaseModel) -> bool:
            return True

        def can_delete(self, user: User, db_obj: SQLModel) -> bool:
            return False

        def filter_list_query(
            self, user: User, query: SelectQuery[SQLModel]
        ) -> SelectQuery[SQLModel]:
            return query

    schemas = derive_schemas(Book)
    router = (
        CrudRouterBuilder.for_model(Book, get_session)
        .with_policy(DenyDelete())
        .with_operations({Operation.CREATE, Operation.BULK_DELETE})
        .build()
    )
    create_ep = _get_route(router, "/", "POST").endpoint
    bulk_delete_ep = _get_route(router, "/bulk", "DELETE").endpoint
    async for session in get_session():
        created = await create_ep(
            obj_in=schemas.create(title="FD", isbn="FD"),
            session=session,
            user=None,
        )
        from pydantic import create_model as _cm2

        BookId = _cm2("BookIdentifier", __base__=BaseModel, id=(int, ...))
        with pytest.raises(ForbiddenError):
            await bulk_delete_ep(
                items_in=[BookId(id=created.id)], session=session, user=None
            )


def test_resolve_relation_fk_field_unknown_relation() -> None:
    with pytest.raises(ConfigurationError, match="Unknown relationship"):
        _resolve_relation_fk_field(
            EndpointCovBook,
            "unknown",
            EndpointCovAuthor,
        )


def test_resolve_relation_fk_field_not_many_to_one(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    from sqlalchemy.orm import RelationshipDirection

    fake_rel = SimpleNamespace(
        direction=RelationshipDirection.ONETOMANY,
        mapper=SimpleNamespace(class_=EndpointCovAuthor),
        local_columns=(SimpleNamespace(name="author_id"),),
    )
    fake_mapper = SimpleNamespace(relationships={"author": fake_rel})
    monkeypatch.setattr("auen._async_endpoints.sa_inspect", lambda _model: fake_mapper)

    with pytest.raises(ConfigurationError, match="MANYTOONE"):
        _resolve_relation_fk_field(
            EndpointCovBook,
            "author",
            EndpointCovAuthor,
        )


def test_resolve_relation_fk_field_model_mismatch(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    from sqlalchemy.orm import RelationshipDirection

    fake_rel = SimpleNamespace(
        direction=RelationshipDirection.MANYTOONE,
        mapper=SimpleNamespace(class_=EndpointCovAuthor),
        local_columns=(SimpleNamespace(name="author_id"),),
    )
    fake_mapper = SimpleNamespace(relationships={"author": fake_rel})
    monkeypatch.setattr("auen._async_endpoints.sa_inspect", lambda _model: fake_mapper)

    with pytest.raises(ConfigurationError, match="model mismatch"):
        _resolve_relation_fk_field(
            EndpointCovBook,
            "author",
            EndpointCovBook,
        )


def test_resolve_relation_fk_field_multiple_local_columns(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    from sqlalchemy.orm import RelationshipDirection

    fake_rel = SimpleNamespace(
        direction=RelationshipDirection.MANYTOONE,
        mapper=SimpleNamespace(class_=EndpointCovAuthor),
        local_columns=(
            SimpleNamespace(name="author_id"),
            SimpleNamespace(name="author_id2"),
        ),
    )
    fake_mapper = SimpleNamespace(relationships={"author": fake_rel})
    monkeypatch.setattr("auen._async_endpoints.sa_inspect", lambda _model: fake_mapper)

    with pytest.raises(ConfigurationError, match="exactly one local FK"):
        _resolve_relation_fk_field(
            EndpointCovBook,
            "author",
            EndpointCovAuthor,
        )


def test_resolve_relation_fk_field_success() -> None:
    assert (
        _resolve_relation_fk_field(EndpointCovBook, "author", EndpointCovAuthor)
        == "author_id"
    )


def test_build_nested_update_schema_fallback_derived() -> None:
    base = derive_schemas(EndpointCovBook)
    ctx = RouterContext(
        router=APIRouter(),
        model=EndpointCovBook,
        model_name=EndpointCovBook.__name__,
        schemas=base.__class__(
            create=base.create,
            read=base.read,
            update=base.update,
            nested_update=None,
        ),
        policy=EndpointCovAllowAll(),
        session_dep=lambda: None,
        auth=None,
        pk_name="id",
        pk_type=int,
        pagination=PaginationConfig(),
        filters=None,
        repository_factory=lambda _model: None,
        hooks=HooksConfig(),
        nested_writes=None,
        nested_policy_registry={},
        include_in_schema={Operation.NESTED_UPDATE},
        operation_id_prefix="book",
    )
    nested_schema = _build_nested_update_schema(ctx)
    assert nested_schema is not None
    assert "title" in nested_schema.model_fields


def test_build_nested_create_schema_fallback_derived() -> None:
    base = derive_schemas(EndpointCovBook)
    ctx = RouterContext(
        router=APIRouter(),
        model=EndpointCovBook,
        model_name=EndpointCovBook.__name__,
        schemas=base.__class__(
            create=base.create,
            read=base.read,
            update=base.update,
            nested_create=None,
            nested_update=base.nested_update,
        ),
        policy=EndpointCovAllowAll(),
        session_dep=lambda: None,
        auth=None,
        pk_name="id",
        pk_type=int,
        pagination=PaginationConfig(),
        filters=None,
        repository_factory=lambda _model: None,
        hooks=HooksConfig(),
        nested_writes=None,
        nested_policy_registry={},
        include_in_schema={Operation.NESTED_CREATE},
        operation_id_prefix="book",
    )
    nested_schema = _build_nested_create_schema(ctx)
    assert nested_schema is not None
    assert "title" in nested_schema.model_fields


async def test_nested_create_forbidden_validation_and_success_branches(
    get_session: SessionFactory,
) -> None:
    called: list[str] = []

    async def before_create(session: AsyncSession, obj: SQLModel, user: User) -> None:
        called.append("before")

    async def after_create(session: AsyncSession, obj: SQLModel, user: User) -> None:
        called.append("after")

    nested_cfg = NestedWriteConfig(
        fields={"author": NestedRelationConfig(model=EndpointCovAuthor)}
    )
    router = (
        CrudRouterBuilder.for_model(EndpointCovBook, get_session)
        .with_hooks(HooksConfig(before_create=before_create, after_create=after_create))
        .with_nested_writes(nested_cfg)
        .with_operations({Operation.NESTED_CREATE})
        .build()
    )
    nested_create_ep = _get_route(router, "/nested", "POST").endpoint
    nested_obj_type = nested_create_ep.__annotations__["obj_in"]

    async for session in get_session():
        created = await nested_create_ep(
            obj_in=nested_obj_type(title="B1", isbn="1", author={"name": "A1"}),
            session=session,
            user=None,
        )
        assert created.title == "B1"
        assert created.author_id is not None

        with pytest.raises(HTTPException, match="must be an object"):
            await nested_create_ep(
                obj_in=nested_obj_type(title="B2", isbn="2", author=None),
                session=session,
                user=None,
            )

        with pytest.raises(HTTPException, match="Field required"):
            await nested_create_ep(
                obj_in=nested_obj_type(title="B3", isbn="3", author={}),
                session=session,
                user=None,
            )

        author = EndpointCovAuthor(name="A-existing", bio="old")
        session.add(author)
        await session.flush()
        author_id = author.id

        updated = await nested_create_ep(
            obj_in=nested_obj_type(
                title="B4",
                isbn="4",
                author={"id": author_id, "name": "A-updated", "bio": "new"},
            ),
            session=session,
            user=None,
        )
        assert updated.author_id == author_id

        refreshed = await session.get(EndpointCovAuthor, author_id)
        assert refreshed is not None
        assert refreshed.name == "A-updated"
        assert called.count("before") == 4
        assert called.count("after") == 2

    forbidden_router = (
        CrudRouterBuilder.for_model(EndpointCovBook, get_session)
        .with_policy(EndpointCovDenyCreate())
        .with_operations({Operation.NESTED_CREATE})
        .build()
    )
    nested_create_ep = _get_route(forbidden_router, "/nested", "POST").endpoint
    nested_obj_type = nested_create_ep.__annotations__["obj_in"]

    async for session in get_session():
        with pytest.raises(ForbiddenError):
            await nested_create_ep(
                obj_in=nested_obj_type(title="Denied", isbn="D"),
                session=session,
                user=None,
            )


async def test_nested_create_relation_policy_denies_create_and_update(
    get_session: SessionFactory,
) -> None:
    deny_create_router = (
        CrudRouterBuilder.for_model(EndpointCovBook, get_session)
        .with_nested_writes(
            NestedWriteConfig(
                fields={
                    "author": NestedRelationConfig(
                        model=EndpointCovAuthor,
                        policy=EndpointCovRelationPolicyDenyCreate(),
                    )
                }
            )
        )
        .with_operations({Operation.NESTED_CREATE})
        .build()
    )
    nested_create_ep = _get_route(deny_create_router, "/nested", "POST").endpoint
    nested_obj_type = nested_create_ep.__annotations__["obj_in"]

    async for session in get_session():
        with pytest.raises(ForbiddenError):
            await nested_create_ep(
                obj_in=nested_obj_type(title="C", isbn="1", author={"name": "blocked"}),
                session=session,
                user=None,
            )

    deny_update_router = (
        CrudRouterBuilder.for_model(EndpointCovBook, get_session)
        .with_nested_writes(
            NestedWriteConfig(
                fields={
                    "author": NestedRelationConfig(
                        model=EndpointCovAuthor,
                        policy=EndpointCovRelationPolicyDenyUpdate(),
                    )
                }
            )
        )
        .with_operations({Operation.NESTED_CREATE})
        .build()
    )
    nested_create_ep = _get_route(deny_update_router, "/nested", "POST").endpoint
    nested_obj_type = nested_create_ep.__annotations__["obj_in"]

    async for session in get_session():
        author = EndpointCovAuthor(name="A")
        session.add(author)
        await session.flush()
        author_id = author.id

        with pytest.raises(ForbiddenError):
            await nested_create_ep(
                obj_in=nested_obj_type(
                    title="C2",
                    isbn="2",
                    author={"id": author_id, "name": "blocked"},
                ),
                session=session,
                user=None,
            )


async def test_nested_create_invalid_nested_writes_configs(
    get_session: SessionFactory,
) -> None:
    unknown_relation_router = (
        CrudRouterBuilder.for_model(EndpointCovBook, get_session)
        .with_nested_writes(
            NestedWriteConfig(
                fields={"missing": NestedRelationConfig(model=EndpointCovAuthor)}
            )
        )
        .with_operations({Operation.NESTED_CREATE})
        .build()
    )
    nested_create_ep = _get_route(unknown_relation_router, "/nested", "POST").endpoint
    nested_obj_type = nested_create_ep.__annotations__["obj_in"]

    async for session in get_session():
        with pytest.raises(ConfigurationError, match="Unknown relationship 'missing'"):
            await nested_create_ep(
                obj_in=nested_obj_type(title="X", isbn="1"),
                session=session,
                user=None,
            )

    model_mismatch_router = (
        CrudRouterBuilder.for_model(EndpointCovBook, get_session)
        .with_nested_writes(
            NestedWriteConfig(
                fields={"author": NestedRelationConfig(model=EndpointCovOther)}
            )
        )
        .with_operations({Operation.NESTED_CREATE})
        .build()
    )
    nested_create_ep = _get_route(model_mismatch_router, "/nested", "POST").endpoint
    nested_obj_type = nested_create_ep.__annotations__["obj_in"]

    async for session in get_session():
        with pytest.raises(ConfigurationError, match="model mismatch"):
            await nested_create_ep(
                obj_in=nested_obj_type(title="Y", isbn="2"),
                session=session,
                user=None,
            )


async def test_nested_create_conflict_and_no_after_hook_branches(
    get_session: SessionFactory,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    router = (
        CrudRouterBuilder.for_model(EndpointCovBook, get_session)
        .with_operations({Operation.NESTED_CREATE})
        .build()
    )
    nested_create_ep = _get_route(router, "/nested", "POST").endpoint
    nested_obj_type = nested_create_ep.__annotations__["obj_in"]

    fake_obj = EndpointCovBook(id=1, title="B", isbn="1")
    monkeypatch.setattr(
        "auen._async_endpoints.apply_nested_creates",
        AsyncMock(return_value=fake_obj),
    )

    ok_session = SimpleNamespace(
        commit=AsyncMock(),
        refresh=AsyncMock(),
        rollback=AsyncMock(),
    )
    result = await nested_create_ep(
        obj_in=nested_obj_type(title="B", isbn="1"),
        session=ok_session,
        user=None,
    )
    assert result is fake_obj

    failing_session = SimpleNamespace(
        commit=AsyncMock(
            side_effect=IntegrityError("stmt", {"x": "y"}, Exception("boom"))
        ),
        refresh=AsyncMock(),
        rollback=AsyncMock(),
    )
    with pytest.raises(ConflictError):
        await nested_create_ep(
            obj_in=nested_obj_type(title="B2", isbn="2"),
            session=failing_session,
            user=None,
        )
    assert failing_session.rollback.await_count == 1
    assert failing_session.refresh.await_count == 0


async def test_nested_update_not_found_forbidden_and_plain_update_path(
    get_session: SessionFactory,
) -> None:
    schemas = derive_schemas(EndpointCovBook)
    router = (
        CrudRouterBuilder.for_model(EndpointCovBook, get_session)
        .with_operations({Operation.CREATE, Operation.NESTED_UPDATE})
        .build()
    )
    create_ep = _get_route(router, "/", "POST").endpoint
    nested_update_ep = _get_route(router, "/{id}/nested", "PATCH").endpoint

    async for session in get_session():
        with pytest.raises(NotFoundError):
            await nested_update_ep(
                id=9999,
                obj_in=schemas.update(title="x"),
                session=session,
                user=None,
            )

        created = await create_ep(
            obj_in=schemas.create(title="Plain", isbn="P"),
            session=session,
            user=None,
        )
        result = await nested_update_ep(
            id=created.id,
            obj_in=schemas.update(title="Plain2"),
            session=session,
            user=None,
        )
        assert result.title == "Plain2"

    forbidden_router = (
        CrudRouterBuilder.for_model(EndpointCovBook, get_session)
        .with_policy(EndpointCovDenyUpdate())
        .with_operations({Operation.CREATE, Operation.NESTED_UPDATE})
        .build()
    )
    create_ep = _get_route(forbidden_router, "/", "POST").endpoint
    nested_update_ep = _get_route(forbidden_router, "/{id}/nested", "PATCH").endpoint

    async for session in get_session():
        created = await create_ep(
            obj_in=schemas.create(title="Denied", isbn="D"),
            session=session,
            user=None,
        )
        with pytest.raises(ForbiddenError):
            await nested_update_ep(
                id=created.id,
                obj_in=schemas.update(title="Nope"),
                session=session,
                user=None,
            )


async def test_nested_update_validation_and_success_branches(
    get_session: SessionFactory,
) -> None:
    called: list[str] = []

    async def before_update(session: AsyncSession, obj: SQLModel, user: User) -> None:
        called.append("before")

    async def after_update(session: AsyncSession, obj: SQLModel, user: User) -> None:
        called.append("after")

    nested_cfg = NestedWriteConfig(
        fields={"author": NestedRelationConfig(model=EndpointCovAuthor)}
    )
    router = (
        CrudRouterBuilder.for_model(EndpointCovBook, get_session)
        .with_hooks(HooksConfig(before_update=before_update, after_update=after_update))
        .with_nested_writes(nested_cfg)
        .with_operations({Operation.CREATE, Operation.NESTED_UPDATE})
        .build()
    )
    schemas = derive_schemas(EndpointCovBook)
    create_ep = _get_route(router, "/", "POST").endpoint
    nested_update_ep = _get_route(router, "/{id}/nested", "PATCH").endpoint
    nested_obj_type = nested_update_ep.__annotations__["obj_in"]

    async for session in get_session():
        author = EndpointCovAuthor(name="A", bio="old")
        author2 = EndpointCovAuthor(name="B", bio="old")
        session.add(author)
        session.add(author2)
        await session.flush()
        author_id = author.id
        author2_id = author2.id

        created = await create_ep(
            obj_in=schemas.create(title="B", isbn="1", author_id=author_id),
            session=session,
            user=None,
        )

        plain_updated = await nested_update_ep(
            id=created.id,
            obj_in=nested_obj_type(title="B0"),
            session=session,
            user=None,
        )
        assert plain_updated.title == "B0"

        with pytest.raises(HTTPException, match="must be an object"):
            await nested_update_ep(
                id=created.id,
                obj_in=nested_obj_type(author=None),
                session=session,
                user=None,
            )

        with pytest.raises(HTTPException, match="without nested PK"):
            await nested_update_ep(
                id=created.id,
                obj_in=nested_obj_type(author_id=author_id, author={"name": "n"}),
                session=session,
                user=None,
            )

        with pytest.raises(HTTPException, match="Conflict between"):
            await nested_update_ep(
                id=created.id,
                obj_in=nested_obj_type(
                    author_id=author_id,
                    author={"id": author2_id, "name": "n"},
                ),
                session=session,
                user=None,
            )

        with pytest.raises(NotFoundError):
            await nested_update_ep(
                id=created.id,
                obj_in=nested_obj_type(author={"id": 9999, "name": "missing"}),
                session=session,
                user=None,
            )

        updated = await nested_update_ep(
            id=created.id,
            obj_in=nested_obj_type(
                title="B2",
                author={"id": author_id, "name": "A2", "bio": "new"},
            ),
            session=session,
            user=None,
        )
        assert updated.title == "B2"
        assert updated.author_id == author_id

        same_fk_updated = await nested_update_ep(
            id=created.id,
            obj_in=nested_obj_type(
                author_id=author_id,
                author={"id": author_id, "bio": "same"},
            ),
            session=session,
            user=None,
        )
        assert same_fk_updated.author_id == author_id

        updated_book = await nested_update_ep(
            id=created.id,
            obj_in=nested_obj_type(author={"name": "Created", "bio": "created"}),
            session=session,
            user=None,
        )
        assert updated_book.author_id is not None
        assert updated_book.author_id not in {author_id, author2_id}
        assert called.count("before") == 8
        assert called.count("after") == 4
        assert called[-2:] == ["before", "after"]


async def test_nested_update_relation_policy_denies_create_and_update(
    get_session: SessionFactory,
) -> None:
    schemas = derive_schemas(EndpointCovBook)

    deny_create_router = (
        CrudRouterBuilder.for_model(EndpointCovBook, get_session)
        .with_nested_writes(
            NestedWriteConfig(
                fields={
                    "author": NestedRelationConfig(
                        model=EndpointCovAuthor,
                        policy=EndpointCovRelationPolicyDenyCreate(),
                    )
                }
            )
        )
        .with_operations({Operation.CREATE, Operation.NESTED_UPDATE})
        .build()
    )
    create_ep = _get_route(deny_create_router, "/", "POST").endpoint
    nested_update_ep = _get_route(deny_create_router, "/{id}/nested", "PATCH").endpoint
    nested_obj_type = nested_update_ep.__annotations__["obj_in"]

    async for session in get_session():
        created = await create_ep(
            obj_in=schemas.create(title="C", isbn="1"),
            session=session,
            user=None,
        )
        with pytest.raises(ForbiddenError):
            await nested_update_ep(
                id=created.id,
                obj_in=nested_obj_type(author={"name": "blocked"}),
                session=session,
                user=None,
            )

    deny_update_router = (
        CrudRouterBuilder.for_model(EndpointCovBook, get_session)
        .with_nested_writes(
            NestedWriteConfig(
                fields={
                    "author": NestedRelationConfig(
                        model=EndpointCovAuthor,
                        policy=EndpointCovRelationPolicyDenyUpdate(),
                    )
                }
            )
        )
        .with_operations({Operation.CREATE, Operation.NESTED_UPDATE})
        .build()
    )
    create_ep = _get_route(deny_update_router, "/", "POST").endpoint
    nested_update_ep = _get_route(deny_update_router, "/{id}/nested", "PATCH").endpoint
    nested_obj_type = nested_update_ep.__annotations__["obj_in"]

    async for session in get_session():
        author = EndpointCovAuthor(name="A")
        session.add(author)
        await session.flush()
        author_id = author.id

        created = await create_ep(
            obj_in=schemas.create(title="C2", isbn="2", author_id=author_id),
            session=session,
            user=None,
        )
        with pytest.raises(ForbiddenError):
            await nested_update_ep(
                id=created.id,
                obj_in=nested_obj_type(author={"id": author_id, "name": "blocked"}),
                session=session,
                user=None,
            )


async def test_nested_update_invalid_nested_writes_unknown_relation(
    get_session: SessionFactory,
) -> None:
    schemas = derive_schemas(EndpointCovBook)
    router = (
        CrudRouterBuilder.for_model(EndpointCovBook, get_session)
        .with_nested_writes(
            NestedWriteConfig(
                fields={"missing": NestedRelationConfig(model=EndpointCovAuthor)}
            )
        )
        .with_operations({Operation.CREATE, Operation.NESTED_UPDATE})
        .build()
    )
    create_ep = _get_route(router, "/", "POST").endpoint
    nested_update_ep = _get_route(router, "/{id}/nested", "PATCH").endpoint

    async for session in get_session():
        created = await create_ep(
            obj_in=schemas.create(title="C3", isbn="3"),
            session=session,
            user=None,
        )
        with pytest.raises(ConfigurationError, match="Unknown relationship 'missing'"):
            await nested_update_ep(
                id=created.id,
                obj_in=schemas.update(title="x"),
                session=session,
                user=None,
            )


async def test_nested_update_invalid_nested_writes_model_mismatch(
    get_session: SessionFactory,
) -> None:
    schemas = derive_schemas(EndpointCovBook)
    router = (
        CrudRouterBuilder.for_model(EndpointCovBook, get_session)
        .with_nested_writes(
            NestedWriteConfig(
                fields={"author": NestedRelationConfig(model=EndpointCovOther)}
            )
        )
        .with_operations({Operation.CREATE, Operation.NESTED_UPDATE})
        .build()
    )
    create_ep = _get_route(router, "/", "POST").endpoint
    nested_update_ep = _get_route(router, "/{id}/nested", "PATCH").endpoint

    async for session in get_session():
        created = await create_ep(
            obj_in=schemas.create(title="C4", isbn="4"),
            session=session,
            user=None,
        )
        with pytest.raises(ConfigurationError, match="model mismatch"):
            await nested_update_ep(
                id=created.id,
                obj_in=schemas.update(title="x"),
                session=session,
                user=None,
            )
