# 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 signon_sync."""

from typing import ClassVar, override
from unittest import mock

import gitlab
import gitlab.v4.objects
from django.test import override_settings

from debusine.db.models import Group
from debusine.db.models.auth import Identity
from debusine.db.playground import scenarios
from debusine.django.management.tests import call_command
from debusine.server.signon import providers
from debusine.test.django import TestCase


class SignonSyncCommandTests(TestCase):
    """Tests for signon_sync command."""

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

    @override
    @classmethod
    def setUpClass(cls) -> None:
        super().setUpClass()
        cls.group = cls.playground.create_group(name="group")
        cls.identity = Identity.objects.create(
            user=cls.scenario.user, issuer="salsa", subject="123"
        )

    def mock_gitlab(self) -> gitlab.Gitlab:
        """Return a mock gitlab object."""
        gl = mock.MagicMock(spec=gitlab.Gitlab)
        gl.groups = mock.MagicMock(spec=gitlab.v4.objects.GroupManager)
        gl.groups.get = mock.MagicMock()
        return gl

    def mock_gitlab_group(self) -> gitlab.v4.objects.Group:
        """Return a mock gitlab group."""
        group = mock.MagicMock(spec=gitlab.v4.objects.Group)
        group.members_all = mock.MagicMock(
            spec=gitlab.v4.objects.GroupMemberAllManager
        )
        group.members_all.list = mock.MagicMock()
        return group

    def mock_gitlab_user(self, id: int | None = None) -> gitlab.v4.objects.User:  # noqa: A002
        """Return a mock gitlab user."""
        user = mock.MagicMock(spec=gitlab.v4.objects.User)
        user.id = id if id is not None else int(self.identity.subject)
        return user

    @override
    def setUp(self) -> None:
        super().setUp()
        self.provider = providers.GitlabProvider(
            name="salsa",
            label="Salsa",
            url="https://salsa.debian.org",
            client_id="client_id",
            client_secret="secret",
            options={
                "add_to_group": {"remote": str(self.group)},
                "sync_token": "TOKEN123",
            },
        )
        self.gl = self.mock_gitlab()
        self.enterContext(mock.patch("gitlab.Gitlab", return_value=self.gl))

    def test_sync_unsupported_provider(self) -> None:
        self.enterContext(
            override_settings(
                SIGNON_PROVIDERS=[
                    providers.Provider("unsupported", "Unsupported")
                ]
            )
        )
        stdout, stderr, retval = call_command("signon_sync")
        self.assertEqual(stdout, "")
        self.assertEqual(
            stderr.splitlines(), ["unsupported: provider unsupported for sync"]
        )
        self.assertEqual(retval, 0)

    def test_sync_gitlab_no_add_to_group(self) -> None:
        del self.provider.options["add_to_group"]
        self.enterContext(override_settings(SIGNON_PROVIDERS=[self.provider]))
        stdout, stderr, retval = call_command("signon_sync")
        self.assertEqual(stdout, "")
        self.assertEqual(
            stderr.splitlines(),
            ["salsa: provider without add_to_group: skipping sync"],
        )
        self.assertEqual(retval, 0)

    def test_sync_gitlab_no_sync_token(self) -> None:
        del self.provider.options["sync_token"]
        self.enterContext(override_settings(SIGNON_PROVIDERS=[self.provider]))
        stdout, stderr, retval = call_command("signon_sync")
        self.assertEqual(stdout, "")
        self.assertEqual(
            stderr.splitlines(),
            [
                "salsa: Provider salsa has no 'sync_token'"
                " configured in its options"
            ],
        )
        self.assertEqual(retval, 0)

    def test_sync_gitlab_nm_remote(self) -> None:
        self.provider.options["add_to_group"] = {
            "nm:unsupported": str(self.group)
        }
        self.enterContext(override_settings(SIGNON_PROVIDERS=[self.provider]))
        stdout, stderr, retval = call_command("signon_sync")
        self.assertEqual(stdout, "")
        self.assertEqual(
            stderr.splitlines(),
            ["nm:unsupported: sync with nm.debian.org is not yet implemented"],
        )
        self.assertEqual(retval, 0)

    def test_sync_gitlab(self) -> None:
        self.enterContext(override_settings(SIGNON_PROVIDERS=[self.provider]))
        stdout, stderr, retval = call_command("signon_sync")
        self.assertEqual(
            stdout.splitlines(),
            [
                "remote → debusine/group: 0 valid members, 0 members to add,"
                " 0 members to remove, 0 users not found in Debusine"
            ],
        )
        self.assertEqual(stderr, "")
        self.assertEqual(retval, 0)

    def test_sync_gitlab_add_user_dry_run(self) -> None:
        self.enterContext(override_settings(SIGNON_PROVIDERS=[self.provider]))
        gl_user = self.mock_gitlab_user()
        gl_group = self.mock_gitlab_group()
        gl_group.members_all.list.return_value = [gl_user]
        self.gl.groups.get.return_value = gl_group
        stdout, stderr, retval = call_command("signon_sync")
        self.assertEqual(
            stdout.splitlines(),
            [
                "remote → debusine/group: 0 valid members, 1 members to add,"
                " 0 members to remove, 0 users not found in Debusine",
                "playground should be added to debusine/group",
            ],
        )
        self.assertEqual(stderr, "")
        self.assertEqual(retval, 0)

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

    def test_sync_gitlab_add_user(self) -> None:
        self.enterContext(override_settings(SIGNON_PROVIDERS=[self.provider]))
        gl_user = self.mock_gitlab_user()
        gl_group = self.mock_gitlab_group()
        gl_group.members_all.list.return_value = [gl_user]
        self.gl.groups.get.return_value = gl_group
        stdout, stderr, retval = call_command("signon_sync", "--fix")
        self.assertEqual(
            stdout.splitlines(),
            [
                "remote → debusine/group: 0 valid members, 1 members to add,"
                " 0 members to remove, 0 users not found in Debusine",
                "playground added to debusine/group",
            ],
        )
        self.assertEqual(stderr, "")
        self.assertEqual(retval, 0)

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

    def test_sync_gitlab_remove_user_dry_run(self) -> None:
        self.playground.add_user(self.group, self.scenario.user)
        self.enterContext(override_settings(SIGNON_PROVIDERS=[self.provider]))
        gl_group = self.mock_gitlab_group()
        gl_group.members_all.list.return_value = []
        self.gl.groups.get.return_value = gl_group
        stdout, stderr, retval = call_command("signon_sync")
        self.assertEqual(
            stdout.splitlines(),
            [
                "remote → debusine/group: 0 valid members, 0 members to add,"
                " 1 members to remove, 0 users not found in Debusine",
                "playground should be removed from debusine/group",
            ],
        )
        self.assertEqual(stderr, "")
        self.assertEqual(retval, 0)

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

    def test_sync_gitlab_remove_user(self) -> None:
        self.playground.add_user(self.group, self.scenario.user)
        self.enterContext(override_settings(SIGNON_PROVIDERS=[self.provider]))
        gl_group = self.mock_gitlab_group()
        gl_group.members_all.list.return_value = []
        self.gl.groups.get.return_value = gl_group
        stdout, stderr, retval = call_command("signon_sync", "--fix")
        self.assertEqual(
            stdout.splitlines(),
            [
                "remote → debusine/group: 0 valid members, 0 members to add,"
                " 1 members to remove, 0 users not found in Debusine",
                "playground removed from debusine/group",
            ],
        )
        self.assertEqual(stderr, "")
        self.assertEqual(retval, 0)

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

    def test_sync_gitlab_unknown_user(self) -> None:
        self.enterContext(override_settings(SIGNON_PROVIDERS=[self.provider]))
        gl_user = self.mock_gitlab_user(id=12345)
        gl_group = self.mock_gitlab_group()
        gl_group.members_all.list.return_value = [gl_user]
        self.gl.groups.get.return_value = gl_group
        stdout, stderr, retval = call_command("signon_sync")
        self.assertEqual(
            stdout.splitlines(),
            [
                "remote → debusine/group: 0 valid members, 0 members to add,"
                " 0 members to remove, 1 users not found in Debusine"
            ],
        )
        self.assertEqual(stderr, "")
        self.assertEqual(retval, 0)

    def test_sync_gitlab_valid_user(self) -> None:
        self.playground.add_user(self.group, self.scenario.user)
        self.enterContext(override_settings(SIGNON_PROVIDERS=[self.provider]))
        gl_user = self.mock_gitlab_user()
        gl_group = self.mock_gitlab_group()
        gl_group.members_all.list.return_value = [gl_user]
        self.gl.groups.get.return_value = gl_group
        stdout, stderr, retval = call_command("signon_sync")
        self.assertEqual(
            stdout.splitlines(),
            [
                "remote → debusine/group: 1 valid members, 0 members to add,"
                " 0 members to remove, 0 users not found in Debusine"
            ],
        )
        self.assertEqual(stderr, "")
        self.assertEqual(retval, 0)
