# SPDX-License-Identifier: MIT

from __future__ import annotations

from datetime import datetime

import pytest

import attr


class TestTransformHook:
    """
    Tests for `attrs(tranform_value_serializer=func)`
    """

    def test_hook_applied(self):
        """
        The transform hook is applied to all attributes.  Types can be missing,
        explicitly set, or annotated.
        """
        results = []

        def hook(cls, attribs):
            attr.resolve_types(cls, attribs=attribs)
            results[:] = [(a.name, a.type) for a in attribs]
            return attribs

        @attr.s(field_transformer=hook)
        class C:
            x = attr.ib()
            y = attr.ib(type=int)
            z: float = attr.ib()

        assert [("x", None), ("y", int), ("z", float)] == results

    def test_hook_applied_auto_attrib(self):
        """
        The transform hook is applied to all attributes and type annotations
        are detected.
        """
        results = []

        def hook(cls, attribs):
            attr.resolve_types(cls, attribs=attribs)
            results[:] = [(a.name, a.type) for a in attribs]
            return attribs

        @attr.s(auto_attribs=True, field_transformer=hook)
        class C:
            x: int
            y: str = attr.ib()

        assert [("x", int), ("y", str)] == results

    def test_hook_applied_modify_attrib(self):
        """
        The transform hook can modify attributes.
        """

        def hook(cls, attribs):
            attr.resolve_types(cls, attribs=attribs)
            return [a.evolve(converter=a.type) for a in attribs]

        @attr.s(auto_attribs=True, field_transformer=hook)
        class C:
            x: int = attr.ib(converter=int)
            y: float

        c = C(x="3", y="3.14")

        assert C(x=3, y=3.14) == c

    def test_hook_remove_field(self):
        """
        It is possible to remove fields via the hook.
        """

        def hook(cls, attribs):
            attr.resolve_types(cls, attribs=attribs)
            return [a for a in attribs if a.type is not int]

        @attr.s(auto_attribs=True, field_transformer=hook)
        class C:
            x: int
            y: float

        assert {"y": 2.7} == attr.asdict(C(2.7))

    def test_hook_add_field(self):
        """
        It is possible to add fields via the hook.
        """

        def hook(cls, attribs):
            a1 = attribs[0]
            a2 = a1.evolve(name="new")
            return [a1, a2]

        @attr.s(auto_attribs=True, field_transformer=hook)
        class C:
            x: int

        assert {"x": 1, "new": 2} == attr.asdict(C(1, 2))

    def test_hook_override_alias(self):
        """
        It is possible to set field alias via hook
        """

        def use_dataclass_names(cls, attribs):
            return [a.evolve(alias=a.name) for a in attribs]

        @attr.s(auto_attribs=True, field_transformer=use_dataclass_names)
        class NameCase:
            public: int
            _private: int
            __dunder__: int

        assert NameCase(public=1, _private=2, __dunder__=3) == NameCase(
            1, 2, 3
        )

    def test_hook_reorder_fields(self):
        """
        It is possible to reorder fields via the hook.
        """

        def hook(cls, attribs):
            return sorted(attribs, key=lambda x: x.metadata["field_order"])

        @attr.s(field_transformer=hook)
        class C:
            x: int = attr.ib(metadata={"field_order": 1})
            y: int = attr.ib(metadata={"field_order": 0})

        assert {"x": 0, "y": 1} == attr.asdict(C(1, 0))

    def test_hook_reorder_fields_before_order_check(self):
        """
        It is possible to reorder fields via the hook before order-based errors are raised.

        Regression test for #1147.
        """

        def hook(cls, attribs):
            return sorted(attribs, key=lambda x: x.metadata["field_order"])

        @attr.s(field_transformer=hook)
        class C:
            x: int = attr.ib(metadata={"field_order": 1}, default=0)
            y: int = attr.ib(metadata={"field_order": 0})

        assert {"x": 0, "y": 1} == attr.asdict(C(1))

    def test_hook_conflicting_defaults_after_reorder(self):
        """
        Raises `ValueError` if attributes with defaults are followed by
        mandatory attributes after the hook reorders fields.

        Regression test for #1147.
        """

        def hook(cls, attribs):
            return sorted(attribs, key=lambda x: x.metadata["field_order"])

        with pytest.raises(ValueError) as e:

            @attr.s(field_transformer=hook)
            class C:
                x: int = attr.ib(metadata={"field_order": 1})
                y: int = attr.ib(metadata={"field_order": 0}, default=0)

        assert (
            "No mandatory attributes allowed after an attribute with a "
            "default value or factory.  Attribute in question: Attribute"
            "(name='x', default=NOTHING, validator=None, repr=True, "
            "eq=True, eq_key=None, order=True, order_key=None, "
            "hash=None, init=True, "
            "metadata=mappingproxy({'field_order': 1}), type='int', converter=None, "
            "kw_only=False, inherited=False, on_setattr=None, alias='x')",
        ) == e.value.args

    def test_hook_with_inheritance(self):
        """
        The hook receives all fields from base classes.
        """

        def hook(cls, attribs):
            assert ["x", "y"] == [a.name for a in attribs]
            # Remove Base' "x"
            return attribs[1:]

        @attr.s(auto_attribs=True)
        class Base:
            x: int

        @attr.s(auto_attribs=True, field_transformer=hook)
        class Sub(Base):
            y: int

        assert {"y": 2} == attr.asdict(Sub(2))

    def test_attrs_attrclass(self):
        """
        The list of attrs returned by a field_transformer is converted to
        "AttrsClass" again.

        Regression test for #821.
        """

        @attr.s(auto_attribs=True, field_transformer=lambda c, a: list(a))
        class C:
            x: int

        fields_type = type(attr.fields(C))
        assert "CAttributes" == fields_type.__name__
        assert issubclass(fields_type, tuple)

    def test_hook_generator(self):
        """
        field_transfromers can be a generators.

        Regression test for #1416.
        """

        def hook(cls, attribs):
            yield from attribs

        @attr.s(auto_attribs=True, field_transformer=hook)
        class Base:
            x: int

        assert ["x"] == [a.name for a in attr.fields(Base)]

    def test_hook_alias_available(self):
        """
        The field_transformer receives attributes with default aliases
        already resolved, not None.

        Regression test for #1479.
        """
        seen = []

        def hook(cls, attribs):
            seen[:] = [(a.name, a.alias, a.alias_is_default) for a in attribs]
            return attribs

        @attr.s(auto_attribs=True, field_transformer=hook)
        class C:
            _private: int
            _explicit: int = attr.ib(alias="_explicit")
            public: int

        assert [
            ("_private", "private", True),
            ("_explicit", "_explicit", False),
            ("public", "public", True),
        ] == seen

    def test_hook_evolve_name_updates_auto_alias(self):
        """
        When a field_transformer evolves a field's name, the alias is
        automatically updated if it was auto-generated.

        Regression test for #1479.
        """

        def hook(cls, attribs):
            return [a.evolve(name="renamed") for a in attribs]

        @attr.s(auto_attribs=True, field_transformer=hook)
        class C:
            _original: int

        f = attr.fields(C).renamed

        assert "renamed" == f.alias
        assert f.alias_is_default is True

    def test_hook_evolve_name_keeps_explicit_alias(self):
        """
        When a field_transformer evolves a field's name but the field had
        an explicit alias, the alias is preserved.

        Regression test for #1479.
        """

        def hook(cls, attribs):
            return [a.evolve(name="renamed") for a in attribs]

        @attr.s(auto_attribs=True, field_transformer=hook)
        class C:
            original: int = attr.ib(alias="my_alias")

        f = attr.fields(C).renamed

        assert "my_alias" == f.alias
        assert f.alias_is_default is False

    def test_hook_new_field_without_alias(self):
        """
        When a field_transformer adds a brand-new field without setting an
        alias, the post-transformer alias resolution fills it in.

        Regression test for #1479.
        """

        def hook(cls, attribs):
            return [
                *list(attribs),
                attr.Attribute(
                    name="_extra",
                    default=0,
                    validator=None,
                    repr=True,
                    cmp=None,
                    hash=None,
                    init=True,
                    metadata={},
                    type=int,
                    converter=None,
                    kw_only=False,
                    eq=True,
                    eq_key=None,
                    order=True,
                    order_key=None,
                    on_setattr=None,
                    alias=None,
                    inherited=False,
                ),
            ]

        @attr.s(auto_attribs=True, field_transformer=hook)
        class C:
            x: int

        f = attr.fields(C)._extra

        assert "extra" == f.alias
        assert f.alias_is_default is True

    def test_hook_explicit_alias_matching_default(self):
        """
        When a user explicitly sets an alias that happens to equal the
        auto-generated default, alias_is_default is still False.

        Regression test for #1479.
        """

        @attr.s(auto_attribs=True)
        class C:
            _private: int = attr.ib(alias="private")

        f = attr.fields(C)._private

        assert "private" == f.alias
        assert f.alias_is_default is False

    def test_hook_evolve_alias_sets_not_default(self):
        """
        When a field_transformer uses evolve() to set an explicit alias,
        alias_is_default becomes False.

        Regression test for #1479.
        """

        def hook(cls, attribs):
            return [a.evolve(alias="custom") for a in attribs]

        @attr.s(auto_attribs=True, field_transformer=hook)
        class C:
            x: int

        f = attr.fields(C).x

        assert "custom" == f.alias
        assert f.alias_is_default is False


class TestAsDictHook:
    def test_asdict(self):
        """
        asdict() calls the hooks in attrs classes and in other datastructures
        like lists or dicts.
        """

        def hook(inst, a, v):
            if isinstance(v, datetime):
                return v.isoformat()
            return v

        @attr.dataclass
        class Child:
            x: datetime
            y: list[datetime]

        @attr.dataclass
        class Parent:
            a: Child
            b: list[Child]
            c: dict[str, Child]
            d: dict[str, datetime]

        inst = Parent(
            a=Child(1, [datetime(2020, 7, 1)]),
            b=[Child(2, [datetime(2020, 7, 2)])],
            c={"spam": Child(3, [datetime(2020, 7, 3)])},
            d={"eggs": datetime(2020, 7, 4)},
        )

        result = attr.asdict(inst, value_serializer=hook)
        assert {
            "a": {"x": 1, "y": ["2020-07-01T00:00:00"]},
            "b": [{"x": 2, "y": ["2020-07-02T00:00:00"]}],
            "c": {"spam": {"x": 3, "y": ["2020-07-03T00:00:00"]}},
            "d": {"eggs": "2020-07-04T00:00:00"},
        } == result

    def test_asdict_calls(self):
        """
        The correct instances and attribute names are passed to the hook.
        """
        calls = []

        def hook(inst, a, v):
            calls.append((inst, a.name if a else a, v))
            return v

        @attr.dataclass
        class Child:
            x: int

        @attr.dataclass
        class Parent:
            a: Child
            b: list[Child]
            c: dict[str, Child]

        inst = Parent(a=Child(1), b=[Child(2)], c={"spam": Child(3)})

        attr.asdict(inst, value_serializer=hook)
        assert [
            (inst, "a", inst.a),
            (inst.a, "x", inst.a.x),
            (inst, "b", inst.b),
            (inst.b[0], "x", inst.b[0].x),
            (inst, "c", inst.c),
            (None, None, "spam"),
            (inst.c["spam"], "x", inst.c["spam"].x),
        ] == calls
