import logging
from decimal import Decimal

import cattrs
from attrs import define
from django.test import TestCase

from mantle.db import (
    create,
    Query,
    QueryLogConfig,
    default_converter,
    make_default_converter,
    update,
)

from .models import SimpleBookmark, Team, User


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


@define
class TeamAttrs:
    name: str


@define
class UserAttrs:
    username: str
    team: TeamAttrs


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


@define
class InvalidSimpleBookmarkWriteAttrs:
    url: str
    not_a_model_field: str


class QueryBasicTest(TestCase):
    """Test Query class with basic types."""

    def setUp(self):
        self.bookmark1 = SimpleBookmark.objects.create(
            url="https://example.com", comment="Example site", favourite=True
        )
        self.bookmark2 = SimpleBookmark.objects.create(
            url="https://django.org", comment="Django docs", favourite=False
        )

    def test_query_all_returns_list_of_attrs(self):
        """Query.all() should return list of attrs instances."""
        query = Query(SimpleBookmark.objects.all(), SimpleBookmarkAttrs)
        bookmarks = query.all()

        self.assertEqual(len(bookmarks), 2)
        self.assertIsInstance(bookmarks[0], SimpleBookmarkAttrs)
        self.assertIsInstance(bookmarks[1], SimpleBookmarkAttrs)

    def test_query_all_with_filter(self):
        """Query should work with filtered querysets."""
        query = Query(
            SimpleBookmark.objects.filter(favourite=True), SimpleBookmarkAttrs
        )
        bookmarks = query.all()

        self.assertEqual(len(bookmarks), 1)
        self.assertEqual(bookmarks[0].url, "https://example.com")
        self.assertEqual(bookmarks[0].favourite, True)

    def test_query_get_returns_single_attrs(self):
        """Query.get() should return single attrs instance."""
        query = Query(
            SimpleBookmark.objects.filter(url="https://example.com"),
            SimpleBookmarkAttrs,
        )
        bookmark = query.get()

        self.assertIsInstance(bookmark, SimpleBookmarkAttrs)
        self.assertEqual(bookmark.url, "https://example.com")
        self.assertEqual(bookmark.comment, "Example site")
        self.assertEqual(bookmark.favourite, True)

    def test_query_get_raises_does_not_exist(self):
        """Query.get() should raise DoesNotExist if no results."""
        query = Query(
            SimpleBookmark.objects.filter(url="https://nonexistent.com"),
            SimpleBookmarkAttrs,
        )

        with self.assertRaises(SimpleBookmark.DoesNotExist):
            query.get()

    def test_query_get_raises_multiple_objects_returned(self):
        """Query.get() should raise MultipleObjectsReturned if multiple results."""
        query = Query(
            SimpleBookmark.objects.all(), SimpleBookmarkAttrs  # Returns 2 objects
        )

        with self.assertRaises(SimpleBookmark.MultipleObjectsReturned):
            query.get()


class QueryNestedTest(TestCase):
    """Test Query class with nested relationships."""

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

    def test_query_with_nested_relationship(self):
        """Query should handle nested attrs classes."""
        query = Query(User.objects.all(), UserAttrs)
        users = query.all()

        self.assertEqual(len(users), 1)
        user = users[0]
        self.assertIsInstance(user, UserAttrs)
        self.assertEqual(user.username, "alice")
        self.assertIsInstance(user.team, TeamAttrs)
        self.assertEqual(user.team.name, "Engineering")

    def test_query_optimizes_queries(self):
        """Query should use optimized queries (no N+1)."""
        query = Query(User.objects.all(), UserAttrs)

        # Should use 2 queries: main + prefetch for team
        with self.assertNumQueries(2):
            users = query.all()

        # Accessing nested data should not trigger additional queries
        with self.assertNumQueries(0):
            _ = users[0].team.name


class ConverterTest(TestCase):
    """Test cattrs converter configuration."""

    def test_default_converter_exists(self):
        """Default converter should be available."""
        self.assertIsInstance(default_converter, cattrs.Converter)

    def test_make_default_converter_handles_decimal(self):
        """Default converter should handle Decimal types."""
        converter = make_default_converter()

        # Test Decimal passthrough
        result = converter.structure(Decimal("10.50"), Decimal)
        self.assertEqual(result, Decimal("10.50"))
        self.assertIsInstance(result, Decimal)

        # Test Decimal from string
        result = converter.structure("10.50", Decimal)
        self.assertEqual(result, Decimal("10.50"))
        self.assertIsInstance(result, Decimal)

    def test_custom_converter_can_be_used(self):
        """Query should accept custom converter."""
        custom_converter = make_default_converter()

        # Add custom hook that uppercases strings
        custom_converter.register_structure_hook(
            str, lambda v, _: v.upper() if isinstance(v, str) else v
        )

        @define
        class CustomAttrs:
            name: str

        Team.objects.create(name="engineering")

        query = Query(Team.objects.all(), CustomAttrs, converter=custom_converter)
        teams = query.all()

        # String should be uppercased by custom converter
        self.assertEqual(teams[0].name, "ENGINEERING")


class LoggingTest(TestCase):
    """Test Query logging configuration."""

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

    def test_query_without_logging_config(self):
        """Query should work without explicit logging config."""
        query = Query(SimpleBookmark.objects.all(), SimpleBookmarkAttrs)
        bookmarks = query.all()

        self.assertEqual(len(bookmarks), 1)

    def test_query_with_logging_config(self):
        """Query should accept logging configuration."""
        log_config = QueryLogConfig(
            level=logging.DEBUG,
            log_spec=True,
            log_sql=True,
            log_queries=True,
            log_dicts=True,
            log_shapes=True,
        )

        query = Query(
            SimpleBookmark.objects.all(), SimpleBookmarkAttrs, log_config=log_config
        )

        # Should not raise errors even with all logging enabled
        with self.assertLogs("mantle.db", level="DEBUG") as cm:
            bookmarks = query.all()

        self.assertEqual(len(bookmarks), 1)
        # Check that some logging occurred
        self.assertGreater(len(cm.output), 0)

    def test_logging_config_defaults(self):
        """QueryLogConfig should have sensible defaults."""
        config = QueryLogConfig()

        self.assertEqual(config.level, logging.INFO)
        self.assertFalse(config.log_spec)
        self.assertFalse(config.log_sql)
        self.assertFalse(config.log_queries)
        self.assertFalse(config.log_dicts)
        self.assertFalse(config.log_shapes)


class QuerySpecGenerationTest(TestCase):
    """Test that Query properly generates specs."""

    def test_query_generates_spec_on_init(self):
        """Query should generate spec during initialization."""
        query = Query(SimpleBookmark.objects.all(), SimpleBookmarkAttrs)

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

    def test_query_caches_prepare_project_pair(self):
        """Query should cache prepare/project functions."""
        query = Query(SimpleBookmark.objects.all(), SimpleBookmarkAttrs)

        self.assertIsNotNone(query.prepare)
        self.assertIsNotNone(query.project)
        self.assertTrue(callable(query.prepare))
        self.assertTrue(callable(query.project))


class WriteFunctionsTest(TestCase):
    """Test create/update write helpers."""

    def test_create_from_shape(self):
        shape = SimpleBookmarkWriteAttrs(
            url="https://example.com",
            comment="Created by mutation",
            favourite=True,
        )

        bookmark = create(SimpleBookmark, shape)

        self.assertIsInstance(bookmark, SimpleBookmark)
        self.assertEqual(bookmark.url, "https://example.com")
        self.assertEqual(bookmark.comment, "Created by mutation")
        self.assertTrue(bookmark.favourite)

    def test_update_from_shape(self):
        bookmark = SimpleBookmark.objects.create(
            url="https://old.example.com",
            comment="Old comment",
            favourite=False,
        )
        shape = SimpleBookmarkWriteAttrs(
            url="https://new.example.com",
            comment="New comment",
            favourite=True,
        )

        updated = update(bookmark, shape)

        self.assertEqual(updated.pk, bookmark.pk)
        self.assertEqual(updated.url, "https://new.example.com")
        self.assertEqual(updated.comment, "New comment")
        self.assertTrue(updated.favourite)

    def test_invalid_shape_field_raises(self):
        with self.assertRaises(ValueError):
            create(
                SimpleBookmark,
                InvalidSimpleBookmarkWriteAttrs(
                    url="https://example.com", not_a_model_field="bad"
                ),
            )

    def test_create_requires_shape_instance(self):
        with self.assertRaises(TypeError):
            create(SimpleBookmark, "not-a-shape")  # type: ignore[arg-type]

    def test_update_shape_fields_must_exist_on_instance_model(self):
        shape = SimpleBookmarkWriteAttrs(
            url="https://example.com",
            comment="Comment",
            favourite=False,
        )

        with self.assertRaises(ValueError):
            update(Team(name="engineering"), shape)  # type: ignore[arg-type]
