"""Nested endpoint branches and relation helper tests."""

from __future__ import annotations

from types import SimpleNamespace
from unittest.mock import AsyncMock

import pytest
from fastapi import APIRouter, HTTPException
from sqlalchemy.exc import IntegrityError
from sqlmodel import SQLModel
from sqlmodel.ext.asyncio.session import AsyncSession
from tests.conftest import SessionFactory
from tests.endpoints.shared import (
    EndpointCovAllowAll,
    EndpointCovAuthor,
    EndpointCovBook,
    EndpointCovDenyCreate,
    EndpointCovDenyUpdate,
    EndpointCovOther,
    EndpointCovRelationPolicyDenyCreate,
    EndpointCovRelationPolicyDenyUpdate,
    get_route,
)

from auen import (
    CrudRouterBuilder,
    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
from auen.config import NestedRelationConfig, NestedWriteConfig
from auen.exceptions import (
    ConfigurationError,
    ConflictError,
    ForbiddenError,
    NotFoundError,
)
from auen.types import User


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_common.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_common.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_common.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()
        .with_hooks(HooksConfig(before_create=before_create, after_create=after_create))
        .with_nested_writes(nested_cfg)
        .with_operations({Operation.NESTED_CREATE})
        .build_routers(models=[EndpointCovBook], session_dep=get_session)[0]
    )
    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()
        .with_policy(EndpointCovDenyCreate())
        .with_operations({Operation.NESTED_CREATE})
        .build_routers(models=[EndpointCovBook], session_dep=get_session)[0]
    )
    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()
        .with_nested_writes(
            NestedWriteConfig(
                fields={
                    "author": NestedRelationConfig(
                        model=EndpointCovAuthor,
                        policy=EndpointCovRelationPolicyDenyCreate(),
                    )
                }
            )
        )
        .with_operations({Operation.NESTED_CREATE})
        .build_routers(models=[EndpointCovBook], session_dep=get_session)[0]
    )
    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()
        .with_nested_writes(
            NestedWriteConfig(
                fields={
                    "author": NestedRelationConfig(
                        model=EndpointCovAuthor,
                        policy=EndpointCovRelationPolicyDenyUpdate(),
                    )
                }
            )
        )
        .with_operations({Operation.NESTED_CREATE})
        .build_routers(models=[EndpointCovBook], session_dep=get_session)[0]
    )
    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()
        .with_nested_writes(
            NestedWriteConfig(
                fields={"missing": NestedRelationConfig(model=EndpointCovAuthor)}
            )
        )
        .with_operations({Operation.NESTED_CREATE})
        .build_routers(models=[EndpointCovBook], session_dep=get_session)[0]
    )
    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()
        .with_nested_writes(
            NestedWriteConfig(
                fields={"author": NestedRelationConfig(model=EndpointCovOther)}
            )
        )
        .with_operations({Operation.NESTED_CREATE})
        .build_routers(models=[EndpointCovBook], session_dep=get_session)[0]
    )
    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()
        .with_operations({Operation.NESTED_CREATE})
        .build_routers(models=[EndpointCovBook], session_dep=get_session)[0]
    )
    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_nested_ops.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()
        .with_operations({Operation.CREATE, Operation.NESTED_UPDATE})
        .build_routers(models=[EndpointCovBook], session_dep=get_session)[0]
    )
    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()
        .with_policy(EndpointCovDenyUpdate())
        .with_operations({Operation.CREATE, Operation.NESTED_UPDATE})
        .build_routers(models=[EndpointCovBook], session_dep=get_session)[0]
    )
    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()
        .with_hooks(HooksConfig(before_update=before_update, after_update=after_update))
        .with_nested_writes(nested_cfg)
        .with_operations({Operation.CREATE, Operation.NESTED_UPDATE})
        .build_routers(models=[EndpointCovBook], session_dep=get_session)[0]
    )
    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()
        .with_nested_writes(
            NestedWriteConfig(
                fields={
                    "author": NestedRelationConfig(
                        model=EndpointCovAuthor,
                        policy=EndpointCovRelationPolicyDenyCreate(),
                    )
                }
            )
        )
        .with_operations({Operation.CREATE, Operation.NESTED_UPDATE})
        .build_routers(models=[EndpointCovBook], session_dep=get_session)[0]
    )
    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()
        .with_nested_writes(
            NestedWriteConfig(
                fields={
                    "author": NestedRelationConfig(
                        model=EndpointCovAuthor,
                        policy=EndpointCovRelationPolicyDenyUpdate(),
                    )
                }
            )
        )
        .with_operations({Operation.CREATE, Operation.NESTED_UPDATE})
        .build_routers(models=[EndpointCovBook], session_dep=get_session)[0]
    )
    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()
        .with_nested_writes(
            NestedWriteConfig(
                fields={"missing": NestedRelationConfig(model=EndpointCovAuthor)}
            )
        )
        .with_operations({Operation.CREATE, Operation.NESTED_UPDATE})
        .build_routers(models=[EndpointCovBook], session_dep=get_session)[0]
    )
    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()
        .with_nested_writes(
            NestedWriteConfig(
                fields={"author": NestedRelationConfig(model=EndpointCovOther)}
            )
        )
        .with_operations({Operation.CREATE, Operation.NESTED_UPDATE})
        .build_routers(models=[EndpointCovBook], session_dep=get_session)[0]
    )
    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,
            )
