import cattrs
from attrs import define
from django.test import TestCase
from django_readers import specs
from rich import print as rprint

from mantle.db import default_converter
from mantle.readers import to_spec, overrides, spec

from .models import SimpleBookmark, Team, User, Project


@define
class SimpleBookmarkAttrs:
    url: str
    comment: str
    favourite: bool


@define
class TeamAttrs:
    name: str


@define
class UserAttrs:
    username: str
    team: TeamAttrs


class ToSpecBasicTypesTest(TestCase):
    """Test to_spec() with basic types only."""

    def test_simple_attrs_class_generates_spec(self):
        """to_spec() should generate a list of field names for basic types."""
        spec = to_spec(SimpleBookmarkAttrs)

        self.assertEqual(spec, ["url", "comment", "favourite"])

    def test_requires_attrs_class(self):
        """to_spec() should raise TypeError if not given an attrs class."""

        class NotAttrs:
            pass

        with self.assertRaises(TypeError) as cm:
            to_spec(NotAttrs)

        self.assertIn("attrs class", str(cm.exception))


class SimpleBookmarkIntegrationTest(TestCase):
    """Test that generated spec works with actual Django models."""

    def setUp(self):
        self.bookmark = SimpleBookmark.objects.create(
            url="https://example.com", comment="A great example", favourite=True
        )

    def test_spec_fields_match_model_fields(self):
        """Generated spec should match model field names."""
        spec = to_spec(SimpleBookmarkAttrs)

        # Verify all spec fields exist on the model
        for field_name in spec:
            self.assertTrue(
                hasattr(self.bookmark, field_name), f"Model missing field: {field_name}"
            )

    def test_can_retrieve_values_from_model(self):
        """Should be able to get all spec fields from model instance."""
        spec = to_spec(SimpleBookmarkAttrs)

        # This simulates what django-readers project() would do
        data = {field_name: getattr(self.bookmark, field_name) for field_name in spec}

        self.assertEqual(data["url"], "https://example.com")
        self.assertEqual(data["comment"], "A great example")
        self.assertEqual(data["favourite"], True)

    def test_can_structure_to_attrs_instance(self):
        """Should be able to convert model data to attrs instance."""
        spec = to_spec(SimpleBookmarkAttrs)
        data = {field_name: getattr(self.bookmark, field_name) for field_name in spec}

        # Use cattrs to structure the dict into attrs instance
        attrs_instance = cattrs.structure(data, SimpleBookmarkAttrs)

        self.assertIsInstance(attrs_instance, SimpleBookmarkAttrs)
        self.assertEqual(attrs_instance.url, "https://example.com")
        self.assertEqual(attrs_instance.comment, "A great example")
        self.assertEqual(attrs_instance.favourite, True)

    def test_core_mantle_flow(self):
        """
        * Process spec into a (prepare, project) pair.
            * WIP: debug_print spec
        * Prepare QuerySet.
            * WIP: (rich) print .query
        * Make the ORM query and project (to dicts)
        * Structure as Attrs class.
        """
        # Step 1: Generate spec from attrs class
        spec = to_spec(SimpleBookmarkAttrs)
        rprint("[bold]Generated spec:[/bold]", spec)

        # Step 2: Process spec into (prepare, project) pair
        prepare, project = specs.process(spec)

        # Step 3: Prepare QuerySet (applies prefetch_related optimizations)
        queryset = SimpleBookmark.objects.all()
        prepared_qs = prepare(queryset)

        # Debug: Print the SQL query
        rprint("[bold]SQL Query:[/bold]")
        rprint(str(prepared_qs.query))

        # Step 4: Execute query and project to dicts
        bookmark_dicts = [project(instance) for instance in prepared_qs]

        rprint("[bold]Projected dicts:[/bold]", bookmark_dicts)

        # Step 5: Structure dicts as attrs instances using cattrs
        bookmark_attrs_list = [
            cattrs.structure(data, SimpleBookmarkAttrs) for data in bookmark_dicts
        ]

        # Assertions
        self.assertEqual(len(bookmark_attrs_list), 1)

        bookmark_attrs = bookmark_attrs_list[0]
        self.assertIsInstance(bookmark_attrs, SimpleBookmarkAttrs)
        self.assertEqual(bookmark_attrs.url, "https://example.com")
        self.assertEqual(bookmark_attrs.comment, "A great example")
        self.assertEqual(bookmark_attrs.favourite, True)


class NestedAttrsTest(TestCase):
    """Test to_spec() with nested attrs classes (ForeignKey relationships)."""

    def test_nested_attrs_class_generates_nested_spec(self):
        """to_spec() should generate nested dict for attrs class attributes."""
        spec = to_spec(UserAttrs)

        expected = ["username", {"team": ["name"]}]
        self.assertEqual(spec, expected)


class UserTeamIntegrationTest(TestCase):
    """Test that nested specs work with ForeignKey relationships."""

    def setUp(self):
        self.team = Team.objects.create(name="Engineering")
        self.user = User.objects.create(username="alice", team=self.team)

    def test_core_mantle_flow_with_nested_relationship(self):
        """
        Full flow test with ForeignKey relationship.
        Verify we can access team.name on the final attrs instance.
        """
        # Step 1: Generate spec from attrs class with nested relationship
        spec = to_spec(UserAttrs)
        rprint("[bold]Generated nested spec:[/bold]", spec)

        # Step 2: Process spec into (prepare, project) pair
        prepare, project = specs.process(spec)

        # Step 3: Prepare QuerySet (should use select_related for team)
        with self.assertNumQueries(0):
            queryset = User.objects.all()
            prepared_qs = prepare(queryset)

        # Debug: Print the SQL query
        rprint("[bold]SQL Query:[/bold]")
        rprint(str(prepared_qs.query))

        # Step 4: Execute query (django-readers uses 2 queries: main + prefetch for FK)
        # This is expected behavior - django-readers uses prefetch even for ForeignKey
        with self.assertNumQueries(2):
            user_instances = list(prepared_qs)

        # Step 5: Project to dicts (should be 0 queries - data already fetched)
        with self.assertNumQueries(0):
            user_dicts = [project(instance) for instance in user_instances]

        rprint("[bold]Projected dicts:[/bold]", user_dicts)

        # Step 6: Structure dicts as attrs instances using cattrs
        user_attrs_list = [cattrs.structure(data, UserAttrs) for data in user_dicts]

        # Assertions
        self.assertEqual(len(user_attrs_list), 1)

        user_attrs = user_attrs_list[0]
        self.assertIsInstance(user_attrs, UserAttrs)
        self.assertEqual(user_attrs.username, "alice")

        # Verify nested team access
        self.assertIsInstance(user_attrs.team, TeamAttrs)
        self.assertEqual(user_attrs.team.name, "Engineering")


class NestedListTest(TestCase):
    """Test to_spec() with list[AttrsClass] (reverse FK / M2M)."""

    def test_list_of_attrs_generates_nested_spec(self):
        """to_spec() should handle list[AttrsClass] for reverse relationships."""

        # Define attrs classes locally to keep naming clean
        @define
        class UserSimpleAttrs:
            username: str

        @define
        class TeamWithUsersAttrs:
            name: str
            users: list[UserSimpleAttrs]

        spec = to_spec(TeamWithUsersAttrs)

        expected = ["name", {"users": ["username"]}]
        self.assertEqual(spec, expected)


class TeamUsersIntegrationTest(TestCase):
    """Test that list specs work with reverse FK relationships."""

    def setUp(self):
        self.team = Team.objects.create(name="Engineering")
        self.user1 = User.objects.create(username="alice", team=self.team)
        self.user2 = User.objects.create(username="bob", team=self.team)
        # Create another team with no users
        self.empty_team = Team.objects.create(name="Marketing")

    def test_core_mantle_flow_with_reverse_fk(self):
        """
        Full flow test with reverse FK relationship (Team -> Users).
        Verify we can iterate over team.users and access each user.username.
        """

        # Define attrs classes locally
        @define
        class UserSimpleAttrs:
            username: str

        @define
        class TeamWithUsersAttrs:
            name: str
            users: list[UserSimpleAttrs]

        # Step 1: Generate spec from attrs class with list relationship
        spec = to_spec(TeamWithUsersAttrs)
        rprint("[bold]Generated list spec:[/bold]", spec)

        # Step 2: Process spec into (prepare, project) pair
        prepare, project = specs.process(spec)

        # Step 3: Prepare QuerySet
        with self.assertNumQueries(0):
            queryset = Team.objects.all()
            prepared_qs = prepare(queryset)

        # Debug: Print the SQL query
        rprint("[bold]SQL Query:[/bold]")
        rprint(str(prepared_qs.query))

        # Step 4: Execute query (should use prefetch for reverse FK)
        # Expecting 2 queries: main team query + prefetch for users
        with self.assertNumQueries(2):
            team_instances = list(prepared_qs)

        # Step 5: Project to dicts (should be 0 queries - data already fetched)
        with self.assertNumQueries(0):
            team_dicts = [project(instance) for instance in team_instances]

        rprint("[bold]Projected dicts:[/bold]", team_dicts)

        # Step 6: Structure dicts as attrs instances using cattrs
        team_attrs_list = [
            cattrs.structure(data, TeamWithUsersAttrs) for data in team_dicts
        ]

        # Assertions
        self.assertEqual(len(team_attrs_list), 2)

        # Find the Engineering team
        eng_team = next(t for t in team_attrs_list if t.name == "Engineering")

        self.assertIsInstance(eng_team, TeamWithUsersAttrs)
        self.assertEqual(eng_team.name, "Engineering")

        # Verify list of users
        self.assertIsInstance(eng_team.users, list)
        self.assertEqual(len(eng_team.users), 2)

        # Verify each user in the list
        usernames = sorted([u.username for u in eng_team.users])
        self.assertEqual(usernames, ["alice", "bob"])

        # Verify type safety on nested list items
        for user in eng_team.users:
            self.assertIsInstance(user, UserSimpleAttrs)

        # Verify empty list for Marketing team
        mkt_team = next(t for t in team_attrs_list if t.name == "Marketing")
        self.assertEqual(len(mkt_team.users), 0)


class OverridesTest(TestCase):
    """Test @overrides decorator for custom field logic."""

    def test_overrides_decorator_stores_in_metadata(self):
        """@overrides should store pairs as class attribute."""
        from django_readers import pairs

        upper_name_pair = pairs.field("name", transform_value=lambda v: v.upper())

        @overrides({"name": upper_name_pair})
        @define
        class TeamWithUpperName:
            name: str

        # Check that overrides were stored as class attribute
        self.assertTrue(hasattr(TeamWithUpperName, "__mantle_overrides__"))
        self.assertIn("name", TeamWithUpperName.__mantle_overrides__)
        self.assertEqual(
            TeamWithUpperName.__mantle_overrides__["name"], upper_name_pair
        )

    def test_overrides_generates_dict_with_pair(self):
        """to_spec() should output {field: (prepare, project)} for overrides."""
        from django_readers import pairs

        upper_name_pair = pairs.field("name", transform_value=lambda v: v.upper())

        @overrides({"name": upper_name_pair})
        @define
        class TeamWithUpperName:
            name: str

        spec = to_spec(TeamWithUpperName)

        # Should be a dict with the pair as value, not a string
        self.assertEqual(len(spec), 1)
        self.assertIsInstance(spec[0], dict)
        self.assertIn("name", spec[0])
        self.assertEqual(spec[0]["name"], upper_name_pair)

    def test_overrides_invalid_field_raises_error(self):
        """@overrides should error if field doesn't exist in attrs class."""
        from django_readers import pairs

        fake_pair = pairs.field("nonexistent")

        with self.assertRaises(ValueError) as cm:

            @overrides({"nonexistent": fake_pair})
            @define
            class TeamBad:
                name: str

        self.assertIn("nonexistent", str(cm.exception))

    def test_overrides_on_non_attrs_class_raises_error(self):
        """@overrides should error if applied to non-attrs class."""
        from django_readers import pairs

        with self.assertRaises(TypeError) as cm:

            @overrides({"name": pairs.field("name")})
            class NotAttrs:
                pass

        self.assertIn("attrs classes", str(cm.exception))

    def test_overrides_allow_missing_permits_nonexistent_field(self):
        """@overrides with allow_missing should permit fields not in attrs class."""
        from django_readers import pairs

        # This should not raise an error even though "source_name" doesn't exist
        @overrides({"source_name": pairs.field("name")}, allow_missing=["source_name"])
        @define
        class TeamWithMappedField:
            name: str

        # Verify decorator succeeded
        self.assertTrue(hasattr(TeamWithMappedField, "__mantle_overrides__"))
        self.assertIn("source_name", TeamWithMappedField.__mantle_overrides__)

    def test_overrides_allow_missing_still_validates_other_fields(self):
        """@overrides should still validate fields not in allow_missing list."""
        from django_readers import pairs

        with self.assertRaises(ValueError) as cm:

            @overrides(
                {
                    "source_name": pairs.field("name"),  # Allowed
                    "invalid_field": pairs.field("bad"),  # Not allowed
                },
                allow_missing=["source_name"],
            )
            @define
            class TeamBad:
                name: str

        self.assertIn("invalid_field", str(cm.exception))

    def test_overrides_with_none_skips_field(self):
        """@overrides with None value should skip field in spec."""

        @overrides({"comment": None})
        @define
        class BookmarkWithoutComment:
            url: str
            comment: str
            favourite: bool

        spec = to_spec(BookmarkWithoutComment)

        # comment field should be skipped
        self.assertEqual(spec, ["url", "favourite"])

    def test_overrides_with_none_and_pair_combined(self):
        """@overrides can combine None (skip) with pairs (custom)."""
        from django_readers import pairs

        upper_url_pair = pairs.field("url", transform_value=lambda v: v.upper())

        @overrides({"url": upper_url_pair, "comment": None})
        @define
        class BookmarkCustom:
            url: str
            comment: str
            favourite: bool

        spec = to_spec(BookmarkCustom)

        # Should have custom pair for url, skip comment, auto-generate favourite
        self.assertEqual(len(spec), 2)
        self.assertIsInstance(spec[0], dict)
        self.assertIn("url", spec[0])
        self.assertEqual(spec[0]["url"], upper_url_pair)
        self.assertEqual(spec[1], "favourite")


class OverridesIntegrationTest(TestCase):
    """Test overrides in full mantle flow."""

    def setUp(self):
        self.team = Team.objects.create(name="engineering")

    def test_override_with_transformation(self):
        """Override should transform field value (e.g., uppercase)."""
        from django_readers import pairs

        upper_name_pair = pairs.field("name", transform_value=lambda v: v.upper())

        @overrides({"name": upper_name_pair})
        @define
        class TeamUpperName:
            name: str

        # Generate spec and process
        spec = to_spec(TeamUpperName)
        rprint("[bold]Override spec (uppercase):[/bold]", spec)

        prepare, project = specs.process(spec)

        # Execute query
        queryset = prepare(Team.objects.all())
        team_dicts = [project(instance) for instance in queryset]

        rprint("[bold]Projected with override:[/bold]", team_dicts)

        # Structure
        teams = [cattrs.structure(data, TeamUpperName) for data in team_dicts]

        # Verify transformation was applied
        self.assertEqual(teams[0].name, "ENGINEERING")

    def test_override_computed_field_not_in_model_raises_orm_error(self):
        """Override referencing non-existent model field should raise Django error."""
        from django.core.exceptions import FieldDoesNotExist
        from django_readers import pairs

        # Create a pair that references a field that doesn't exist
        fake_pair = pairs.field("nonexistent_field")

        @overrides({"computed": fake_pair})
        @define
        class TeamWithComputed:
            computed: str

        spec = to_spec(TeamWithComputed)
        prepare, project = specs.process(spec)

        queryset = prepare(Team.objects.all())

        # Should raise FieldDoesNotExist when trying to execute the query
        with self.assertRaises(FieldDoesNotExist):
            list(queryset)

    def test_override_type_mismatch_raises_cattrs_error(self):
        """Override producing incompatible type should raise ClassValidationError."""
        from cattrs.errors import ClassValidationError

        # Create a custom object that can't be converted to str
        class Unconvertible:
            def __str__(self):
                raise RuntimeError("Cannot convert to string")

        # Create a pair that returns this unconvertible object
        def fake_prepare(qs):
            return qs

        def fake_project(instance):
            return Unconvertible()

        @overrides({"name": (fake_prepare, fake_project)})
        @define
        class TeamWrongType:
            name: str  # Declared as str but pair returns Unconvertible

        spec = to_spec(TeamWrongType)
        prepare, project = specs.process(spec)

        queryset = prepare(Team.objects.all())
        team_dicts = [project(instance) for instance in queryset]

        # cattrs wraps the error in ClassValidationError when structuring fails
        with self.assertRaises((ClassValidationError, TypeError, RuntimeError)):
            [cattrs.structure(data, TeamWrongType) for data in team_dicts]

    def test_override_bypasses_auto_generation(self):
        """Override should prevent auto-generation logic from running."""
        from django_readers import pairs

        # Without override, nested Team would auto-generate
        @define
        class UserSimple:
            username: str
            team: TeamAttrs

        normal_spec = to_spec(UserSimple)
        # Should have nested dict: {'team': ['name']}
        self.assertEqual(normal_spec, ["username", {"team": ["name"]}])

        # With override, should use pair instead
        custom_pair = pairs.field("team_id")

        @overrides({"team": custom_pair})
        @define
        class UserWithOverride:
            username: str
            team: TeamAttrs  # Type still says TeamAttrs, but override changes behavior

        override_spec = to_spec(UserWithOverride)
        # Should have pair instead of nested spec
        self.assertEqual(override_spec, ["username", {"team": custom_pair}])

    def test_override_with_none_skips_field_in_flow(self):
        """None override should exclude field from database query."""
        # Setup test data
        SimpleBookmark.objects.create(
            url="https://example.com", comment="Secret comment", favourite=True
        )

        @overrides({"comment": None})
        @define
        class BookmarkWithoutComment:
            url: str
            comment: str  # Field exists in attrs but will be skipped in spec
            favourite: bool

        # Generate spec
        spec = to_spec(BookmarkWithoutComment)
        self.assertEqual(spec, ["url", "favourite"])

        # Process and execute
        prepare, project = specs.process(spec)
        queryset = prepare(SimpleBookmark.objects.all())
        bookmark_dicts = [project(instance) for instance in queryset]

        # Dict should not have comment field
        self.assertEqual(len(bookmark_dicts), 1)
        self.assertNotIn("comment", bookmark_dicts[0])
        self.assertEqual(bookmark_dicts[0]["url"], "https://example.com")
        self.assertEqual(bookmark_dicts[0]["favourite"], True)


class OptionalTypesTest(TestCase):
    """Test to_spec() with Optional types (Optional[T] and T | None)."""

    def test_optional_basic_type_generates_spec(self):
        """to_spec() should handle Optional[str] and str | None."""
        from typing import Optional

        @define
        class BookmarkWithOptionalComment:
            url: str
            comment: Optional[str]
            favourite: bool

        spec = to_spec(BookmarkWithOptionalComment)
        # Optional basic type should be treated same as required
        self.assertEqual(spec, ["url", "comment", "favourite"])

    def test_optional_with_union_syntax(self):
        """to_spec() should handle Type | None syntax (Python 3.10+)."""

        @define
        class BookmarkWithUnionOptional:
            url: str
            comment: str | None
            favourite: bool

        spec = to_spec(BookmarkWithUnionOptional)
        # Union with None should be treated same as required
        self.assertEqual(spec, ["url", "comment", "favourite"])

    def test_optional_nested_attrs_generates_nested_spec(self):
        """to_spec() should handle Optional[AttrsClass] for nullable ForeignKeys."""
        from typing import Optional

        @define
        class ProjectAttrs:
            title: str
            manager: Optional[UserAttrs]

        spec = to_spec(ProjectAttrs)

        expected = ["title", {"manager": ["username", {"team": ["name"]}]}]
        self.assertEqual(spec, expected)

    def test_optional_with_union_syntax_nested(self):
        """to_spec() should handle AttrsClass | None syntax."""

        @define
        class ProjectAttrs:
            title: str
            manager: UserAttrs | None

        spec = to_spec(ProjectAttrs)

        expected = ["title", {"manager": ["username", {"team": ["name"]}]}]
        self.assertEqual(spec, expected)

    def test_optional_list_is_supported(self):
        """Optional[list[T]] should work - unwraps to list[T]."""
        from typing import Optional

        @define
        class TeamWithOptionalUsers:
            name: str
            users: Optional[list[UserAttrs]]

        # This should work - Optional[list[T]] unwraps to list[T]
        spec = to_spec(TeamWithOptionalUsers)
        expected = ["name", {"users": ["username", {"team": ["name"]}]}]
        self.assertEqual(spec, expected)


class OptionalIntegrationTest(TestCase):
    """Test optional types in full mantle flow."""

    def setUp(self):
        self.team = Team.objects.create(name="Engineering")
        self.user = User.objects.create(username="alice", team=self.team)
        # Project with manager
        self.project_with_manager = Project.objects.create(
            title="Project Alpha", manager=self.user
        )
        # Project without manager (null FK)
        self.project_without_manager = Project.objects.create(
            title="Project Beta", manager=None
        )

    def test_core_mantle_flow_with_optional_present(self):
        """Test full flow when optional FK is present (not None)."""
        from typing import Optional

        @define
        class ProjectAttrs:
            title: str
            manager: Optional[UserAttrs]

        # Generate spec and process
        spec = to_spec(ProjectAttrs)
        rprint("[bold]Optional spec:[/bold]", spec)

        prepare, project = specs.process(spec)

        # Prepare QuerySet
        with self.assertNumQueries(0):
            queryset = Project.objects.filter(title="Project Alpha")
            prepared_qs = prepare(queryset)

        # Execute query: Main + prefetch for manager + prefetch for team
        with self.assertNumQueries(3):
            project_instances = list(prepared_qs)

        # Project to dicts
        with self.assertNumQueries(0):
            project_dicts = [project(instance) for instance in project_instances]

        rprint("[bold]Projected with optional (present):[/bold]", project_dicts)

        # Structure
        projects = [cattrs.structure(data, ProjectAttrs) for data in project_dicts]

        # Assertions
        self.assertEqual(len(projects), 1)
        proj = projects[0]
        self.assertEqual(proj.title, "Project Alpha")
        self.assertIsNotNone(proj.manager)
        self.assertEqual(proj.manager.username, "alice")
        self.assertEqual(proj.manager.team.name, "Engineering")

    def test_core_mantle_flow_with_optional_absent(self):
        """Test full flow when optional FK is None."""
        from typing import Optional

        @define
        class ProjectAttrs:
            title: str
            manager: Optional[UserAttrs]

        # Generate spec and process
        spec = to_spec(ProjectAttrs)

        prepare, project = specs.process(spec)

        # Prepare QuerySet
        queryset = Project.objects.filter(title="Project Beta")
        prepared_qs = prepare(queryset)

        # Execute query
        project_instances = list(prepared_qs)

        # Project to dicts
        project_dicts = [project(instance) for instance in project_instances]

        rprint("[bold]Projected with optional (absent):[/bold]", project_dicts)

        # Structure
        projects = [cattrs.structure(data, ProjectAttrs) for data in project_dicts]

        # Assertions
        self.assertEqual(len(projects), 1)
        proj = projects[0]
        self.assertEqual(proj.title, "Project Beta")
        self.assertIsNone(proj.manager)

    def test_union_syntax_with_none_value(self):
        """Test AttrsClass | None syntax with None value."""

        @define
        class ProjectAttrs:
            title: str
            manager: UserAttrs | None

        spec = to_spec(ProjectAttrs)
        prepare, project = specs.process(spec)

        queryset = Project.objects.filter(title="Project Beta")
        prepared_qs = prepare(queryset)
        project_instances = list(prepared_qs)
        project_dicts = [project(instance) for instance in project_instances]
        projects = [cattrs.structure(data, ProjectAttrs) for data in project_dicts]

        proj = projects[0]
        self.assertEqual(proj.title, "Project Beta")
        self.assertIsNone(proj.manager)


class SpecDecoratorTest(TestCase):
    """Test @spec decorator for custom manual specs."""

    def test_spec_decorator_bypasses_auto_generation(self):
        """@spec should allow providing a complete custom spec."""
        from django_readers import pairs

        # Create a custom spec with a transformation
        custom_spec = [
            {"name": pairs.field("name", transform_value=lambda v: v.upper())}
        ]

        @spec(custom_spec)
        @define
        class TeamCustomSpec:
            name: str

        # to_spec should return the custom spec, not auto-generate
        result_spec = to_spec(TeamCustomSpec)
        self.assertEqual(result_spec, custom_spec)

    def test_spec_decorator_works_in_full_flow(self):
        """@spec should work in the full mantle flow."""
        from django_readers import pairs

        # Setup test data
        Team.objects.create(name="engineering")

        # Create a custom spec that uppercases the name
        custom_spec = [
            {"name": pairs.field("name", transform_value=lambda v: v.upper())}
        ]

        @spec(custom_spec)
        @define
        class TeamCustomSpec:
            name: str

        # Full flow
        result_spec = to_spec(TeamCustomSpec)
        prepare, project = specs.process(result_spec)

        queryset = prepare(Team.objects.all())
        team_dicts = [project(instance) for instance in queryset]
        teams = [cattrs.structure(data, TeamCustomSpec) for data in team_dicts]

        # Verify the custom spec was used (name should be uppercased)
        self.assertEqual(teams[0].name, "ENGINEERING")

    def test_spec_decorator_on_non_attrs_raises_error(self):
        """@spec should error if applied to non-attrs class."""
        with self.assertRaises(TypeError) as cm:

            @spec(["field1", "field2"])
            class NotAttrs:
                pass

        self.assertIn("attrs classes", str(cm.exception))


class DateTimeTypesTest(TestCase):
    """Test to_spec() with date, datetime, time, timedelta, and UUID types."""

    def test_date_type_generates_spec(self):
        """to_spec() should handle datetime.date type."""
        from datetime import date

        @define
        class EventWithDate:
            name: str
            event_date: date

        spec = to_spec(EventWithDate)
        self.assertEqual(spec, ["name", "event_date"])

    def test_datetime_type_generates_spec(self):
        """to_spec() should handle datetime.datetime type."""
        from datetime import datetime

        @define
        class EventWithDateTime:
            name: str
            created_at: datetime

        spec = to_spec(EventWithDateTime)
        self.assertEqual(spec, ["name", "created_at"])

    def test_time_type_generates_spec(self):
        """to_spec() should handle datetime.time type."""
        from datetime import time

        @define
        class EventWithTime:
            name: str
            start_time: time

        spec = to_spec(EventWithTime)
        self.assertEqual(spec, ["name", "start_time"])

    def test_timedelta_type_generates_spec(self):
        """to_spec() should handle datetime.timedelta type."""
        from datetime import timedelta

        @define
        class EventWithDuration:
            name: str
            duration: timedelta

        spec = to_spec(EventWithDuration)
        self.assertEqual(spec, ["name", "duration"])

    def test_uuid_type_generates_spec(self):
        """to_spec() should handle uuid.UUID type."""
        from uuid import UUID

        @define
        class EventWithUUID:
            name: str
            event_id: UUID

        spec = to_spec(EventWithUUID)
        self.assertEqual(spec, ["name", "event_id"])

    def test_all_datetime_types_combined(self):
        """to_spec() should handle all datetime types together."""
        from datetime import date, datetime, time, timedelta
        from uuid import UUID

        @define
        class EventComplete:
            name: str
            event_date: date
            created_at: datetime
            start_time: time
            duration: timedelta
            event_id: UUID

        spec = to_spec(EventComplete)
        self.assertEqual(
            spec,
            ["name", "event_date", "created_at", "start_time", "duration", "event_id"],
        )

    def test_list_of_dates(self):
        """to_spec() should handle list[date] (the original error case)."""
        from datetime import date

        @define
        class EventWithDates:
            name: str
            due_dates: list[date]

        spec = to_spec(EventWithDates)
        # list of basic types should just be a field name, not nested
        self.assertEqual(spec, ["name", "due_dates"])


class DateTimeIntegrationTest(TestCase):
    """Test datetime types in full mantle flow."""

    def setUp(self):
        from datetime import date, datetime, time, timedelta
        from uuid import uuid4

        from django.utils import timezone

        from .models import Event

        self.event = Event.objects.create(
            name="Conference",
            event_date=date(2026, 3, 15),
            created_at=timezone.make_aware(
                datetime(2026, 1, 1, 10, 30, 0), timezone.UTC
            ),
            start_time=time(9, 0, 0),
            duration=timedelta(hours=2),
            event_id=uuid4(),
        )

    def test_core_mantle_flow_with_datetime_types(self):
        """Test full flow with date, datetime, time, timedelta, UUID."""
        from datetime import date, datetime, time, timedelta, timezone as dt_timezone
        from uuid import UUID

        from .models import Event

        @define
        class EventAttrs:
            name: str
            event_date: date
            created_at: datetime
            start_time: time
            duration: timedelta
            event_id: UUID

        # Generate spec and process
        spec = to_spec(EventAttrs)
        rprint("[bold]DateTime spec:[/bold]", spec)

        prepare, project = specs.process(spec)

        # Execute query
        queryset = prepare(Event.objects.all())
        event_dicts = [project(instance) for instance in queryset]

        rprint("[bold]Projected with datetime:[/bold]", event_dicts)

        # Structure - use default_converter which has datetime hooks
        events = [default_converter.structure(data, EventAttrs) for data in event_dicts]

        # Assertions
        self.assertEqual(len(events), 1)
        event = events[0]
        self.assertEqual(event.name, "Conference")
        self.assertEqual(event.event_date, date(2026, 3, 15))
        # Datetime remains timezone-aware (UTC) through projection/structuring
        self.assertEqual(
            event.created_at, datetime(2026, 1, 1, 10, 30, 0, tzinfo=dt_timezone.utc)
        )
        self.assertEqual(event.start_time, time(9, 0, 0))
        self.assertEqual(event.duration, timedelta(hours=2))
        self.assertIsInstance(event.event_id, UUID)

    def test_optional_datetime_types(self):
        """Test Optional datetime types."""
        from datetime import date
        from typing import Optional

        @define
        class EventWithOptionalDate:
            name: str
            event_date: Optional[date]

        spec = to_spec(EventWithOptionalDate)
        self.assertEqual(spec, ["name", "event_date"])
