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

"""Utility methods used in management module."""

import abc
import datetime as dt
import io
from collections.abc import Generator
from dataclasses import dataclass
from typing import Any, ClassVar, IO, cast, override

import rich
import yaml
from django.conf import settings
from django.core.management import CommandError
from django.db.models import QuerySet, Value
from django.db.models.functions import Coalesce
from rich.table import Table

from debusine.assets.models import AssetCategory
from debusine.db.models import Asset, Scope, WorkerPool, Workspace
from debusine.db.models.assets import AssetUsageRole
from debusine.db.models.auth import GroupMembership


@dataclass
class Column:
    """Represent a column in a tabular output."""

    #: Name in YAML
    name: str
    #: Label in tables
    label: str

    def to_data(self, value: Any) -> Any:
        """Return a value for YAML."""
        return value

    def to_rich(self, value: Any) -> str:
        """Return a value for human-readable output."""
        match value:
            case None:
                return "-"
            case dt.datetime():
                return value.isoformat()
            case _:
                return str(value)


@dataclass
class AttrColumn(Column):
    """Column displaying an attribute of an object."""

    #: Attribute to use for value
    attr: str

    @override
    def to_data(self, value: Any) -> Any:
        return getattr(value, self.attr) if value is not None else None

    @override
    def to_rich(self, value: Any) -> str:
        if value is None:
            return "-"
        value = getattr(value, self.attr)
        return super().to_rich(value)


class Printer(abc.ABC):
    """Print tabular values in human or machine readable formats."""

    #: Description of tabular columns
    columns: ClassVar[list[Column]]

    def __init__(self, yaml: bool) -> None:
        """
        Choose the output type.

        :param yaml: use machine-readable YAML
        """
        self.yaml = yaml

    @abc.abstractmethod
    def rows(self, qs: QuerySet[Any]) -> Generator[list[Any]]:
        """Generate rows to display."""

    def print(
        self, qs: QuerySet[Any], file: IO[str] | io.TextIOBase | None = None
    ) -> None:
        """Generate and print output."""
        if self.yaml:
            self.print_yaml(qs, file)
        else:
            self.print_rich(qs, file)

    def print_yaml(
        self, qs: QuerySet[Any], file: IO[str] | io.TextIOBase | None = None
    ) -> None:
        """Generate machine-readable YAML."""
        rows: list[dict[str, Any]] = []
        for row in self.rows(qs):
            rows.append(  # noqa: PERF401
                {
                    col.name: col.to_data(val)
                    for col, val in zip(self.columns, row)
                }
            )
        print(yaml.dump(rows), file=file)

    def print_rich(
        self, qs: QuerySet[Any], file: IO[str] | io.TextIOBase | None = None
    ) -> None:
        """Generate human-readable output."""
        table = Table(box=rich.box.MINIMAL_DOUBLE_HEAD)
        for col in self.columns:
            table.add_column(col.label)
        for row in self.rows(qs):
            table.add_row(
                *(col.to_rich(val) for col, val in zip(self.columns, row))
            )
        rich.print(table, file=cast(IO[str], file))


class Tokens(Printer):
    """Display a queryset of tokens."""

    columns = [
        Column("hash", 'Token hash (do not copy)'),
        AttrColumn("user", 'User', "username"),
        Column("created", 'Created'),
        Column("expires", 'Expires'),
        Column("enabled", 'Enabled'),
        Column("comment", 'Comment'),
    ]

    @override
    def rows(self, qs: QuerySet[Any]) -> Generator[list[Any]]:
        for token in qs.order_by('user__username', 'created_at'):
            yield [
                token.hash,
                token.user,
                token.created_at,
                token.expire_at,
                token.enabled,
                token.comment,
            ]


class Workers(Printer):
    """Display a queryset of workers."""

    columns = [
        Column("name", 'Name'),
        Column("type", 'Type'),
        Column("registered", 'Registered'),
        Column("connected", 'Connected'),
        AttrColumn("hash", 'Token hash (do not copy)', "hash"),
        AttrColumn("enabled", 'Enabled', "enabled"),
    ]

    @override
    def rows(self, qs: QuerySet[Any]) -> Generator[list[Any]]:
        for worker in qs.order_by('registered_at'):
            token = worker.token
            yield [
                worker.name,
                worker.worker_type,
                worker.registered_at,
                worker.connected_at,
                token,
                token,
            ]


class Workspaces(Printer):
    """Display a queryset of workspaces."""

    columns = [
        Column("name", 'Name'),
        Column("public", 'Public'),
        Column("expiration", 'Default Expiration Delay (days)'),
    ]

    @override
    def rows(self, qs: QuerySet[Any]) -> Generator[list[Any]]:
        for workspace in qs.order_by('id'):
            yield [
                str(workspace),
                workspace.public,
                workspace.default_expiration_delay.days or "Never",
            ]


class ResourceRoles(Printer):
    """Display a queryset of resource roles."""

    columns = [
        Column("group", 'Group'),
        Column("role", 'Role'),
    ]

    @override
    def rows(self, qs: QuerySet[Any]) -> Generator[list[Any]]:
        for role in qs.order_by('group__name', 'role'):
            yield [
                role.group.name,
                role.role,
            ]


class WorkRequests(Printer):
    """Display a queryset of work requests."""

    columns = [
        Column("id", "ID"),
        AttrColumn("worker", "Worker", "name"),
        Column("created_at", "Created"),
        Column("started_at", "Started"),
        Column("completed_at", "Completed"),
        Column("status", "Status"),
        Column("result", "Result"),
    ]

    @override
    def rows(self, qs: QuerySet[Any]) -> Generator[list[Any]]:
        for work_request in qs.order_by("created_at"):
            yield [
                work_request.id,
                work_request.worker,
                work_request.created_at,
                work_request.started_at,
                work_request.completed_at,
                work_request.status,
                work_request.result,
            ]


class Users(Printer):
    """Display a queryset of users."""

    columns = [
        Column("username", "User"),
        Column("email", "Email"),
        Column("date_joined", "Joined"),
    ]

    @override
    def rows(self, qs: QuerySet[Any]) -> Generator[list[Any]]:
        for user in qs.order_by("date_joined"):
            yield [user.username, user.email, user.date_joined]


class Groups(Printer):
    """Display a queryset of groups."""

    columns = [
        Column("scope", "Scope"),
        Column("workspace", "Workspace"),
        Column("group", "Group"),
        Column("users", "Users"),
    ]

    @override
    def rows(self, qs: QuerySet[Any]) -> Generator[list[Any]]:
        for group in qs.order_by(
            "scope", Coalesce("workspace__name", Value("")), "name"
        ):
            yield [
                group.scope.name,
                group.workspace.name if group.workspace else None,
                group.name,
                group.users.count(),
            ]


class GroupMembers(Printer):
    """Display a queryset of group members."""

    columns = [
        Column("username", "User"),
        Column("email", "Email"),
        Column("role", 'Role'),
        Column("date_joined", "Joined"),
    ]

    @override
    def rows(self, qs: QuerySet[GroupMembership]) -> Generator[list[Any]]:
        for membership in qs.order_by("user__date_joined"):
            yield [
                membership.user.username,
                membership.user.email,
                membership.role,
                membership.user.date_joined,
            ]


def sort_keys(d: dict[str, Any]) -> dict[str, Any]:
    """Order keys of a dict."""
    return dict(sorted(d.items()))


class NotificationChannels(Printer):
    """Display a queryset of notification channels."""

    columns = [
        Column("name", "Name"),
        Column("method", "Method"),
        Column("data", "Data"),
    ]

    @override
    def rows(self, qs: QuerySet[Any]) -> Generator[list[Any]]:
        for notification_channel in qs.order_by("name"):
            yield [
                notification_channel.name,
                notification_channel.method,
                sort_keys(notification_channel.data),
            ]


class Assets(Printer):
    """Display a queryset of assets."""

    columns = [
        Column("id", "ID"),
        Column("category", "Category"),
        Column("workspace", "Workspace"),
        Column("data", "Data"),
    ]

    @override
    def rows(self, qs: QuerySet[Asset]) -> Generator[list[Any]]:
        for asset in qs.order_by("id"):
            data: dict[str, Any] = {}
            match asset.category:
                case AssetCategory.SIGNING_KEY:
                    data = {
                        "fingerprint": asset.data["fingerprint"],
                        "purpose": asset.data["purpose"],
                    }
                case _:  # pragma: no cover
                    data = asset.data
            yield [asset.id, asset.category, str(asset.workspace), data]


class AssetUsageRoles(Printer):
    """Display a queryset of asset usage roles."""

    columns = [
        Column("workspace", "Workspace"),
        Column("group", "Group"),
        Column("role", "Role"),
    ]

    @override
    def rows(self, qs: QuerySet[AssetUsageRole]) -> Generator[list[Any]]:
        for asset_usage_role in qs.order_by("id"):
            yield [
                asset_usage_role.resource.workspace.name,
                asset_usage_role.group.name,
                asset_usage_role.role,
            ]


class WorkerPools(Printer):
    """Display a queryset of worker_pools."""

    columns = [
        Column("name", "Name"),
        Column("provider_account", "Provider Account"),
        Column("enabled", "Enabled"),
        Column("specifications", "Specifications"),
        Column("limits", "Limits"),
    ]

    @override
    def rows(self, qs: QuerySet[WorkerPool]) -> Generator[list[Any]]:
        for worker_pool in qs.order_by("id"):
            yield [
                worker_pool.name,
                worker_pool.provider_account.data["name"],
                worker_pool.enabled,
                sort_keys(worker_pool.specifications),
                sort_keys(worker_pool.limits),
            ]


def get_scope(scope_name: str) -> Scope:
    """Look up a scope by name."""
    try:
        return Scope.objects.get(name=scope_name)
    except Scope.DoesNotExist:
        raise CommandError(f"Scope {scope_name!r} not found", returncode=3)


def get_workspace(name: str, require_scope: bool = False) -> Workspace:
    """Get a workspace from the user-provided name."""
    if "/" in name:
        scope_name, workspace_name = name.split("/", 1)
    elif require_scope:
        raise CommandError(
            (
                f"scope_workspace {name!r} should be in the form "
                f"'scopename/workspacename'"
            ),
            returncode=3,
        )
    else:
        scope_name = settings.DEBUSINE_DEFAULT_SCOPE
        workspace_name = name

    scope = get_scope(scope_name)
    try:
        return Workspace.objects.get(name=workspace_name, scope=scope)
    except Workspace.DoesNotExist:
        raise CommandError(
            f"Workspace {workspace_name!r} not found in scope {scope.name!r}",
            returncode=3,
        )
