# Copyright (c) 2023 Rackslab
#
# This file is part of RFL.
#
# SPDX-License-Identifier: LGPL-3.0-or-later

import configparser
import logging
import warnings
from pathlib import Path
from typing import Optional, Set, Tuple

from rfl.authentication.user import AuthenticatedUser
import yaml

from .errors import RBACPolicyDefinitionLoadError, RBACPolicyRolesLoadError


logger = logging.getLogger(__name__)

ANONYMOUS_ROLE = "anonymous"
ALL_MEMBER = "ALL"


class ActionMetadata:
    """Metadata describing an action in the policy definition."""

    def __init__(
        self,
        name: str,
        description: str,
        deprecated: bool = False,
        replaced_by: Optional[str] = None,
    ):
        self.name = name
        self.description = description
        self.deprecated = deprecated
        self.replaced_by = replaced_by

    def __repr__(self):
        return (
            f"ActionMetadata(name={self.name}, deprecated={self.deprecated}, "
            f"replaced_by={self.replaced_by})"
        )


class RBACPolicyRole:
    def __init__(self, name: str, members: Set[str], actions: Set[str]):
        self.name = name
        self.members = members
        self.actions = actions

    def __repr__(self):
        return f"{self.name} [ members: {self.members}, actions: {self.actions} ]"


class RBACPolicyDefinitionLoader:
    pass


class RBACPolicyDefinitionYAMLLoader(RBACPolicyDefinitionLoader):
    def __init__(self, path: Path = None, raw: str = None):
        try:
            if raw is not None:
                content = yaml.safe_load(raw)
            elif path is not None:
                try:
                    with open(path) as fh:
                        content = yaml.safe_load(fh)
                except FileNotFoundError:
                    raise RBACPolicyDefinitionLoadError(
                        f"Policy definition file {path} not found"
                    )
            else:
                raise RBACPolicyDefinitionLoadError(
                    "Either a raw string value or a path must be given to load YAML "
                    "policy definition"
                )
        except yaml.parser.ParserError as err:
            raise RBACPolicyDefinitionLoadError(
                f"Invalid YAML policy definition: {str(err)}"
            ) from err
        try:
            actions_content = content["actions"]
        except KeyError as err:
            raise RBACPolicyDefinitionLoadError(
                "Actions key not found in YAML policy definition"
            ) from err
        except AttributeError as err:
            raise RBACPolicyDefinitionLoadError(
                "Unable to extract the set of actions from actions key in YAML policy "
                "definition"
            ) from err
        if not isinstance(actions_content, dict):
            raise RBACPolicyDefinitionLoadError(
                "Actions definition must be a mapping of action names to definitions"
            )

        self.actions = {}
        for action, definition in actions_content.items():
            if isinstance(definition, str):
                description = definition
                deprecated = False
                replaced_by = None
            elif isinstance(definition, dict):
                try:
                    description = definition["description"]
                except KeyError as err:
                    raise RBACPolicyDefinitionLoadError(
                        f"Description is mandatory for action {action}"
                    ) from err
                deprecated = definition.get("deprecated", False)
                if not isinstance(deprecated, bool):
                    raise RBACPolicyDefinitionLoadError(
                        f"Invalid deprecated value for action {action}"
                    )
                replaced_by = definition.get("replaced_by")
                if replaced_by is not None and not isinstance(replaced_by, str):
                    raise RBACPolicyDefinitionLoadError(
                        f"Invalid replaced_by value for action {action}"
                    )
            else:
                raise RBACPolicyDefinitionLoadError(
                    f"Invalid definition for action {action}"
                )
            self.actions[action] = ActionMetadata(
                name=action,
                description=description,
                deprecated=deprecated,
                replaced_by=replaced_by,
            )

        # Validate replacement references
        for action, metadata in self.actions.items():
            if metadata.replaced_by and metadata.replaced_by not in self.actions:
                raise RBACPolicyDefinitionLoadError(
                    f"Replacement action {metadata.replaced_by} for {action} not found "
                    "in policy definition"
                )

    def apply(self, action: str) -> Set[str]:
        """Return effective actions for the given action name, handling deprecation.

        Deprecated actions are stripped, a UserWarning is emitted, and a single
        replacement action (if declared) is added instead. Unknown actions still raise
        RBACPolicyRolesLoadError.
        """
        metadata = self.actions[action]
        if metadata.deprecated:
            warning_msg = f"Action {action} is deprecated"
            if metadata.replaced_by:
                warning_msg += f"; use {metadata.replaced_by} instead"
            warnings.warn(warning_msg, UserWarning)
            if metadata.replaced_by:
                return self.apply(metadata.replaced_by)
            return set()

        return {action}


class RBACPolicyRolesLoader:
    pass


class RBACPolicyRolesIniLoader(RBACPolicyRolesLoader):
    def __init__(
        self,
        definition: RBACPolicyDefinitionLoader,
        path: Path = None,
        raw: str = None,
        preload: bool = True,
    ):
        self.definition = definition
        self.content = configparser.ConfigParser(
            interpolation=configparser.ExtendedInterpolation(),
            allow_no_value=True,
        )
        try:
            if raw is not None:
                logger.debug("Loading settings in INI format from raw value")
                self.content.read_string(raw)
            elif path is not None:
                logger.debug("Loading settings file %s", path)
                self.content.read(path)
            else:
                raise RBACPolicyRolesLoadError(
                    "Either a raw string value or a path must be given to load site "
                    "settings"
                )
        except configparser.Error as err:
            raise RBACPolicyDefinitionLoadError(str(err)) from err
        self.roles = set()
        if preload:
            self._load()

    def _load(self):
        """Load the set of PolicyRoles defined in file pointed by the given path."""
        try:
            roles = self.content.options("roles")
        except configparser.NoSectionError as err:
            raise RBACPolicyRolesLoadError(
                "Section roles to define roles members is not defined in INI content"
            ) from err
        for role in roles:
            try:
                actions = self._expand_actions(self.content.get(role, "actions"))
            except configparser.NoSectionError as err:
                raise RBACPolicyRolesLoadError(
                    f"Section to define actions for role {role} is not defined in INI "
                    "content"
                ) from err
            except configparser.NoOptionError as err:
                raise RBACPolicyRolesLoadError(
                    f"Option to define actions for role {role} is not defined in INI "
                    "content"
                ) from err
            if role == ANONYMOUS_ROLE:
                members = None
            else:
                members = self._expand_members(self.content.get("roles", role))
            self.roles.add(RBACPolicyRole(role, members, actions))

    def _expand_actions(self, actions_str: str) -> Set[str]:
        """Return the set of actions declared in comma-separated list provided in
        actions_str argument. If an item is prefixed by @, the set is expanded with the
        actions of the role name that follows."""
        actions = set()
        for action in actions_str.split(","):
            if action.startswith("@"):
                actions.update(self._role_actions(action[1:]))
            else:
                if action not in self.definition.actions:
                    raise RBACPolicyRolesLoadError(
                        f"Action {action} not found in policy definition"
                    )
                actions.update(self.definition.apply(action))
        return actions

    def _expand_members(self, members_str):
        """Return the set of members declared in comma-separated list provided in
        members_str argument."""
        return set(members_str.split(","))

    def _role_actions(self, role):
        """Return the set of actions allowed the to given role. Raise
        RBACPolicyVerificationError if the role is not found in policy."""
        for _role in self.roles:
            if _role.name == role:
                return _role.actions

        raise RBACPolicyRolesLoadError(
            f"Unable to inherit actions from role {role} not found in policy"
        )


class RBACPolicyManager:
    def __init__(self, loader: RBACPolicyRolesLoader):
        self.loader = loader

    @property
    def allow_anonymous(self) -> bool:
        """Return True if the anonymous role is declared in policy, False otherwise."""
        for role in self.loader.roles:
            if role.name == ANONYMOUS_ROLE:
                return True
        return False

    def disable_anonymous(self) -> None:
        """Ensure anonymous role is disabled, even if defined in loaded policy."""
        for role in self.loader.roles.copy():
            if role.name == ANONYMOUS_ROLE:
                self.loader.roles.remove(role)

    def _user_roles(self, user: AuthenticatedUser) -> Set[str]:
        """Return the set of roles associated to a given user name."""
        roles = set()
        assert not user.is_anonymous()
        for role in self.loader.roles:
            # Skip anonymous role w/o members
            if role.name == ANONYMOUS_ROLE:
                continue
            # Include role with ALL authenticated members
            if ALL_MEMBER in role.members:
                roles.add(role)
            else:
                for member in role.members:
                    if member == user.login or (
                        member.startswith("@") and member[1:] in user.groups
                    ):
                        roles.add(role)
        logger.debug("Found the following roles for user %s: %s", user, roles)
        return roles

    def _anonymous_actions(self) -> Tuple[Set[str], Set[str]]:
        """Return tuple with set of role names and set of allowed actions for
        anonymous user."""
        for role in self.loader.roles:
            if role.name == ANONYMOUS_ROLE:
                return ({role.name}, role.actions)
        return (set(), set())

    def _user_actions(self, user: AuthenticatedUser) -> Tuple[Set[str], Set[str]]:
        """Return tuple with set of role names and set of allowed actions for a
        particular user and list of groups membership."""
        roles = self._user_roles(user)
        actions = set()
        for role in roles:
            actions.update(role.actions)
        return (set([role.name for role in roles]), actions)

    def roles_actions(self, user: AuthenticatedUser) -> Tuple[Set[str], Set[str]]:
        """Return tuple with set of role names and set of allowed actions for a
        given user."""
        if user.is_anonymous():
            return self._anonymous_actions()
        return self._user_actions(user)

    def allowed_anonymous_action(self, action: str) -> bool:
        """Return True if the given action is allowed for anonymous role, False
        otherwise."""
        for role in self.loader.roles:
            if role.name == ANONYMOUS_ROLE and action in role.actions:
                return True
        return False

    def allowed_user_action(self, user: AuthenticatedUser, action: str) -> bool:
        """Return True if the given action is allowed for the given user, False
        otherwise."""
        if user.is_anonymous():
            return self.allowed_anonymous_action(action)
        for role in self._user_roles(user):
            if action in role.actions:
                logger.debug(
                    "Token for user %s is permitted to perform action %s",
                    user,
                    action,
                )
                return True
        logger.warning(
            "Token for user %s is not authorized to perform action %s",
            user,
            action,
        )
        return False

    @classmethod
    def yaml_definition_ini_roles(cls, definition: Path, roles: Path):
        return cls(
            RBACPolicyRolesIniLoader(
                definition=RBACPolicyDefinitionYAMLLoader(path=definition), path=roles
            )
        )
