# Copyright © The Debusine Developers
# See the AUTHORS file at the top-level directory of this distribution
#
# This file is part of Debusine. It is subject to the license terms
# in the LICENSE file found in the top-level directory of this
# distribution. No part of Debusine, including this file, may be copied,
# modified, propagated, or distributed except according to the terms
# contained in the LICENSE file.

"""Tests for the table ordering functions."""

from typing import Any, cast
from unittest import mock
from urllib.parse import urlencode

from django.core.exceptions import ImproperlyConfigured
from django.db.models import F
from django.test import RequestFactory

from debusine.db.models import User
from debusine.test.django import TestCase
from debusine.web.views.table.ordering import (
    DateTimeOrdering,
    NumberOrdering,
    OrderBys,
    Ordering,
    Sort,
    StringOrdering,
    TableOrdering,
    TableOrderings,
    compute_sort_icon_current_next,
    order_by_tuple,
    render_sort_icon,
)


class OrderByTupleTests(TestCase):
    """Tests for :py:func:`order_by_tuple`."""

    def test_single(self) -> None:
        for val in (
            "name",
            F("name"),
            F("name").asc(),
            ["name"],
            [F("name")],
            [F("name").asc()],
        ):
            with self.subTest(val=repr(val)):
                self.assertEqual(
                    str(order_by_tuple(val)),
                    "(OrderBy(F(name), descending=False),)",
                )

    def test_multi(self) -> None:
        for val in (
            ["name1", "name2"],
            [F("name1"), F("name2")],
            [F("name1").asc(), F("name2").asc()],
            cast(OrderBys, ["name1", F("name2").asc()]),
            cast(OrderBys, [F("name1"), "name2"]),
        ):
            with self.subTest(val=repr(val)):
                self.assertEqual(
                    str(order_by_tuple(val)),
                    "(OrderBy(F(name1), descending=False),"
                    " OrderBy(F(name2), descending=False))",
                )


class OrderingTests(TestCase):
    """Tests for :py:class:`Ordering`."""

    def test_default_desc(self) -> None:
        """Test inferring descending with a single field."""
        for val in ("name", F("name"), F("name").asc()):
            with self.subTest(val=repr(val)):
                o = Ordering(val)
                self.assertEqual(list(o.options.keys()), ["asc", "desc"])
                self.assertEqual(
                    str(o.options["asc"].order_by),
                    "(OrderBy(F(name), descending=False),)",
                )
                self.assertEqual(
                    str(o.options["desc"].order_by),
                    "(OrderBy(F(name), descending=True),)",
                )

    def test_default_desc_multi(self) -> None:
        """Test inferring descending with a single field."""
        for val in (
            ("name1", "name2"),
            (F("name1"), F("name2")),
            (F("name1").asc(), F("name2").asc()),
        ):
            with self.subTest(val=repr(val)):
                o = Ordering(val)
                self.assertEqual(list(o.options.keys()), ["asc", "desc"])
                self.assertEqual(
                    str(o.options["asc"].order_by),
                    "(OrderBy(F(name1), descending=False),"
                    " OrderBy(F(name2), descending=False))",
                )
                self.assertEqual(
                    str(o.options["desc"].order_by),
                    "(OrderBy(F(name1), descending=True),"
                    " OrderBy(F(name2), descending=True))",
                )

    def test_asc_desc(self) -> None:
        """Test inferring descending with a single field."""
        o = Ordering("name1", "name2")
        self.assertEqual(list(o.options.keys()), ["asc", "desc"])
        self.assertEqual(
            str(o.options["asc"].order_by),
            "(OrderBy(F(name1), descending=False),)",
        )
        self.assertEqual(
            str(o.options["desc"].order_by),
            "(OrderBy(F(name2), descending=False),)",
        )

    def test_dash(self) -> None:
        """Test inferring descending with a single field."""
        o = Ordering("name", "-name")
        self.assertEqual(list(o.options.keys()), ["asc", "desc"])
        self.assertEqual(
            str(o.options["asc"].order_by),
            "(OrderBy(F(name), descending=False),)",
        )
        self.assertEqual(
            str(o.options["desc"].order_by),
            "(OrderBy(F(name), descending=True),)",
        )

    def test_kwargs(self) -> None:
        """Test passing fields as kwargs."""
        o = Ordering(asc="name", desc="-name")
        self.assertEqual(list(o.options.keys()), ["asc", "desc"])
        self.assertEqual(
            str(o.options["asc"].order_by),
            "(OrderBy(F(name), descending=False),)",
        )
        self.assertEqual(
            str(o.options["desc"].order_by),
            "(OrderBy(F(name), descending=True),)",
        )

    def test_arbitrary_incomplete(self) -> None:
        """Arbitrary orderings not fully specified: ImproperlyConfigured."""
        with self.assertRaisesRegex(
            ImproperlyConfigured,
            r"ordering that isn't 'asc' or 'desc'"
            r" needs to be given as full 'Sort' instances",
        ):
            Ordering(foo="foo")

    def test_arbitrary(self) -> None:
        """Test arbitrary orderings, with default icon."""
        o = Ordering(foo=Sort("foo", "Label", "icon"))
        self.assertEqual(list(o.options.keys()), ["foo"])
        self.assertEqual(o.options["foo"].label, "Label")
        self.assertEqual(o.options["foo"].icon, "icon")
        self.assertEqual(
            str(o.options["foo"].order_by),
            "(OrderBy(F(foo), descending=False),)",
        )

    def test_arbitrary_default_icon(self) -> None:
        """Test arbitrary orderings, with default icon."""
        o = Ordering(foo=Sort("foo", "Label"))
        self.assertEqual(list(o.options.keys()), ["foo"])
        self.assertEqual(o.options["foo"].label, "Label")
        self.assertEqual(o.options["foo"].icon, "sort-alpha-down")
        self.assertEqual(
            str(o.options["foo"].order_by),
            "(OrderBy(F(foo), descending=False),)",
        )

    def test_infer(self) -> None:
        """Infer label and icon (defaults to string)."""
        o = Ordering("name")
        self.assertEqual(o.options["asc"].label, "Alphabetical order")
        self.assertEqual(o.options["asc"].icon, "sort-alpha-down")
        self.assertEqual(o.options["desc"].label, "Reverse alphabetical order")
        self.assertEqual(o.options["desc"].icon, "sort-alpha-up-alt")


class StringOrderingTests(TestCase):
    """Tests for :py:class:`StringOrdering`."""

    def test_infer(self) -> None:
        """Infer label and icon."""
        o = StringOrdering("field")
        self.assertEqual(o.options["asc"].label, "Alphabetical order")
        self.assertEqual(o.options["asc"].icon, "sort-alpha-down")
        self.assertEqual(o.options["desc"].label, "Reverse alphabetical order")
        self.assertEqual(o.options["desc"].icon, "sort-alpha-up-alt")


class NumberOrderingTests(TestCase):
    """Tests for :py:class:`NumberOrdering`."""

    def test_infer(self) -> None:
        """Infer label and icon."""
        o = NumberOrdering("field")
        self.assertEqual(o.options["asc"].label, "Lowest first")
        self.assertEqual(o.options["asc"].icon, "sort-numeric-down")
        self.assertEqual(o.options["desc"].label, "Highest first")
        self.assertEqual(o.options["desc"].icon, "sort-numeric-up-alt")


class DateTimeOrderingTests(TestCase):
    """Tests for :py:class:`DateTimeOrdering`."""

    def test_infer(self) -> None:
        """Infer label and icon."""
        o = DateTimeOrdering("field")
        self.assertEqual(o.options["asc"].label, "Oldest first")
        self.assertEqual(o.options["asc"].icon, "sort-numeric-down")
        self.assertEqual(o.options["desc"].label, "Newest first")
        self.assertEqual(o.options["desc"].icon, "sort-numeric-up-alt")


class SortIconTests(TestCase):
    """Tests for sort icon rendering."""

    def orderings(
        self, options: int, active: int | None = None
    ) -> list[TableOrdering[User]]:
        """Create ordering lists."""
        table = mock.Mock()
        table.prefix = ""
        table.dom_id = "tdom_id"
        table.request = RequestFactory().get("/")
        table_orderings: TableOrderings[User] = TableOrderings(table)

        orderings = [
            TableOrdering(
                table_orderings,
                Sort(f"foo{i}", f"Foo{i}", f"icon{i}"),
                "test",
                f"foo{i}",
                "Test",
                f"order=test.foo{i}",
            )
            for i in range(options)
        ]

        if active is not None:
            orderings[active].active = True

        return orderings

    def test_compute_sort_icon_current_next(self) -> None:
        """Test current,next selection."""
        for size, active, current, next_ in (
            (0, None, None, None),
            (1, None, None, 0),
            (1, 0, 0, None),
            (2, None, None, 0),
            (2, 0, 0, 1),
            (2, 1, 1, 0),
            (3, None, None, 0),
            (3, 0, 0, 1),
            (3, 1, 1, 2),
            (3, 2, 2, 0),
        ):
            with self.subTest(size=size, active=active):
                orderings = self.orderings(size, active)
                c, n = compute_sort_icon_current_next(orderings)
                if current is None:
                    self.assertIsNone(c)
                else:
                    self.assertIs(c, orderings[current])
                if next_ is None:
                    self.assertIsNone(n)
                else:
                    self.assertIs(n, orderings[next_])
                if orderings:
                    self.assertHTMLValid(render_sort_icon(orderings))

    def test_render_with_no_orderings(self) -> None:
        """If there are no orderings, render returns None."""
        self.assertEqual(render_sort_icon([]), "")

    def test_render_with_current_only(self) -> None:
        """If current is defined, the icon shows it."""
        orderings = self.orderings(1, 0)
        tree = self.assertHTMLValid(render_sort_icon(orderings))
        i = self.assertHasElement(tree, "body/span/i")
        self.assertEqual(i.get("class"), f"bi bi-{orderings[0].icon}")

    def test_render_with_next_only(self) -> None:
        """If next is defined, the icon is a link to it."""
        orderings = self.orderings(1, None)
        tree = self.assertHTMLValid(render_sort_icon(orderings))
        a = self.assertHasElement(tree, "body/a")
        self.assertEqual(a.get("href"), "?" + orderings[0].query_string)

    def test_render_with_both(self) -> None:
        """If next is defined, the icon is a link to it."""
        orderings = self.orderings(2, 0)
        tree = self.assertHTMLValid(render_sort_icon(orderings))
        a = self.assertHasElement(tree, "body/a")
        self.assertEqual(a.get("href"), "?" + orderings[1].query_string)
        i = self.assertHasElement(a, "i")
        self.assertEqual(i.get("class"), f"bi bi-{orderings[0].icon}")
        self.assertEqual(
            i.get("title"),
            f"{orderings[0].sort.label} → {orderings[1].sort.label}",
        )


class TableOrderingTests(TestCase):
    """Tests for :py:class:`TableOrdering`."""

    def _orderings(self) -> TableOrderings[User]:
        """Create a mock table."""
        return mock.Mock()

    def test_members(self) -> None:
        sort = Sort("sname", "Sort Label", "sicon")
        to: TableOrdering[User] = TableOrdering(
            self._orderings(), sort, "what", "how", "What", "order=what.how"
        )
        self.assertEqual(to.sort, sort)
        self.assertEqual(to.what, "what")
        self.assertEqual(to.how, "how")
        self.assertEqual(to.label_what, "What")
        self.assertEqual(to.query_string, "order=what.how")
        self.assertEqual(to.icon, "sicon")
        self.assertFalse(to.active)
        self.assertEqual(str(to), "what.how")
        self.assertEqual(repr(to), """TableOrdering("what.how")""")
        tm = self.assertHTMLValid(to.table_menu_item)
        self.assertTextContentEqual(tm, "What: Sort Label")
        cm = self.assertHTMLValid(to.column_menu_item)
        self.assertTextContentEqual(cm, "Sort Label")


class TableOrderingsTests(TestCase):
    """Tests for :py:class:`TableOrderings`."""

    def _orderings(
        self,
        prefix: str = "",
        default_order: str | None = None,
        order: str | None = None,
    ) -> TableOrderings[User]:
        """Create a mock table."""
        table = mock.Mock()
        table.prefix = prefix
        table.dom_id = "tdom_id"
        if default_order is not None:
            table.default_order = default_order

        data: dict[str, Any] = {}
        if order is not None:
            data[f"{prefix}order"] = order
        table.request = RequestFactory().get("/", data=data)
        table.make_link = lambda **kwargs: urlencode(kwargs)
        return TableOrderings(table)

    def test_init(self) -> None:
        orderings = self._orderings()
        self.assertEqual(orderings.field_name, "order")
        self.assertEqual(orderings.options, [])
        self.assertFalse(hasattr(orderings, "current"))

    def test_init_with_prefix(self) -> None:
        orderings = self._orderings(prefix="table-")
        self.assertEqual(orderings.field_name, "table-order")
        self.assertEqual(orderings.options, [])
        self.assertFalse(hasattr(orderings, "current"))

    def test_add(self) -> None:
        orderings = self._orderings()
        res = orderings.add("username", Ordering("username"))
        self.assertEqual(len(res), 2)

        to = res[0]
        self.assertIs(to.orderings, orderings)
        self.assertEqual(to.sort.label, "Alphabetical order")
        self.assertEqual(to.what, "username")
        self.assertEqual(to.how, "asc")
        self.assertEqual(to.label_what, "Username")
        self.assertEqual(to.query_string, "order=username.asc")
        self.assertFalse(to.active)

        to = res[1]
        self.assertIs(to.orderings, orderings)
        self.assertEqual(to.sort.label, "Reverse alphabetical order")
        self.assertEqual(to.what, "username")
        self.assertEqual(to.how, "desc")
        self.assertEqual(to.label_what, "Username")
        self.assertEqual(to.query_string, "order=username.desc")
        self.assertFalse(to.active)

        self.assertEqual(orderings.options, res)

    def test_parse_order(self) -> None:
        orderings = self._orderings()
        for order, exp_what, exp_how in (
            ("user", "user", None),
            ("-user", "user", "desc"),
            ("user.asc", "user", "asc"),
            ("user.desc", "user", "desc"),
            ("user.foo", "user", "foo"),
        ):
            with self.subTest(order=order):
                self.assertEqual(
                    orderings._parse_order(order), (exp_what, exp_how)
                )

    def test_find(self) -> None:
        orderings = self._orderings()
        asc, desc = orderings.add("username", Ordering("username"))
        for order, expected in (
            ("username", asc),
            ("-username", desc),
            ("username.asc", asc),
            ("username.desc", desc),
            ("username.foo", None),
            ("-username.asc", None),
            ("foo", None),
            ("foo.asc", None),
        ):
            self.assertIs(orderings._find(order), expected)

    def test_finalize(self) -> None:
        orderings = self._orderings(default_order="username")
        asc, desc = orderings.add("username", Ordering("username"))
        orderings.finalize()
        self.assertIs(orderings.current, asc)
        self.assertTrue(asc.active)
        self.assertFalse(desc.active)

    def test_finalize_wrong_default_ordering(self) -> None:
        orderings = self._orderings(default_order="foo")
        orderings.add("username", Ordering("username"))
        with self.assertRaisesRegex(
            ImproperlyConfigured,
            r"default_order 'foo' does not match available ordering options",
        ):
            orderings.finalize()

    def test_finalize_selected_column(self) -> None:
        orderings = self._orderings(
            default_order="username", order="username.desc"
        )
        asc, desc = orderings.add("username", Ordering("username"))
        orderings.finalize()
        self.assertIs(orderings.current, desc)
        self.assertFalse(asc.active)
        self.assertTrue(desc.active)

    def test_finalize_empty(self) -> None:
        """Test finalize when there are no orderings."""
        orderings = self._orderings()
        orderings.finalize()
        self.assertEqual(orderings.options, [])
        self.assertFalse(hasattr(orderings, "current"))

    def test_order_queryset(self) -> None:
        user1 = self.playground.create_user("user1")
        user2 = self.playground.create_user("user2")
        user3 = self.playground.create_user("user3")

        for order, expected in (
            (None, [user1, user2, user3]),
            ("username", [user1, user2, user3]),
            ("-username", [user3, user2, user1]),
            ("username.asc", [user1, user2, user3]),
            ("username.desc", [user3, user2, user1]),
        ):
            with self.subTest(order=order):
                orderings = self._orderings(
                    default_order="username", order=order
                )
                orderings.add("username", Ordering("username"))
                orderings.finalize()
                self.assertQuerySetEqual(
                    orderings.order_queryset(
                        User.objects.filter(username__startswith="user")
                    ),
                    expected,
                )

    def test_what_menu_entries(self) -> None:
        orderings = self._orderings(
            default_order="username", order="username.desc"
        )
        orderings.add("username", Ordering("username"))
        orderings.add("email", Ordering("username"))
        orderings.finalize()
        entries = orderings.what_menu_entries

        self.assertEqual(len(entries), 2)
        tree = self.assertHTMLValid(entries[0])
        self.assertTextContentEqual(tree, "Username")
        self.assertElementHasClass(tree.body.a, "active")
        tree = self.assertHTMLValid(entries[1])
        self.assertTextContentEqual(tree, "Email")
        self.assertElementHasNoClass(tree.body.a, "active")

    def test_how_menu_entries(self) -> None:
        orderings = self._orderings(
            default_order="username", order="email.desc"
        )
        orderings.add("username", Ordering("username"))
        easc, edesc = orderings.add("email", Ordering("username"))
        orderings.finalize()
        entries = orderings.how_menu_entries

        self.assertEqual(len(entries), 2)
        tree = self.assertHTMLValid(entries[0])
        self.assertTextContentEqual(tree, easc.sort.label)
        self.assertElementHasNoClass(tree.body.a, "active")
        tree = self.assertHTMLValid(entries[1])
        self.assertTextContentEqual(tree, edesc.sort.label)
        self.assertElementHasClass(tree.body.a, "active")
