# 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 management command group."""

from typing import ClassVar, override
from unittest import mock

from django.core.management import CommandError

from debusine.db.models import Group, Scope, User
from debusine.db.models.auth import GroupRoles
from debusine.db.playground import scenarios
from debusine.django.management.tests import call_command
from debusine.server.management.commands.group import (
    Command,
    ParsedGroupArgument,
)
from debusine.server.management.commands.tests.utils import TabularOutputTests
from debusine.test.django import TestCase


class ParsedGroupArgumentTests(TestCase):
    """Tests for :py:class:`ParsedGroupArgument` class."""

    scenario = scenarios.DefaultContext()
    group: ClassVar[Group]
    workspace_group: ClassVar[Group]

    @override
    @classmethod
    def setUpTestData(cls) -> None:
        super().setUpTestData()
        cls.group = cls.playground.create_group("group")
        cls.workspace_group = cls.playground.create_group(
            "group", workspace=cls.scenario.workspace
        )

    def test_str(self) -> None:
        for value in ("foo/bar", "foo/bar/baz"):
            with self.subTest(value=value):
                parsed = ParsedGroupArgument.parse(value)
                self.assertEqual(str(parsed), value)
                self.assertEqual(repr(parsed), f"ParsedGroupArgument({value})")

    def test_parse(self) -> None:
        for value, scope, workspace, name in (
            ("foo/bar", "foo", None, "bar"),
            ("foo/bar/baz", "foo", "bar", "baz"),
        ):
            with self.subTest(value=value):
                parsed = ParsedGroupArgument.parse(value)
                self.assertEqual(parsed.scope, scope)
                self.assertEqual(parsed.workspace, workspace)
                self.assertEqual(parsed.name, name)

    def test_as_queryset_filter(self) -> None:
        for value, expected in (
            (
                "foo/bar",
                {"scope__name": "foo", "workspace": None, "name": "bar"},
            ),
            (
                "foo/bar/baz",
                {"scope__name": "foo", "workspace__name": "bar", "name": "baz"},
            ),
        ):
            with self.subTest(value=value):
                parsed = ParsedGroupArgument.parse(value)
                self.assertEqual(parsed.as_queryset_filter(), expected)

    def test_scope_instance(self) -> None:
        parsed = ParsedGroupArgument.parse("debusine/name")
        self.assertEqual(parsed.scope_instance, self.scenario.scope)

    def test_scope_instance_invalid(self) -> None:
        parsed = ParsedGroupArgument.parse("invalid/name")
        with self.assertRaisesRegex(
            CommandError, "Scope 'invalid' not found"
        ) as exc:
            parsed.scope_instance
        self.assertEqual(getattr(exc.exception, "returncode"), 3)

    def test_workspace_instance(self) -> None:
        parsed = ParsedGroupArgument.parse("debusine/System/name")
        self.assertEqual(parsed.workspace_instance, self.scenario.workspace)

    def test_workspace_instance_unset(self) -> None:
        parsed = ParsedGroupArgument.parse("debusine/name")
        self.assertIsNone(parsed.workspace_instance)

    def test_workspace_instance_invalid(self) -> None:
        parsed = ParsedGroupArgument.parse("debusine/invalid/name")
        with self.assertRaisesRegex(
            CommandError, "Workspace 'debusine/invalid' not found"
        ) as exc:
            parsed.workspace_instance
        self.assertEqual(getattr(exc.exception, "returncode"), 3)

    def test_group_instance(self) -> None:
        parsed = ParsedGroupArgument.parse("debusine/group")
        self.assertEqual(parsed.group_instance, self.group)

    def test_group_instance_workspaced(self) -> None:
        parsed = ParsedGroupArgument.parse("debusine/System/group")
        self.assertEqual(parsed.group_instance, self.workspace_group)

    def test_group_instance_invalid(self) -> None:
        parsed = ParsedGroupArgument.parse("debusine/System/invalid")
        with self.assertRaisesRegex(
            CommandError,
            "Group 'invalid' not found in scope 'debusine'"
            " and workspace 'System'",
        ) as exc:
            parsed.group_instance
        self.assertEqual(getattr(exc.exception, "returncode"), 3)


class GroupCommandTests(TabularOutputTests, TestCase):
    """Tests for group create management command."""

    scenario = scenarios.DefaultContext()
    group: ClassVar[Group]

    @override
    @classmethod
    def setUpTestData(cls) -> None:
        super().setUpTestData()
        cls.group = cls.playground.create_group("group")

    def assertGroup(
        self, scope: Scope, name: str, users: list[User] | None = None
    ) -> Group:
        """Check that the given scope exists, with its Admin group."""
        if users is None:
            users = []
        group = Group.objects.get(scope=scope, name=name)
        self.assertQuerySetEqual(group.users.all(), users, ordered=False)
        return group

    def test_get_scope_and_workspace(self) -> None:
        command = Command()
        for arg, scope, workspace in (
            ("debusine", self.scenario.scope, None),
            (
                "debusine/System",
                self.scenario.scope,
                self.scenario.workspace,
            ),
        ):
            with self.subTest(arg=arg):
                arg_scope, arg_workspace = command.get_scope_and_workspace(arg)
                self.assertEqual(arg_scope, scope)
                self.assertEqual(arg_workspace, workspace)

    def test_invalid_action(self) -> None:
        """Test invoking an invalid subcommand."""
        with self.assertRaisesRegex(
            CommandError, r"invalid choice: 'does-not-exist'"
        ) as exc:
            call_command("group", "does-not-exist")

        self.assertEqual(getattr(exc.exception, "returncode"), 1)

    def test_unexpected_action(self) -> None:
        """Test a subcommand with no implementation."""
        command = Command()

        with self.assertRaisesRegex(
            CommandError, r"Action 'does_not_exist' not found"
        ) as exc:
            command.handle(action="does_not_exist")

        self.assertEqual(getattr(exc.exception, "returncode"), 3)

    def test_get_users(self) -> None:
        """Test get_users."""
        user1 = self.playground.create_user("user1")
        user2 = self.playground.create_user("user2")

        command = Command()
        self.assertEqual(command.get_users(["user1", "user2"]), [user1, user2])

    def test_get_users_empty(self) -> None:
        """Test get_users with an empty username list."""
        command = Command()
        self.assertEqual(command.get_users([]), [])

    def test_get_users_nonexisting(self) -> None:
        """Test get_users with nonexisting usernames."""
        command = Command()
        with self.assertRaisesRegex(
            CommandError,
            r"User 'missing1' does not exist\n"
            r"User 'missing2' does not exist",
        ) as exc:
            command.get_users(["missing1", "missing2"])
        self.assertEqual(getattr(exc.exception, "returncode"), 3)

    def test_list_no_users(self) -> None:
        with self.assertPrintsTable() as output:
            _, stderr, exit_code = call_command("group", "list", "debusine")

        self.assertEqual(output.col(0), ["debusine"])
        self.assertEqual(output.col(1), ["-"])
        self.assertEqual(output.col(2), ["group"])
        self.assertEqual(output.col(3), ["0"])

        self.assertEqual(stderr, "")
        self.assertEqual(exit_code, 0)

    def test_list(self) -> None:
        self.playground.create_group("group", workspace=self.scenario.workspace)
        self.playground.add_user(self.group, self.scenario.user)
        with self.assertPrintsTable() as output:
            _, stderr, exit_code = call_command("group", "list", "debusine")

        self.assertEqual(output.col(0), ["debusine", "debusine"])
        self.assertEqual(output.col(1), ["-", "System"])
        self.assertEqual(output.col(2), ["group", "group"])
        self.assertEqual(output.col(3), ["1", "0"])

        self.assertEqual(stderr, "")
        self.assertEqual(exit_code, 0)

    def test_list_workspace(self) -> None:
        self.playground.create_group("group", workspace=self.scenario.workspace)
        with self.assertPrintsTable() as output:
            _, stderr, exit_code = call_command(
                "group", "list", "debusine/System"
            )

        self.assertEqual(output.col(0), ["debusine"])
        self.assertEqual(output.col(1), ["System"])
        self.assertEqual(output.col(2), ["group"])
        self.assertEqual(output.col(3), ["0"])

        self.assertEqual(stderr, "")
        self.assertEqual(exit_code, 0)

    def test_list_no_workspace(self) -> None:
        self.playground.add_user(self.group, self.scenario.user)
        self.playground.create_group("group", workspace=self.scenario.workspace)
        with self.assertPrintsTable() as output:
            _, stderr, exit_code = call_command(
                "group", "list", "debusine", "--no-workspace"
            )

        self.assertEqual(output.col(0), ["debusine"])
        self.assertEqual(output.col(1), ["-"])
        self.assertEqual(output.col(2), ["group"])
        self.assertEqual(output.col(3), ["1"])

        self.assertEqual(stderr, "")
        self.assertEqual(exit_code, 0)

    def test_create(self) -> None:
        """Test a successful create."""
        stdout, stderr, exit_code = call_command(
            "group", "create", "debusine/group1"
        )
        self.assertEqual(stdout, "")
        self.assertEqual(stderr, "")
        self.assertEqual(exit_code, 0)
        self.assertGroup(self.scenario.scope, "group1")

    def test_create_idempotent(self) -> None:
        """Test a idempotence in create twice."""
        stdout, stderr, exit_code = call_command(
            "group", "create", "debusine/group1"
        )
        self.assertEqual(stdout, "")
        self.assertEqual(stderr, "")
        self.assertEqual(exit_code, 0)
        group = self.assertGroup(self.scenario.scope, "group1")
        self.playground.add_user(group, self.playground.get_default_user())

        stdout, stderr, exit_code = call_command(
            "group", "create", "debusine/group1"
        )
        self.assertEqual(stdout, "")
        self.assertEqual(stderr, "")
        self.assertEqual(exit_code, 0)
        group1 = self.assertGroup(
            self.scenario.scope, "group1", [self.playground.get_default_user()]
        )

        self.assertEqual(group.pk, group1.pk)

    def test_create_invalid_argument(self) -> None:
        with self.assertRaisesRegex(
            CommandError,
            r"should be in the form 'scopename\[/workspacename\]/groupname",
        ) as exc:
            call_command("group", "create", "debusine/workspace/group/invalid")
        self.assertEqual(getattr(exc.exception, "returncode"), 3)
        self.assertQuerySetEqual(Group.objects.filter(name="group/invalid"), [])

    def test_create_invalid_name(self) -> None:
        stdout, stderr, exit_code = call_command(
            "group", "create", "debusine/@name"
        )
        self.assertEqual(stdout, "")
        self.assertEqual(
            stderr,
            "Created group would be invalid:\n"
            "* name: '@name' is not a valid group name\n",
        )
        self.assertEqual(exit_code, 3)
        self.assertQuerySetEqual(Group.objects.filter(name="@name"), [])

    def test_rename(self) -> None:
        """Test a successful rename."""
        self.playground.add_user(self.group, self.playground.get_default_user())
        stdout, stderr, exit_code = call_command(
            "group", "rename", "debusine/group", "group1"
        )
        self.assertEqual(stdout, "")
        self.assertEqual(stderr, "")
        self.assertEqual(exit_code, 0)

        self.assertGroup(
            self.scenario.scope, "group1", [self.playground.get_default_user()]
        )

    def test_rename_noop(self) -> None:
        """Test renaming to current name."""
        self.playground.add_user(self.group, self.playground.get_default_user())

        with mock.patch("debusine.db.models.Group.save") as save:
            stdout, stderr, exit_code = call_command(
                "group", "rename", "debusine/group", "group"
            )
        self.assertEqual(stdout, "")
        self.assertEqual(stderr, "")
        self.assertEqual(exit_code, 0)

        self.assertGroup(
            self.scenario.scope, "group", [self.playground.get_default_user()]
        )

        save.assert_not_called()

    def test_rename_group_does_not_exist(self) -> None:
        """Test renaming a nonexisting group."""
        with self.assertRaisesRegex(
            CommandError, r"Group 'does-not-exist' not found"
        ) as exc:
            call_command(
                "group", "rename", "debusine/does-not-exist", "newname"
            )

        self.assertEqual(getattr(exc.exception, "returncode"), 3)

    def test_rename_target_exists(self) -> None:
        """Test renaming with a name already in use."""
        group = self.playground.create_group("test")
        stdout, stderr, exit_code = call_command(
            "group", "rename", "debusine/test", "group"
        )
        self.assertEqual(stdout, "")
        self.assertEqual(
            stderr.splitlines(),
            [
                "Renamed group would be invalid:",
                (
                    "* Constraint “db_group_unique_name_scope_workspace”"
                    " is violated."
                ),
                # From django 5.0, when db_group_unique_name_scope_workspace is
                # redone with nulls_distinct, we can go back to a better error
                # message:
                # "* Group with this Name and Scope already exists.",
            ],
        )

        self.assertEqual(exit_code, 3)
        group.refresh_from_db()
        self.assertEqual(group.name, "test")

    def test_rename_new_name_invalid(self) -> None:
        """Test renaming to an invalid name."""
        stdout, stderr, exit_code = call_command(
            "group", "rename", "debusine/group", "invalid/name"
        )
        self.assertEqual(stdout, "")
        self.assertEqual(
            stderr.splitlines(),
            [
                "Renamed group would be invalid:",
                "* name: 'invalid/name' is not a valid group name",
            ],
        )
        self.assertEqual(exit_code, 3)
        self.group.refresh_from_db()
        self.assertEqual(self.group.name, "group")

    def test_delete(self) -> None:
        """Test a successful delete."""
        self.playground.add_user(self.group, self.playground.get_default_user())
        stdout, stderr, exit_code = call_command(
            "group",
            "delete",
            "debusine/group",
        )
        self.assertEqual(stdout, "")
        self.assertEqual(stderr, "")
        self.assertEqual(exit_code, 0)

        self.assertQuerySetEqual(
            Group.objects.filter(scope=self.scenario.scope), []
        )

    def test_delete_missing(self) -> None:
        """Test idempotence in deleting a missing group."""
        self.playground.add_user(self.group, self.playground.get_default_user())
        stdout, stderr, exit_code = call_command(
            "group",
            "delete",
            "debusine/missing",
        )
        self.assertEqual(stdout, "")
        self.assertEqual(stderr, "")
        self.assertEqual(exit_code, 0)

        self.assertQuerySetEqual(
            Group.objects.filter(scope=self.scenario.scope), [self.group]
        )

    def test_members_set(self) -> None:
        """Test members --set."""
        user1 = self.playground.create_user("user1")
        user2 = self.playground.create_user("user2")
        self.playground.add_user(self.group, self.playground.get_default_user())

        stdout, stderr, exit_code = call_command(
            "group", "members", "debusine/group", "--set", "user1", "user2"
        )
        self.assertEqual(stdout, "")
        self.assertEqual(stderr, "")
        self.assertEqual(exit_code, 0)

        self.assertQuerySetEqual(
            self.group.users.all(), [user1, user2], ordered=False
        )

    def test_members_set_empty(self) -> None:
        """Test members --set to the empty set."""
        self.playground.add_user(self.group, self.playground.get_default_user())

        stdout, stderr, exit_code = call_command(
            "group", "members", "debusine/group", "--set"
        )
        self.assertEqual(stdout, "")
        self.assertEqual(stderr, "")
        self.assertEqual(exit_code, 0)

        self.assertQuerySetEqual(self.group.users.all(), [])

    def test_members_add(self) -> None:
        """Test members --add."""
        user1 = self.playground.create_user("user1")
        self.playground.add_user(self.group, self.playground.get_default_user())

        stdout, stderr, exit_code = call_command(
            "group",
            "members",
            "debusine/group",
            "--add",
            "user1",
        )
        self.assertEqual(stdout, "")
        self.assertEqual(stderr, "")
        self.assertEqual(exit_code, 0)

        self.assertQuerySetEqual(
            self.group.users.all(),
            [self.playground.get_default_user(), user1],
            ordered=False,
        )

    def test_members_remove(self) -> None:
        """Test members --remove."""
        user1 = self.playground.create_user("user1")
        self.playground.add_user(self.group, self.playground.get_default_user())
        self.playground.add_user(self.group, user1)

        stdout, stderr, exit_code = call_command(
            "group",
            "members",
            "debusine/group",
            "--remove",
            self.playground.get_default_user().username,
        )
        self.assertEqual(stdout, "")
        self.assertEqual(stderr, "")
        self.assertEqual(exit_code, 0)

        self.assertQuerySetEqual(
            self.group.users.all(),
            [user1],
        )

    def test_members_mix(self) -> None:
        """Test running multiple members actions."""
        with self.assertRaisesRegex(
            CommandError,
            r"Error: argument --add: not allowed with argument --remove",
        ) as exc:
            call_command(
                "group",
                "members",
                "debusine/group",
                "--remove",
                "1",
                "--add",
                "2",
            )
        self.assertEqual(getattr(exc.exception, "returncode"), 1)

    def test_members_list_empty(self) -> None:
        """Test group members with no users."""
        with self.assertPrintsTable() as output:
            _, stderr, exit_code = call_command(
                "group", "members", "debusine/group"
            )

        self.assertEqual(output.col(0), [])
        self.assertEqual(output.col(1), [])
        self.assertEqual(output.col(2), [])
        self.assertEqual(output.col(3), [])

        self.assertEqual(stderr, "")
        self.assertEqual(exit_code, 0)

    def test_members_list(self) -> None:
        """Test group members."""
        self.playground.add_user(self.group, self.scenario.user)
        with self.assertPrintsTable() as output:
            _, stderr, exit_code = call_command(
                "group", "members", "debusine/group"
            )

        self.assertEqual(output.col(0), [self.scenario.user.username])
        self.assertEqual(output.col(1), [self.scenario.user.email])
        self.assertEqual(output.col(2), [GroupRoles.MEMBER])
        self.assertEqual(
            output.col(3), [self.scenario.user.date_joined.isoformat()]
        )

        self.assertEqual(stderr, "")
        self.assertEqual(exit_code, 0)

    def test_members_list_multiple_roles(self) -> None:
        """Test group members list shows correct roles for each member."""
        user1 = self.playground.create_user("user1")
        user2 = self.playground.create_user("user2")
        self.playground.add_user(self.group, user1, role=GroupRoles.MEMBER)
        self.playground.add_user(self.group, user2, role=GroupRoles.ADMIN)

        with self.assertPrintsTable() as output:
            _, stderr, exit_code = call_command(
                "group", "members", "debusine/group"
            )

        # Rows ordered by user__date_joined
        self.assertEqual(output.col(0), [user1.username, user2.username])
        self.assertEqual(output.col(2), [GroupRoles.MEMBER, GroupRoles.ADMIN])

        self.assertEqual(stderr, "")
        self.assertEqual(exit_code, 0)

    def test_members_add_default_role(self) -> None:
        """Test --add without --role. default is MEMBER."""
        user1 = self.playground.create_user("user1")

        stdout, stderr, exit_code = call_command(
            "group", "members", "debusine/group", "--add", "user1"
        )
        self.assertEqual(stdout, "")
        self.assertEqual(stderr, "")
        self.assertEqual(exit_code, 0)

        self.assertEqual(self.group.get_user_role(user1), GroupRoles.MEMBER)

    def test_members_add_member_role_explicit(self) -> None:
        """Test --add --role member explicitly sets MEMBER role"""
        user1 = self.playground.create_user("user1")

        stdout, stderr, exit_code = call_command(
            "group",
            "members",
            "debusine/group",
            "--add",
            "user1",
            "--role",
            "member",
        )
        self.assertEqual(stdout, "")
        self.assertEqual(stderr, "")
        self.assertEqual(exit_code, 0)

        self.assertEqual(self.group.get_user_role(user1), GroupRoles.MEMBER)

    def test_members_add_admin_role(self) -> None:
        """Test --add --role admin creates a membership with ADMIN role"""
        user1 = self.playground.create_user("user1")

        stdout, stderr, exit_code = call_command(
            "group",
            "members",
            "debusine/group",
            "--add",
            "user1",
            "--role",
            "admin",
        )
        self.assertEqual(stdout, "")
        self.assertEqual(stderr, "")
        self.assertEqual(exit_code, 0)

        self.assertEqual(self.group.get_user_role(user1), GroupRoles.ADMIN)

    def test_members_add_multiple_users_same_role(self) -> None:
        """Test --add with multiple users assigns the same role to all"""
        user1 = self.playground.create_user("user1")
        user2 = self.playground.create_user("user2")

        _, _, exit_code = call_command(
            "group",
            "members",
            "debusine/group",
            "--add",
            "user1",
            "user2",
            "--role",
            "admin",
        )
        self.assertEqual(exit_code, 0)

        self.assertEqual(self.group.get_user_role(user1), GroupRoles.ADMIN)
        self.assertEqual(self.group.get_user_role(user2), GroupRoles.ADMIN)

    def test_members_set_admin_role(self) -> None:
        """Test --set --role admin replaces all members with ADMIN role"""
        user1 = self.playground.create_user("user1")
        user2 = self.playground.create_user("user2")
        self.playground.add_user(self.group, self.playground.get_default_user())

        stdout, stderr, exit_code = call_command(
            "group",
            "members",
            "debusine/group",
            "--set",
            "user1",
            "user2",
            "--role",
            "admin",
        )
        self.assertEqual(stdout, "")
        self.assertEqual(stderr, "")
        self.assertEqual(exit_code, 0)

        self.assertEqual(self.group.get_user_role(user1), GroupRoles.ADMIN)
        self.assertEqual(self.group.get_user_role(user2), GroupRoles.ADMIN)
        # Previous member should be gone
        self.assertNotIn(
            self.playground.get_default_user(), self.group.users.all()
        )

    def test_members_role_invalid(self) -> None:
        """Test invalid --role value is rejected"""
        with self.assertRaisesRegex(
            CommandError,
            r"invalid choice: 'superuser'",
        ) as exc:
            call_command(
                "group",
                "members",
                "debusine/group",
                "--add",
                "someuser",
                "--role",
                "superuser",
            )
        self.assertEqual(getattr(exc.exception, "returncode"), 1)
