# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements.  See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership.  The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License.  You may obtain a copy of the License at
#
#   http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied.  See the License for the
# specific language governing permissions and limitations
# under the License.
# ruff: noqa: D102, UP006, UP045
"""Tests for :func:`tvm_ffi.dataclasses.is_dataclass`, :func:`fields`, :func:`replace`."""

from __future__ import annotations

import collections
import dataclasses as _dc
import itertools
import typing
from typing import List, Optional

import pytest
import tvm_ffi
import tvm_ffi.testing
from tvm_ffi.core import MISSING, Object
from tvm_ffi.dataclasses import (
    KW_ONLY,
    Field,
    asdict,
    astuple,
    field,
    fields,
    is_dataclass,
    py_class,
    replace,
)

_counter = itertools.count()


def _k(base: str) -> str:
    return f"testing.compat.{base}_{next(_counter)}"


# ---------------------------------------------------------------------------
# is_dataclass
# ---------------------------------------------------------------------------
class TestIsDataclass:
    def test_c_class_instance(self) -> None:
        p = tvm_ffi.testing.TestIntPair(1, 2)
        assert is_dataclass(p) is True

    def test_c_class_type(self) -> None:
        assert is_dataclass(tvm_ffi.testing.TestIntPair) is True

    def test_py_class_instance(self) -> None:
        @py_class(_k("PC"))
        class PC(Object):
            x: int

        assert is_dataclass(PC(x=1)) is True

    def test_py_class_type(self) -> None:
        @py_class(_k("PCT"))
        class PCT(Object):
            x: int

        assert is_dataclass(PCT) is True

    def test_non_dataclass_types(self) -> None:
        assert is_dataclass(42) is False
        assert is_dataclass("hi") is False
        assert is_dataclass(object()) is False
        assert is_dataclass(int) is False

    def test_plain_object_subclass(self) -> None:
        """Object subclass without @py_class / @c_class shouldn't qualify."""

        class Plain(Object):
            pass

        assert is_dataclass(Plain) is False

    def test_stdlib_dataclass_not_recognised(self) -> None:
        """``common.is_dataclass`` only recognises FFI types."""

        @_dc.dataclass
        class D:
            x: int

        assert is_dataclass(D) is False
        assert is_dataclass(D(1)) is False


# ---------------------------------------------------------------------------
# fields
# ---------------------------------------------------------------------------
class TestFields:
    def test_returns_tuple_of_field(self) -> None:
        p = tvm_ffi.testing.TestIntPair(1, 2)
        result = fields(p)
        assert isinstance(result, tuple)
        assert all(isinstance(f, Field) for f in result)

    def test_c_class_basic(self) -> None:
        fs = fields(tvm_ffi.testing.TestIntPair)
        assert [f.name for f in fs] == ["a", "b"]
        assert all(f.type is int for f in fs)
        # TestIntPair registers no defaults; the Field descriptors reflect that.
        assert all(f.default is MISSING for f in fs)

    def test_c_class_default_value_from_cxx(self) -> None:
        """``fields()`` exposes ``refl::default_value(...)`` from C++.

        ``_TestCxxClassDerived.v_f32`` has ``refl::default_value(8.0f)``.
        """
        fs = fields(tvm_ffi.testing._TestCxxClassDerived)
        by_name = {f.name: f for f in fs}
        assert by_name["v_f32"].default == pytest.approx(8.0)
        assert by_name["v_f32"].default_factory is MISSING
        # v_f64 has no C++ default.
        assert by_name["v_f64"].default is MISSING

    def test_c_class_instance_and_type_equivalent(self) -> None:
        p = tvm_ffi.testing.TestIntPair(1, 2)
        assert fields(p) == fields(tvm_ffi.testing.TestIntPair)

    def test_py_class_with_default(self) -> None:
        @py_class(_k("PCDef"))
        class PCDef(Object):
            x: int
            y: str = field(default="hi")

        fs = fields(PCDef)
        names = [f.name for f in fs]
        assert names == ["x", "y"]
        (fx, fy) = fs
        assert fx.default is MISSING
        assert fx.default_factory is MISSING
        assert fy.default == "hi"
        assert fy.default_factory is MISSING

    def test_py_class_with_default_factory(self) -> None:
        @py_class(_k("PCFact"))
        class PCFact(Object):
            items: List[int] = field(default_factory=list)

        (f,) = fields(PCFact)
        assert f.default is MISSING
        assert f.default_factory is list

    def test_py_class_optional_type_resolved(self) -> None:
        @py_class(_k("PCOpt"))
        class PCOpt(Object):
            v: Optional[int] = field(default=None)

        (f,) = fields(PCOpt)
        # Depending on the Python version, get_type_hints may preserve
        # Optional[int] or normalize it to Union[int, None]; inspect the
        # type's args instead of relying on str() formatting.
        args = typing.get_args(f.type)
        assert int in args and type(None) in args
        assert f.default is None

    def test_inheritance_parent_first(self) -> None:
        @py_class(_k("PCP"))
        class P(Object):
            x: int
            y: str = field(default="p")

        @py_class(_k("PCC"))
        class C(P):
            z: float = field(default=1.5)

        assert [f.name for f in fields(C)] == ["x", "y", "z"]
        assert fields(C)[2].default == 1.5

    def test_c_class_inheritance(self) -> None:
        """@c_class derived types also walk the parent chain."""
        fs = fields(tvm_ffi.testing._TestCxxClassDerived)
        # Parent v_i64/v_i32 come before child v_f64/v_f32.
        assert [f.name for f in fs] == ["v_i64", "v_i32", "v_f64", "v_f32"]

    def test_non_dataclass_raises(self) -> None:
        with pytest.raises(TypeError, match="c_class or py_class"):
            fields(42)

    def test_field_attribute_access(self) -> None:
        """Returned Field descriptors expose ``name`` / ``type`` / ``default``."""
        (fa, _) = fields(tvm_ffi.testing.TestIntPair)
        assert fa.name == "a"
        assert fa.type is int
        assert fa.default is MISSING


# ---------------------------------------------------------------------------
# replace
# ---------------------------------------------------------------------------
class TestReplace:
    def test_c_class_single_field(self) -> None:
        p = tvm_ffi.testing.TestIntPair(1, 2)
        p2 = replace(p, a=10)
        assert (p2.a, p2.b) == (10, 2)
        assert (p.a, p.b) == (1, 2)  # original unchanged
        assert not p.same_as(p2)

    def test_c_class_multiple_fields(self) -> None:
        p = tvm_ffi.testing.TestIntPair(1, 2)
        p2 = replace(p, a=7, b=8)
        assert (p2.a, p2.b) == (7, 8)

    def test_c_class_no_changes_is_copy(self) -> None:
        p = tvm_ffi.testing.TestIntPair(1, 2)
        p2 = replace(p)
        assert (p2.a, p2.b) == (1, 2)
        assert not p.same_as(p2)

    def test_py_class_basic(self) -> None:
        @py_class(_k("PCR"))
        class PCR(Object):
            x: int = field(default=5)
            y: str = field(default="hi")

        obj = PCR()
        obj2 = replace(obj, x=99)
        assert obj2.x == 99
        assert obj2.y == "hi"
        assert obj.x == 5  # original unchanged

    def test_py_class_inherited_fields(self) -> None:
        @py_class(_k("PCRParent"))
        class P(Object):
            x: int

        @py_class(_k("PCRChild"))
        class C(P):
            y: str

        obj = C(x=1, y="a")
        obj2 = replace(obj, x=99)
        assert obj2.x == 99
        assert obj2.y == "a"

    def test_replace_frozen_field(self) -> None:
        """replace() goes through __replace__, which uses the frozen-bypass setter."""
        # TestIntPair.a and .b are read-only (frozen c_class fields).
        p = tvm_ffi.testing.TestIntPair(3, 4)
        p2 = replace(p, a=10)
        assert p2.a == 10
        assert p.a == 3  # still read-only, original untouched


# ---------------------------------------------------------------------------
# asdict
# ---------------------------------------------------------------------------
class TestAsdict:
    def test_c_class_basic(self) -> None:
        p = tvm_ffi.testing.TestIntPair(1, 2)
        assert asdict(p) == {"a": 1, "b": 2}

    def test_py_class_basic(self) -> None:
        @py_class(_k("PCAsdict"))
        class PC(Object):
            x: int
            y: str

        assert asdict(PC(x=1, y="hi")) == {"x": 1, "y": "hi"}

    def test_py_class_nested(self) -> None:
        @py_class(_k("PCInner"))
        class Inner(Object):
            x: int

        @py_class(_k("PCOuter"))
        class Outer(Object):
            a: Inner
            b: Inner

        out = Outer(a=Inner(x=1), b=Inner(x=2))
        assert asdict(out) == {"a": {"x": 1}, "b": {"x": 2}}

    def test_py_class_inheritance(self) -> None:
        @py_class(_k("PCParent"))
        class P(Object):
            x: int

        @py_class(_k("PCChild"))
        class C(P):
            y: str

        assert asdict(C(x=1, y="a")) == {"x": 1, "y": "a"}

    def test_ffi_array_recurses_to_list(self) -> None:
        @py_class(_k("PCWithArray"))
        class PC(Object):
            xs: tvm_ffi.Array

        pc = PC(xs=tvm_ffi.Array([1, 2, 3]))
        result = asdict(pc)
        assert result == {"xs": [1, 2, 3]}
        assert type(result["xs"]) is list

    def test_ffi_list_recurses_to_list(self) -> None:
        @py_class(_k("PCWithList"))
        class PC(Object):
            xs: tvm_ffi.List

        pc = PC(xs=tvm_ffi.List([1, 2, 3]))
        result = asdict(pc)
        assert result == {"xs": [1, 2, 3]}
        assert type(result["xs"]) is list

    def test_ffi_map_recurses_to_dict(self) -> None:
        @py_class(_k("PCWithMap"))
        class PC(Object):
            m: tvm_ffi.Map

        pc = PC(m=tvm_ffi.Map({"a": 1, "b": 2}))
        result = asdict(pc)
        assert result == {"m": {"a": 1, "b": 2}}
        assert type(result["m"]) is dict

    def test_ffi_dict_recurses_to_dict(self) -> None:
        @py_class(_k("PCWithDict"))
        class PC(Object):
            d: tvm_ffi.Dict

        pc = PC(d=tvm_ffi.Dict({"a": 1, "b": 2}))
        result = asdict(pc)
        assert result == {"d": {"a": 1, "b": 2}}
        assert type(result["d"]) is dict

    def test_ffi_array_of_dataclasses(self) -> None:
        @py_class(_k("PCItem"))
        class Item(Object):
            v: int

        @py_class(_k("PCBox"))
        class Box(Object):
            items: tvm_ffi.Array

        box = Box(items=tvm_ffi.Array([Item(v=1), Item(v=2)]))
        assert asdict(box) == {"items": [{"v": 1}, {"v": 2}]}

    def test_ffi_map_of_dataclasses(self) -> None:
        @py_class(_k("PCItem2"))
        class Item(Object):
            v: int

        @py_class(_k("PCBox2"))
        class Box(Object):
            items: tvm_ffi.Map

        box = Box(items=tvm_ffi.Map({"a": Item(v=1), "b": Item(v=2)}))
        assert asdict(box) == {"items": {"a": {"v": 1}, "b": {"v": 2}}}

    def test_dict_factory(self) -> None:
        @py_class(_k("PCDF"))
        class PC(Object):
            x: int
            y: int

        result = asdict(PC(x=1, y=2), dict_factory=collections.OrderedDict)
        assert isinstance(result, collections.OrderedDict)
        assert list(result.items()) == [("x", 1), ("y", 2)]

    def test_dict_factory_recurses(self) -> None:
        @py_class(_k("PCDFI"))
        class Inner(Object):
            v: int

        @py_class(_k("PCDFO"))
        class Outer(Object):
            a: Inner

        result = asdict(Outer(a=Inner(v=5)), dict_factory=collections.OrderedDict)
        assert isinstance(result, collections.OrderedDict)
        assert isinstance(result["a"], collections.OrderedDict)

    def test_default_factory_list_independent(self) -> None:
        """Result must be a fresh ``list``, not aliased to any internal state."""

        @py_class(_k("PCInd"))
        class PC(Object):
            xs: tvm_ffi.Array

        pc = PC(xs=tvm_ffi.Array([1, 2]))
        d = asdict(pc)
        d["xs"].append(99)
        # Mutating the result must not affect the original.
        assert list(pc.xs) == [1, 2]

    def test_type_raises(self) -> None:
        with pytest.raises(TypeError, match="c_class / py_class instances"):
            asdict(tvm_ffi.testing.TestIntPair)  # passing type, not instance

    def test_non_dataclass_raises(self) -> None:
        with pytest.raises(TypeError, match="c_class / py_class instances"):
            asdict(42)
        with pytest.raises(TypeError, match="c_class / py_class instances"):
            asdict([1, 2, 3])

    def test_defaultdict_preserved(self) -> None:
        """``defaultdict`` round-trips with its ``default_factory`` intact.

        Exercises the ``_asdict_inner`` defaultdict branch directly, since
        FFI ``Any`` field storage converts a stored ``defaultdict`` into
        an FFI ``Map`` on readback.  Mirrors stdlib
        ``dataclasses._asdict_inner``'s check on ``type(obj)``.
        """
        from tvm_ffi.dataclasses.common import _asdict_inner  # noqa: PLC0415

        dd = collections.defaultdict(list)
        dd["a"].append(1)
        dd["b"].append(2)
        result = _asdict_inner(dd, dict)
        assert type(result) is collections.defaultdict
        assert result.default_factory is list
        assert dict(result) == {"a": [1], "b": [2]}


# ---------------------------------------------------------------------------
# astuple
# ---------------------------------------------------------------------------
class TestAstuple:
    def test_c_class_basic(self) -> None:
        p = tvm_ffi.testing.TestIntPair(1, 2)
        assert astuple(p) == (1, 2)

    def test_py_class_basic(self) -> None:
        @py_class(_k("PCAstuple"))
        class PC(Object):
            x: int
            y: str

        assert astuple(PC(x=1, y="hi")) == (1, "hi")

    def test_py_class_nested(self) -> None:
        @py_class(_k("PCInnerT"))
        class Inner(Object):
            x: int

        @py_class(_k("PCOuterT"))
        class Outer(Object):
            a: Inner
            b: Inner

        out = Outer(a=Inner(x=1), b=Inner(x=2))
        assert astuple(out) == ((1,), (2,))

    def test_py_class_inheritance(self) -> None:
        @py_class(_k("PCParentT"))
        class P(Object):
            x: int

        @py_class(_k("PCChildT"))
        class C(P):
            y: str

        assert astuple(C(x=1, y="a")) == (1, "a")

    def test_ffi_array_recurses_to_list(self) -> None:
        @py_class(_k("PCArrT"))
        class PC(Object):
            xs: tvm_ffi.Array

        pc = PC(xs=tvm_ffi.Array([1, 2, 3]))
        result = astuple(pc)
        assert result == ([1, 2, 3],)
        assert type(result[0]) is list

    def test_ffi_map_recurses_to_dict(self) -> None:
        @py_class(_k("PCMapT"))
        class PC(Object):
            m: tvm_ffi.Map

        pc = PC(m=tvm_ffi.Map({"a": 1}))
        result = astuple(pc)
        assert result == ({"a": 1},)
        assert type(result[0]) is dict

    def test_tuple_factory(self) -> None:
        @py_class(_k("PCTF"))
        class PC(Object):
            x: int
            y: int

        assert astuple(PC(x=1, y=2), tuple_factory=list) == [1, 2]

    def test_tuple_factory_recurses(self) -> None:
        @py_class(_k("PCTFI"))
        class Inner(Object):
            v: int

        @py_class(_k("PCTFO"))
        class Outer(Object):
            a: Inner

        result = astuple(Outer(a=Inner(v=5)), tuple_factory=list)
        assert result == [[5]]

    def test_type_raises(self) -> None:
        with pytest.raises(TypeError, match="c_class / py_class instances"):
            astuple(tvm_ffi.testing.TestIntPair)

    def test_non_dataclass_raises(self) -> None:
        with pytest.raises(TypeError, match="c_class / py_class instances"):
            astuple(42)
        with pytest.raises(TypeError, match="c_class / py_class instances"):
            astuple([1, 2, 3])

    def test_defaultdict_preserved(self) -> None:
        """``defaultdict`` round-trips with its ``default_factory`` intact."""
        from tvm_ffi.dataclasses.common import _astuple_inner  # noqa: PLC0415

        dd = collections.defaultdict(list)
        dd["a"].append(1)
        dd["b"].append(2)
        result = _astuple_inner(dd, tuple)
        assert type(result) is collections.defaultdict
        assert result.default_factory is list
        assert dict(result) == {"a": [1], "b": [2]}


# ---------------------------------------------------------------------------
# __match_args__
# ---------------------------------------------------------------------------
def _match_args(cls: type) -> object:
    """Read ``__match_args__`` without tripping static attribute checks."""
    return getattr(cls, "__match_args__")


class TestMatchArgs:
    def test_py_class_basic(self) -> None:
        @py_class(_k("MAPy"))
        class PC(Object):
            x: int
            y: str

        assert _match_args(PC) == ("x", "y")

    def test_py_class_init_false_excluded(self) -> None:
        @py_class(_k("MAInitFalse"))
        class PC(Object):
            x: int
            y: int = field(default=0, init=False)

        assert _match_args(PC) == ("x",)

    def test_py_class_kw_only_field_excluded(self) -> None:
        @py_class(_k("MAKwField"))
        class PC(Object):
            x: int
            y: int = field(kw_only=True)

        assert _match_args(PC) == ("x",)

    def test_py_class_kw_only_decorator_excludes_all(self) -> None:
        @py_class(_k("MAKwDeco"), kw_only=True)
        class PC(Object):
            x: int
            y: int

        assert _match_args(PC) == ()

    def test_py_class_kw_only_sentinel(self) -> None:
        @py_class(_k("MAKwSent"))
        class PC(Object):
            x: int
            _: KW_ONLY
            y: int
            z: int

        assert _match_args(PC) == ("x",)

    def test_py_class_inheritance_parent_first(self) -> None:
        @py_class(_k("MAParent"))
        class P(Object):
            x: int
            y: str

        @py_class(_k("MAChild"))
        class C(P):
            z: float

        assert _match_args(P) == ("x", "y")
        assert _match_args(C) == ("x", "y", "z")

    def test_py_class_opt_out(self) -> None:
        @py_class(_k("MAOptOut"), match_args=False)
        class PC(Object):
            x: int
            y: int

        assert "__match_args__" not in PC.__dict__

    def test_py_class_user_defined_preserved(self) -> None:
        @py_class(_k("MAUser"))
        class PC(Object):
            x: int
            y: int
            __match_args__ = ("y",)

        assert _match_args(PC) == ("y",)

    def test_c_class_basic(self) -> None:
        assert _match_args(tvm_ffi.testing.TestIntPair) == ("a", "b")

    def test_c_class_inheritance_parent_first(self) -> None:
        assert _match_args(tvm_ffi.testing._TestCxxClassDerived) == (
            "v_i64",
            "v_i32",
            "v_f64",
            "v_f32",
        )

    def test_tuple_type(self) -> None:
        @py_class(_k("MATupT"))
        class PC(Object):
            x: int

        assert isinstance(_match_args(PC), tuple)
