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

"""debusine-admin command to sync user information with its sources of truth."""

from typing import Any, NamedTuple, NoReturn, override

import gitlab
from django.conf import settings
from django.core.management import CommandParser

from debusine.db.context import context
from debusine.db.models import Group, User
from debusine.db.models.auth import Identity
from debusine.django.management.debusine_base_command import DebusineBaseCommand
from debusine.server.signon.providers import GitlabProvider


class GroupCheckResults(NamedTuple):
    """Results of a group membership check."""

    external_group_name: str
    group: Group
    found: int
    unknown: int
    to_add: list[User]
    to_remove: list[User]


class GitlabCheck:
    """
    Account sync support for Gitlab providers.

    This requires the ``sync_token`` key to be set in the provider's
    ``options``, to be a gitlab API token that is able to access the membership
    information of the groups to be synced.
    """

    def __init__(self, provider: GitlabProvider) -> None:
        """Instantiate a GitlabCheck for a provider."""
        self.provider = provider
        sync_token = self.provider.options.get("sync_token")
        if sync_token is None:
            raise ValueError(
                f"Provider {provider.name} has no 'sync_token'"
                " configured in its options"
            )
        self.gl = gitlab.Gitlab(
            self.provider.url_issuer, private_tokeen=sync_token
        )
        self.identities = {
            i.subject: i
            for i in Identity.objects.filter(
                issuer=self.provider.name, user__isnull=False
            ).select_related("user")
        }

    def sync_group(
        self, salsa_group_name: str, debusine_group: Group
    ) -> GroupCheckResults:
        """Sync accounts for Gitlab groups."""
        salsa_group = self.gl.groups.get(id=salsa_group_name)
        current_debusine_members = {u.id: u for u in debusine_group.users.all()}

        found = 0
        unknown = 0
        to_add: list[User] = []
        for member in salsa_group.members_all.list(iterator=True):
            subject = str(member.id)
            identity = self.identities.get(subject, None)
            if identity is None:
                # User is not in our database
                unknown += 1
                continue
            assert identity.user is not None
            if current_debusine_members.pop(identity.user.id, None):
                found += 1
            else:
                to_add.append(identity.user)

        return GroupCheckResults(
            external_group_name=salsa_group_name,
            group=debusine_group,
            found=found,
            unknown=unknown,
            to_add=to_add,
            to_remove=list(current_debusine_members.values()),
        )


class Command(DebusineBaseCommand):
    """Check group membership against remote signon providers."""

    help = "Update group status based on membership changes on remote servers."

    @override
    def add_arguments(self, parser: CommandParser) -> None:
        parser.add_argument(
            "--fix",
            action="store_true",
            help="Perform membership changes",
        )

    def apply_group_check_result(
        self, result: GroupCheckResults, dry_run: bool
    ) -> None:
        """Apply changes from a :py:class:`GroupCheckResults`."""
        self.stdout.write(
            f"{result.external_group_name} → {result.group}:"
            f" {result.found} valid members,"
            f" {len(result.to_add)} members to add,"
            f" {len(result.to_remove)} members to remove,"
            f" {result.unknown} users not found in Debusine"
        )
        if dry_run:
            for user in result.to_add:
                self.stdout.write(f"{user} should be added to {result.group}")
            for user in result.to_remove:
                self.stdout.write(
                    f"{user} should be removed from {result.group}"
                )
        else:
            with context.management_command():
                for user in result.to_add:
                    result.group.add_user(user)
                    self.stdout.write(f"{user} added to {result.group}")
                for user in result.to_remove:
                    result.group.remove_user(user)
                    self.stdout.write(f"{user} removed from {result.group}")

    def handle_gitlab(self, provider: GitlabProvider, *, dry_run: bool) -> None:
        """Handle account sync for a gitlab provider."""
        if not (add_to_group := provider.options.get("add_to_group")):
            self.stderr.write(
                f"{provider.name}: provider without add_to_group: skipping sync"
            )
            return

        try:
            gitlab_check = GitlabCheck(provider)
        except Exception as e:
            self.stderr.write(f"{provider.name}: {e}")
            return

        for external_group, debusine_group in add_to_group.items():
            if external_group.startswith("nm:"):
                self.stderr.write(
                    f"{external_group}: sync with nm.debian.org"
                    " is not yet implemented"
                )
            else:
                group = Group.objects.get_by_name(debusine_group)
                result = gitlab_check.sync_group(external_group, group)
                self.apply_group_check_result(result, dry_run)

    @override
    def handle(self, *args: Any, fix: bool = False, **options: Any) -> NoReturn:
        for provider in getattr(settings, "SIGNON_PROVIDERS", ()):
            match provider:
                case GitlabProvider():
                    self.handle_gitlab(provider, dry_run=not fix)
                case _:
                    self.stderr.write(
                        f"{provider.name}: provider unsupported for sync"
                    )

        raise SystemExit(0)
