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

Note: to make commands easier to be invoked from Ansible, we take care to make
them idempotent.
"""

import argparse
from collections.abc import Callable
from functools import cached_property
from typing import Any, NoReturn, Self, cast, override

from django.core.exceptions import ValidationError
from django.core.management import CommandError, CommandParser
from django.core.management.base import OutputWrapper
from django.db import transaction

from debusine.db.context import context
from debusine.db.models import Group, Scope, User, Workspace
from debusine.db.models.scopes import ScopeRole
from debusine.django.management.debusine_base_command import DebusineBaseCommand
from debusine.server.management.management_utils import (
    Groups,
    Users,
    get_scope,
    get_workspace,
)


def print_validation_error(error: ValidationError, file: OutputWrapper) -> None:
    """Print a ValidationError to the given file."""
    for field, errors in error.message_dict.items():
        if field == "__all__":
            lead = ""
        else:
            lead = f"{field}: "
        for msg in errors:
            file.write(f"* {lead}{msg}")


class ParsedGroupArgument:
    """Parsed elements of a ``scope[/workspace]/name`` specification."""

    def __init__(
        self, *, scope: str, name: str, workspace: str | None = None
    ) -> None:
        """
        Construct a ParsedGroupArgument.

        :param scope: scope name
        :param name: group name
        :param workspace: workspace name, or None if the group is not workspaced
        """
        self.scope = scope
        self.name = name
        self.workspace = workspace

    @override
    def __str__(self) -> str:
        parts = [self.scope]
        if self.workspace is not None:
            parts.append(self.workspace)
        parts.append(self.name)
        return "/".join(parts)

    @override
    def __repr__(self) -> str:
        return f"ParsedGroupArgument({str(self)})"

    def as_queryset_filter(self) -> dict[str, str | None]:
        """Build a Group queryset filter kwargs."""
        res: dict[str, str | None] = {
            "scope__name": self.scope,
            "name": self.name,
        }
        if self.workspace is None:
            res["workspace"] = None
        else:
            res["workspace__name"] = self.workspace
        return res

    @cached_property
    def scope_instance(self) -> Scope:
        """Resolve to a Scope instance."""
        try:
            return Scope.objects.get(name=self.scope)
        except Scope.DoesNotExist:
            raise CommandError(f"Scope {self.scope!r} not found", returncode=3)

    @cached_property
    def workspace_instance(self) -> Workspace | None:
        """Resolve to a Workspace instance."""
        if self.workspace is None:
            return None
        else:
            try:
                return Workspace.objects.get(
                    scope__name=self.scope, name=self.workspace
                )
            except Workspace.DoesNotExist:
                name = self.scope + "/" + self.workspace
                raise CommandError(
                    f"Workspace {name!r} not found", returncode=3
                )

    @cached_property
    def group_instance(self) -> Group:
        """Resolve to a Group instance."""
        try:
            return Group.objects.get(**self.as_queryset_filter())
        except Group.DoesNotExist:
            raise CommandError(
                f"Group {self.name!r} not found in scope {self.scope!r}"
                f" and workspace {self.workspace!r}",
                returncode=3,
            )

    @classmethod
    def parse(cls, value: str) -> Self:
        """Build a ParsedGroupArgument from a string argument."""
        tokens = value.split("/")
        match tokens:
            case [scope, name]:
                return cls(scope=scope, name=name)
            case [scope, workspace, name]:
                return cls(scope=scope, name=name, workspace=workspace)
            case _:
                raise CommandError(
                    f"argument {value!r} should be in the form"
                    " 'scopename[/workspacename]/groupname'",
                    returncode=3,
                )


class Command(DebusineBaseCommand):
    """Command to manage groups."""

    help = "Manage groups"

    def add_group_argument(
        self,
        parser: argparse.ArgumentParser,
        help: str,  # noqa: A002
    ) -> None:
        """Add a group identifier argument to an argument parser."""
        parser.add_argument(
            "scope_workspace_group",
            metavar="scope[/workspace]/name",
            help=help,
        )

    def parse_group_argument(
        self, options: dict[str, Any]
    ) -> ParsedGroupArgument:
        """Parse a ``scope[/workspace]/name`` argument."""
        return ParsedGroupArgument.parse(options["scope_workspace_group"])

    def add_arguments(self, parser: CommandParser) -> None:
        """Add CLI arguments."""
        subparsers = parser.add_subparsers(dest="action", required=True)

        group_list = subparsers.add_parser(
            "list", help=self.handle_list.__doc__
        )
        group_list.add_argument(
            '--yaml', action="store_true", help="Machine readable YAML output"
        )
        group_list.add_argument(
            "--no-workspace",
            action="store_true",
            help="Do not list workspace-specific groups",
        )
        group_list.add_argument(
            "scope_workspace",
            metavar="scope[/workspace]",
            help="list groups for this scope or workspace",
        )

        create = subparsers.add_parser(
            "create", help=self.handle_create.__doc__
        )
        self.add_group_argument(
            create, help="scope/workspace/name identifying the new group"
        )

        rename = subparsers.add_parser(
            "rename", help=self.handle_rename.__doc__
        )
        self.add_group_argument(
            rename, help="scope/workspace/name identifying the group to rename"
        )
        rename.add_argument("name", help="New name for the group")

        delete = subparsers.add_parser(
            "delete", help=self.handle_delete.__doc__
        )
        self.add_group_argument(
            delete, help="scope/workspace/name identifying the group to delete"
        )

        members = subparsers.add_parser(
            "members",
            # Override default usage message since there's otherwise no way
            # to persuade argparse to put the positional argument first:
            # https://github.com/python/cpython/issues/105947
            usage="%(prog)s scope/name [options]",
            help=self.handle_members.__doc__,
        )
        members.add_argument(
            '--yaml', action="store_true", help="Machine readable YAML output"
        )
        self.add_group_argument(
            members,
            help=(
                "scope/workspace/name identifying the group "
                "(must come before --add/--remove/--set options)"
            ),
        )
        members_actions = members.add_mutually_exclusive_group()
        members_actions.add_argument(
            "--add", metavar="user", nargs="+", help="add users"
        )
        members_actions.add_argument(
            "--remove", metavar="user", nargs="+", help="remove users"
        )
        members_actions.add_argument(
            "--set",
            metavar="user",
            nargs="*",
            help="set members to listed users",
        )

    def get_scope_and_workspace(
        self, scope_workspace: str
    ) -> tuple[Scope, Workspace | None]:
        """Lookup a ``scopename[/workspace]`` string."""
        if "/" not in scope_workspace:
            return get_scope(scope_workspace), None
        else:
            workspace = get_workspace(scope_workspace, require_scope=True)
            return workspace.scope, workspace

    def get_users(self, usernames: list[str]) -> list[User]:
        """Resolve a list of usernames into a list of users."""
        users: list[User] = []
        usernames_ok: bool = True
        errors: list[str] = []
        for username in usernames:
            try:
                users.append(User.objects.get(username=username))
            except User.DoesNotExist:
                usernames_ok = False
                errors.append(f"User {username!r} does not exist")
        if not usernames_ok:
            raise CommandError(
                "\n".join(errors),
                returncode=3,
            )
        return users

    def handle(self, *args: Any, **options: Any) -> NoReturn:
        """Dispatch the requested action."""
        func = cast(
            Callable[..., NoReturn],
            getattr(self, f"handle_{options['action']}", None),
        )
        if func is None:
            raise CommandError(
                f"Action {options['action']!r} not found", returncode=3
            )

        func(*args, **options)

    def handle_list(
        self,
        no_workspace: bool,
        yaml: bool,
        scope_workspace: str,
        **options: Any,
    ) -> NoReturn:
        """List groups in a scope."""
        scope, workspace = self.get_scope_and_workspace(scope_workspace)
        groups = Group.objects.filter(scope=scope)
        if workspace is not None:
            groups = groups.filter(workspace=workspace)
        elif no_workspace:
            groups = groups.filter(workspace=None)
        Groups(yaml).print(groups, self.stdout)
        raise SystemExit(0)

    @context.disable_permission_checks()
    def handle_create(self, **options: Any) -> NoReturn:
        """
        Create a group, specified as scope/name.

        This is idempotent, and it makes sure the named group exists.
        """
        group_args = self.parse_group_argument(options)
        with transaction.atomic():
            if Group.objects.filter(**group_args.as_queryset_filter()).exists():
                raise SystemExit(0)
            group = Group(
                scope=group_args.scope_instance,
                workspace=group_args.workspace_instance,
                name=group_args.name,
            )
            try:
                group.full_clean()
            except ValidationError as exc:
                self.stderr.write("Created group would be invalid:")
                print_validation_error(exc, file=self.stderr)
                raise SystemExit(3)
            group.save()
        raise SystemExit(0)

    @context.disable_permission_checks()
    def handle_rename(self, *, name: str, **options: Any) -> NoReturn:
        """Rename a group."""
        group_args = self.parse_group_argument(options)
        if name == group_args.name:
            raise SystemExit(0)

        group = group_args.group_instance
        group.name = name
        try:
            group.full_clean()
        except ValidationError as exc:
            self.stderr.write("Renamed group would be invalid:")
            print_validation_error(exc, file=self.stderr)
            raise SystemExit(3)

        group.save()

        raise SystemExit(0)

    def handle_delete(self, **options: Any) -> NoReturn:
        """Delete a group."""
        group_args = self.parse_group_argument(options)
        with transaction.atomic():
            if not Group.objects.filter(
                **group_args.as_queryset_filter()
            ).exists():
                raise SystemExit(0)
            group = group_args.group_instance
            ScopeRole.objects.filter(group=group).delete()
            group.delete()

        raise SystemExit(0)

    @context.disable_permission_checks()
    def handle_members(self, **options: Any) -> NoReturn:
        """Display or change group membership."""
        group_args = self.parse_group_argument(options)
        group = group_args.group_instance

        # Change membership
        change_requested: bool = False
        with transaction.atomic():
            if (usernames := options["set"]) is not None:
                change_requested = True
                group.users.set(self.get_users(usernames))
            if usernames := options["add"]:
                change_requested = True
                for user in self.get_users(usernames):
                    group.add_user(user)
            if usernames := options["remove"]:
                change_requested = True
                for user in self.get_users(usernames):
                    group.remove_user(user)

        if not change_requested:
            # List users
            Users(options["yaml"]).print(group.users.all(), self.stdout)
            raise SystemExit(0)
        raise SystemExit(0)
