"""Unit coverage for nested mutation helpers."""

from __future__ import annotations

from types import SimpleNamespace
from typing import cast
from unittest.mock import AsyncMock

import pytest
from pydantic import BaseModel, ConfigDict
from sqlalchemy.orm import RelationshipDirection
from sqlmodel import Field, SQLModel
from sqlmodel.ext.asyncio.session import AsyncSession

from auen import CrudPolicy
from auen._nested_mutations import (
    _apply_many_to_many,
    _apply_one_to_many,
    _apply_relation,
    _as_list,
    _create_model_graph,
    _many_to_many_pairs,
    _many_to_one_fk_field,
    _MutationContext,
    _one_to_many_pair,
    _update_model_graph,
)
from auen._relations import RelationInfo
from auen.exceptions import ConfigurationError, ForbiddenError, NotFoundError


class AnyCreate(BaseModel):
    model_config = ConfigDict(extra="allow")


class AnyUpdate(BaseModel):
    model_config = ConfigDict(extra="allow")


class UnitParent(SQLModel, table=True):
    __tablename__ = "unit_parent"
    id: int | None = Field(default=None, primary_key=True)
    parent_fk: int | None = None


class UnitChild(SQLModel, table=True):
    __tablename__ = "unit_child"
    id: int | None = Field(default=None, primary_key=True)
    parent_id: int | None = Field(default=None, foreign_key="unit_parent.id")
    name: str | None = None


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


class UnitLink(SQLModel, table=True):
    __tablename__ = "unit_link"
    parent_id: int | None = Field(
        default=None,
        foreign_key="unit_parent.id",
        primary_key=True,
    )
    tag_id: int | None = Field(
        default=None,
        foreign_key="unit_tag.id",
        primary_key=True,
    )
    weight: int | None = None


def _ctx(session: AsyncSession | object) -> _MutationContext:
    return _MutationContext(
        session=cast(AsyncSession, session),
        user=None,
        nested_policy_registry={},
        relation_policy_overrides={},
    )


def _rel(
    *,
    name: str,
    direction: RelationshipDirection,
    uselist: bool,
    local_columns: tuple[str, ...] = ("parent_fk",),
    sync_pairs: tuple[tuple[str, str], ...] = (("id", "parent_id"),),
    secondary_sync_pairs: tuple[tuple[str, str], ...] = (("id", "tag_id"),),
    target_model: type[SQLModel] = UnitChild,
    link_model: type[SQLModel] | None = None,
) -> RelationInfo:
    return RelationInfo(
        name=name,
        parent_model=UnitParent,
        target_model=target_model,
        direction=direction,
        uselist=uselist,
        secondary=None,
        local_columns=local_columns,
        remote_columns=(),
        sync_pairs=sync_pairs,
        secondary_sync_pairs=secondary_sync_pairs,
        local_remote_pairs=(),
        link_model=link_model,
    )


def test_pair_helpers_and_list_validation_errors() -> None:
    with pytest.raises(Exception, match="must be a list"):
        _as_list("x", relation_name="rel")

    with pytest.raises(ConfigurationError, match="exactly one local FK"):
        _many_to_one_fk_field(
            _rel(
                name="rel",
                direction=RelationshipDirection.MANYTOONE,
                uselist=False,
                local_columns=("a", "b"),
            )
        )

    with pytest.raises(ConfigurationError, match="exactly one sync pair"):
        _one_to_many_pair(
            _rel(
                name="children",
                direction=RelationshipDirection.ONETOMANY,
                uselist=True,
                sync_pairs=(("id", "parent_id"), ("id", "parent_id2")),
            )
        )

    with pytest.raises(ConfigurationError, match="exactly one parent and one target"):
        _many_to_many_pairs(
            _rel(
                name="tags",
                direction=RelationshipDirection.MANYTOMANY,
                uselist=True,
                sync_pairs=(("id", "parent_id"), ("id", "parent_id2")),
            )
        )


@pytest.mark.asyncio
async def test_create_model_graph_processes_m2o_and_followup_relations(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    session = SimpleNamespace(add=lambda _obj: None, flush=AsyncMock())
    ctx = _ctx(session)

    relation_data = {"author": {"id": 1}, "chapters": [{"name": "c1"}]}
    relations = {
        "author": _rel(
            name="author",
            direction=RelationshipDirection.MANYTOONE,
            uselist=False,
            local_columns=("parent_fk",),
            target_model=UnitChild,
        ),
        "chapters": _rel(
            name="chapters",
            direction=RelationshipDirection.ONETOMANY,
            uselist=True,
            sync_pairs=(("id", "parent_id"),),
            target_model=UnitChild,
        ),
    }
    monkeypatch.setattr(
        "auen._nested_mutations._split_payload",
        lambda _m, _p: ({"parent_fk": 1}, dict(relation_data), relations),
    )
    monkeypatch.setattr(
        "auen._nested_mutations._flat_schemas",
        lambda _m: (AnyCreate, AnyUpdate),
    )
    m2o = AsyncMock()
    followup = AsyncMock()
    monkeypatch.setattr("auen._nested_mutations._apply_many_to_one", m2o)
    monkeypatch.setattr("auen._nested_mutations._apply_relation", followup)

    obj = await _create_model_graph(ctx, UnitParent, {"x": 1})
    assert isinstance(obj, UnitParent)
    assert m2o.await_count == 1
    assert followup.await_count == 1


@pytest.mark.asyncio
async def test_update_model_graph_processes_relation_loop(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    session = SimpleNamespace(add=lambda _obj: None, flush=AsyncMock())
    ctx = _ctx(session)
    relations = {
        "author": _rel(
            name="author",
            direction=RelationshipDirection.MANYTOONE,
            uselist=False,
            local_columns=("parent_fk",),
            target_model=UnitChild,
        )
    }
    monkeypatch.setattr(
        "auen._nested_mutations._split_payload",
        lambda _m, _p: ({"parent_fk": 1}, {"author": {"id": 1}}, relations),
    )
    monkeypatch.setattr(
        "auen._nested_mutations._flat_schemas",
        lambda _m: (AnyCreate, AnyUpdate),
    )
    apply_relation = AsyncMock()
    monkeypatch.setattr("auen._nested_mutations._apply_relation", apply_relation)

    await _update_model_graph(ctx, UnitParent, UnitParent(id=1), {"x": 1})
    assert apply_relation.await_count == 1


@pytest.mark.asyncio
async def test_apply_relation_requires_parent_for_to_many() -> None:
    ctx = _ctx(SimpleNamespace())
    scalar_data: dict[str, object] = {}
    with pytest.raises(Exception, match="persisted parent object"):
        await _apply_relation(
            ctx,
            parent_model=UnitParent,
            parent_obj=None,
            relation_name="tags",
            rel_info=_rel(
                name="tags",
                direction=RelationshipDirection.MANYTOMANY,
                uselist=True,
                target_model=UnitTag,
            ),
            relation_payload=[],
            parent_scalar_data=scalar_data,
        )
    with pytest.raises(Exception, match="persisted parent object"):
        await _apply_relation(
            ctx,
            parent_model=UnitParent,
            parent_obj=None,
            relation_name="children",
            rel_info=_rel(
                name="children",
                direction=RelationshipDirection.ONETOMANY,
                uselist=True,
                target_model=UnitChild,
            ),
            relation_payload=[],
            parent_scalar_data=scalar_data,
        )
    with pytest.raises(Exception, match="persisted parent object"):
        await _apply_relation(
            ctx,
            parent_model=UnitParent,
            parent_obj=None,
            relation_name="child",
            rel_info=_rel(
                name="child",
                direction=RelationshipDirection.ONETOMANY,
                uselist=False,
                target_model=UnitChild,
            ),
            relation_payload={},
            parent_scalar_data=scalar_data,
        )

    with pytest.raises(Exception, match="Unsupported nested relation type"):
        await _apply_relation(
            ctx,
            parent_model=UnitParent,
            parent_obj=UnitParent(id=1),
            relation_name="weird",
            rel_info=_rel(
                name="weird",
                direction=cast(RelationshipDirection, "UNKNOWN"),
                uselist=False,
                target_model=UnitChild,
            ),
            relation_payload={},
            parent_scalar_data=scalar_data,
        )


@pytest.mark.asyncio
async def test_apply_one_to_many_parent_flush_and_error_paths(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    parent = UnitParent(id=None)

    async def _flush_assign() -> None:
        parent.id = 99

    session = SimpleNamespace(flush=_flush_assign, get=AsyncMock(return_value=None))
    ctx = _ctx(session)
    monkeypatch.setattr("auen._nested_mutations._create_model_graph", AsyncMock())
    monkeypatch.setattr("auen._nested_mutations._update_model_graph", AsyncMock())

    rel = _rel(
        name="children",
        direction=RelationshipDirection.ONETOMANY,
        uselist=True,
        sync_pairs=(("id", "parent_id"),),
        target_model=UnitChild,
    )

    # parent id is assigned during flush, then create path runs and continues
    await _apply_one_to_many(
        ctx,
        parent_model=UnitParent,
        parent_obj=parent,
        relation_name="children",
        rel_info=rel,
        relation_payload=[{"name": "n1"}],
    )
    assert parent.id == 99

    with pytest.raises(Exception, match="Conflict between 'parent_id'"):
        await _apply_one_to_many(
            ctx,
            parent_model=UnitParent,
            parent_obj=UnitParent(id=1),
            relation_name="children",
            rel_info=rel,
            relation_payload=[{"parent_id": 2}],
        )

    with pytest.raises(NotFoundError):
        await _apply_one_to_many(
            ctx,
            parent_model=UnitParent,
            parent_obj=UnitParent(id=1),
            relation_name="children",
            rel_info=rel,
            relation_payload=[{"id": 123}],
        )

    session.get = AsyncMock(return_value=UnitChild(id=123, parent_id=7))
    with pytest.raises(Exception, match="is not related"):
        await _apply_one_to_many(
            ctx,
            parent_model=UnitParent,
            parent_obj=UnitParent(id=1),
            relation_name="children",
            rel_info=rel,
            relation_payload=[{"id": 123}],
        )

    # existing child update path calls nested updater
    update_child = AsyncMock()
    monkeypatch.setattr("auen._nested_mutations._update_model_graph", update_child)
    session.get = AsyncMock(return_value=UnitChild(id=123, parent_id=1))
    await _apply_one_to_many(
        ctx,
        parent_model=UnitParent,
        parent_obj=UnitParent(id=1),
        relation_name="children",
        rel_info=rel,
        relation_payload=[{"id": 123, "name": "ok"}],
    )
    assert update_child.await_count == 1


@pytest.mark.asyncio
async def test_apply_many_to_many_error_and_link_paths(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    parent = UnitParent(id=None)

    async def _flush_assign() -> None:
        parent.id = 10

    session = SimpleNamespace(flush=_flush_assign, get=AsyncMock(return_value=None))
    ctx = _ctx(session)
    monkeypatch.setattr("auen._nested_mutations._create_model_graph", AsyncMock())
    monkeypatch.setattr("auen._nested_mutations._update_model_graph", AsyncMock())
    monkeypatch.setattr(
        "auen._nested_mutations._flat_schemas",
        lambda _m: (AnyCreate, AnyUpdate),
    )

    rel_with_link = _rel(
        name="tags",
        direction=RelationshipDirection.MANYTOMANY,
        uselist=True,
        sync_pairs=(("id", "parent_id"),),
        secondary_sync_pairs=(("id", "tag_id"),),
        target_model=UnitTag,
        link_model=UnitLink,
    )

    with pytest.raises(Exception, match="must contain 'entity'"):
        await _apply_many_to_many(
            ctx,
            parent_model=UnitParent,
            parent_obj=parent,
            relation_name="tags",
            rel_info=rel_with_link,
            relation_payload=[{}],
        )

    with pytest.raises(NotFoundError):
        await _apply_many_to_many(
            ctx,
            parent_model=UnitParent,
            parent_obj=UnitParent(id=1),
            relation_name="tags",
            rel_info=rel_with_link,
            relation_payload=[{"entity": {"id": 999}}],
        )

    # link_model is None path appends to relation list
    tag_obj = UnitTag(id=5)
    session.get = AsyncMock(return_value=tag_obj)
    rel_without_link = _rel(
        name="tags",
        direction=RelationshipDirection.MANYTOMANY,
        uselist=True,
        sync_pairs=(("id", "parent_id"),),
        secondary_sync_pairs=(("id", "tag_id"),),
        target_model=UnitTag,
        link_model=None,
    )
    parent_with_list = SimpleNamespace(id=1, tags=[])
    await _apply_many_to_many(
        ctx,
        parent_model=UnitParent,
        parent_obj=cast(SQLModel, parent_with_list),
        relation_name="tags",
        rel_info=rel_without_link,
        relation_payload=[{"entity": {"id": 5}}],
    )
    assert tag_obj in parent_with_list.tags

    # link create branch
    async def _exec_none(_stmt: object) -> object:
        return SimpleNamespace(first=lambda: None)

    added: list[object] = []
    session.exec = _exec_none
    session.add = lambda obj: added.append(obj)
    await _apply_many_to_many(
        ctx,
        parent_model=UnitParent,
        parent_obj=UnitParent(id=1),
        relation_name="tags",
        rel_info=rel_with_link,
        relation_payload=[{"entity": {"id": 5}, "link": {"weight": 3}}],
    )
    assert any(isinstance(item, UnitLink) for item in added)

    # link update branch
    link_row = UnitLink(parent_id=1, tag_id=5, weight=1)

    async def _exec_row(_stmt: object) -> object:
        return SimpleNamespace(first=lambda: link_row)

    session.exec = _exec_row
    await _apply_many_to_many(
        ctx,
        parent_model=UnitParent,
        parent_obj=UnitParent(id=1),
        relation_name="tags",
        rel_info=rel_with_link,
        relation_payload=[{"entity": {"id": 5}, "link": {"weight": 7}}],
    )
    assert link_row.weight == 7

    # link create policy denial
    deny_create = SimpleNamespace(
        can_create=lambda _u, _o: False,
        can_read=lambda _u, _o: True,
        can_update=lambda _u, _o, _i: True,
        can_delete=lambda _u, _o: True,
        filter_list_query=lambda _u, q: q,
    )
    ctx.nested_policy_registry[UnitLink] = cast(CrudPolicy, deny_create)
    session.exec = _exec_none
    with pytest.raises(ForbiddenError):
        await _apply_many_to_many(
            ctx,
            parent_model=UnitParent,
            parent_obj=UnitParent(id=1),
            relation_name="tags",
            rel_info=rel_with_link,
            relation_payload=[{"entity": {"id": 5}, "link": {"weight": 3}}],
        )

    # link update policy denial
    deny_update = SimpleNamespace(
        can_create=lambda _u, _o: True,
        can_read=lambda _u, _o: True,
        can_update=lambda _u, _o, _i: False,
        can_delete=lambda _u, _o: True,
        filter_list_query=lambda _u, q: q,
    )
    ctx.nested_policy_registry[UnitLink] = cast(CrudPolicy, deny_update)
    session.exec = _exec_row
    with pytest.raises(ForbiddenError):
        await _apply_many_to_many(
            ctx,
            parent_model=UnitParent,
            parent_obj=UnitParent(id=1),
            relation_name="tags",
            rel_info=rel_with_link,
            relation_payload=[{"entity": {"id": 5}, "link": {"weight": 7}}],
        )

    # link_model None without append branch (already present)
    parent_with_existing = SimpleNamespace(id=1, tags=[tag_obj])
    await _apply_many_to_many(
        ctx,
        parent_model=UnitParent,
        parent_obj=cast(SQLModel, parent_with_existing),
        relation_name="tags",
        rel_info=rel_without_link,
        relation_payload=[{"entity": {"id": 5}}],
    )
    assert len(parent_with_existing.tags) == 1


@pytest.mark.asyncio
async def test_apply_many_to_one_without_parent_object_does_not_set_attr(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    session = SimpleNamespace(get=AsyncMock(return_value=UnitChild(id=1)))
    ctx = _ctx(session)
    update_child = AsyncMock()
    monkeypatch.setattr("auen._nested_mutations._update_model_graph", update_child)

    await _apply_relation(
        ctx,
        parent_model=UnitParent,
        parent_obj=None,
        relation_name="author",
        rel_info=_rel(
            name="author",
            direction=RelationshipDirection.MANYTOONE,
            uselist=False,
            local_columns=("parent_fk",),
            target_model=UnitChild,
        ),
        relation_payload={"id": 1},
        parent_scalar_data={},
    )
    assert update_child.await_count == 1
