#
# Kickstart module for subscription handling.
#
# Copyright (C) 2020 Red Hat, Inc.
#
# This copyrighted material is made available to anyone wishing to use,
# modify, copy, or redistribute it subject to the terms and conditions of
# the GNU General Public License v.2, or (at your option) any later version.
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY expressed or implied, including the implied warranties of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General
# Public License for more details.  You should have received a copy of the
# GNU General Public License along with this program; if not, write to the
# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
# 02110-1301, USA.  Any Red Hat trademarks that are incorporated in the
# source code or documentation are not subject to the GNU General Public
# License and may only be used or replicated with the express permission of
# Red Hat, Inc.
#
import copy
import warnings

from dasbus.typing import get_native

from pyanaconda.core.payload import ProxyString, ProxyStringError
from pyanaconda.core.signal import Signal
from pyanaconda.core.constants import SECRET_TYPE_HIDDEN, SUBSCRIPTION_REQUEST_TYPE_ORG_KEY
from pyanaconda.core.configuration.anaconda import conf

from pyanaconda.modules.common.base import KickstartService
from pyanaconda.modules.common.structures.subscription import SystemPurposeData, \
    SubscriptionRequest
from pyanaconda.modules.common.structures.secret import get_public_copy
from pyanaconda.core.dbus import DBus

from pyanaconda.modules.common.constants.services import SUBSCRIPTION
from pyanaconda.modules.common.constants.objects import RHSM_CONFIG, RHSM_REGISTER_SERVER, \
    RHSM_UNREGISTER, RHSM_ATTACH, RHSM_ENTITLEMENT, RHSM_SYSPURPOSE
from pyanaconda.modules.common.containers import TaskContainer
from pyanaconda.modules.common.structures.requirement import Requirement

from pyanaconda.modules.subscription import system_purpose
from pyanaconda.modules.subscription.kickstart import SubscriptionKickstartSpecification
from pyanaconda.modules.subscription.subscription_interface import SubscriptionInterface
from pyanaconda.modules.subscription.installation import ConnectToInsightsTask, \
    RestoreRHSMDefaultsTask, TransferSubscriptionTokensTask
from pyanaconda.modules.subscription.initialization import StartRHSMTask
from pyanaconda.modules.subscription.runtime import SetRHSMConfigurationTask, \
    RegisterWithUsernamePasswordTask, RegisterWithOrganizationKeyTask, \
    UnregisterTask, AttachSubscriptionTask, SystemPurposeConfigurationTask, \
    ParseAttachedSubscriptionsTask
from pyanaconda.modules.subscription.rhsm_observer import RHSMObserver


from pykickstart.errors import KickstartParseWarning

from pyanaconda.anaconda_loggers import get_module_logger
log = get_module_logger(__name__)


class SubscriptionService(KickstartService):
    """The Subscription service."""

    def __init__(self):
        super().__init__()

        # system purpose

        self._valid_roles = []
        self._valid_slas = []
        self._valid_usage_types = []

        self._system_purpose_data = SystemPurposeData()
        self.system_purpose_data_changed = Signal()

        self._load_valid_system_purpose_values()

        # subscription request

        self._subscription_request = SubscriptionRequest()
        self.subscription_request_changed = Signal()

        # attached subscriptions
        self._attached_subscriptions = []
        self.attached_subscriptions_changed = Signal()

        # Insights

        # What are the defaults for Red Hat Insights ?
        # - during a kickstart installation, the user
        #   needs to opt-in by using the rhsm command
        #   with the --connect-to-insights option
        # - during a GUI interactive installation the
        #  "connect to Insights" checkbox is checked by default,
        #  making Insights opt-out
        # - in both cases the system also needs to be subscribed,
        #   or else the system can't be connected to Insights
        self._connect_to_insights = False
        self.connect_to_insights_changed = Signal()

        # registration status
        self.registered_changed = Signal()
        self._registered = False

        # subscription status
        self.subscription_attached_changed = Signal()
        self._subscription_attached = False

        # RHSM service startup and access
        self._rhsm_startup_task = StartRHSMTask(verify_ssl=conf.payload.verify_ssl)
        self._rhsm_observer = RHSMObserver(self._rhsm_startup_task.is_service_available)

        # RHSM config default values cache
        self._rhsm_config_defaults = None

    def publish(self):
        """Publish the module."""
        TaskContainer.set_namespace(SUBSCRIPTION.namespace)
        DBus.publish_object(SUBSCRIPTION.object_path, SubscriptionInterface(self))
        DBus.register_service(SUBSCRIPTION.service_name)

    def run(self):
        """Initiate RHSM service startup before starting the main loop.

        This way RHSM service can startup in parallel without blocking
        startup of the Subscription module.
        """
        self._rhsm_startup_task.start()
        super().run()

    @property
    def kickstart_specification(self):
        """Return the kickstart specification."""
        return SubscriptionKickstartSpecification

    def process_kickstart(self, data):
        """Process the kickstart data."""
        log.debug("Processing kickstart data...")

        # system purpose
        #
        # Try if any of the values in kickstart match a valid field.
        # If it does, write the valid field value instead of the value from kickstart.
        #
        # This way a value in kickstart that has a different case and/or trailing white space
        # can still be used to preselect a value in a UI instead of being marked as a custom
        # user specified value.
        system_purpose_data = SystemPurposeData()

        system_purpose_data.role = system_purpose.process_field(
            data.syspurpose.role,
            self.valid_roles,
            "role"
        )

        system_purpose_data.sla = system_purpose.process_field(
            data.syspurpose.sla,
            self.valid_slas,
            "sla"
        )

        system_purpose_data.usage = system_purpose.process_field(
            data.syspurpose.usage,
            self.valid_usage_types,
            "usage"
        )

        if data.syspurpose.addons:
            # As we do not have a list of valid addons available, we just use what was provided
            # by the user in kickstart verbatim.
            system_purpose_data.addons = data.syspurpose.addons

        self.set_system_purpose_data(system_purpose_data)

        # apply system purpose data, if any, so that it is all in place when we start
        # talking to the RHSM service
        if self.system_purpose_data.check_data_available():
            self._apply_syspurpose()

        # subscription request

        subscription_request = SubscriptionRequest()

        # credentials
        if data.rhsm.organization:
            subscription_request.organization = data.rhsm.organization
        if data.rhsm.activation_keys:
            subscription_request.activation_keys.set_secret(data.rhsm.activation_keys)

        # if org id and at least one activation key is set, switch authentication
        # type to ORG & KEY
        if data.rhsm.organization and data.rhsm.activation_keys:
            subscription_request.type = SUBSCRIPTION_REQUEST_TYPE_ORG_KEY

        # custom URLs
        if data.rhsm.server_hostname:
            subscription_request.server_hostname = data.rhsm.server_hostname
        if data.rhsm.rhsm_baseurl:
            subscription_request.rhsm_baseurl = data.rhsm.rhsm_baseurl

        # HTTP proxy
        if data.rhsm.proxy:
            # first try to parse the proxy string from kickstart
            try:
                proxy = ProxyString(data.rhsm.proxy)
                if proxy.host:
                    # ensure port is an integer and set to -1 if unknown
                    port = int(proxy.port) if proxy.port else -1

                    subscription_request.server_proxy_hostname = proxy.host
                    subscription_request.server_proxy_port = port

                    # ensure no username translates to the expected ""
                    # instead of the None returned by the ProxyString class
                    subscription_request.server_proxy_user = proxy.username or ""
                    subscription_request.server_proxy_password.set_secret(proxy.password)
            except ProxyStringError as e:
                # should not be fatal, but definitely logged as error
                message = "Failed to parse proxy for the rhsm command: {}".format(str(e))
                warnings.warn(message, KickstartParseWarning)

        # set the resulting subscription request
        self.set_subscription_request(subscription_request)

        # insights
        self.set_connect_to_insights(bool(data.rhsm.connect_to_insights))

    def setup_kickstart(self, data):
        """Return the kickstart string.

        NOTE: We are not writing out the rhsm command as the input can contain
              sensitive data (activation keys, proxy passwords) that we would have
              to omit from the output kickstart. This in turn would make the rhsm
              command incomplete & would turn the output kickstart invalid as a result.
              For this reason we skip the rhsm command completely in the output
              kickstart.
        """

        # system purpose
        data.syspurpose.role = self.system_purpose_data.role
        data.syspurpose.sla = self.system_purpose_data.sla
        data.syspurpose.usage = self.system_purpose_data.usage
        data.syspurpose.addons = self.system_purpose_data.addons

    # system purpose configuration

    def _load_valid_system_purpose_values(self):
        """Load lists of valid roles, SLAs and usage types.

        About role/sla/validity:
        - an older installation image might have older list of valid fields,
          missing fields that have become valid after the image has been released
        - fields that have been valid in the past might be dropped in the future
        - there is no list of valid addons

        Due to this we need to take into account that the listing might not always be
        comprehensive and that we need to allow what might on a first glance look like
        invalid values to be written to the target system.
        """
        roles, slas, usage_types = system_purpose.get_valid_fields()
        self._valid_roles = roles
        self._valid_slas = slas
        self._valid_usage_types = usage_types

    @property
    def valid_roles(self):
        """Return a list of valid roles.

        :return: list of valid roles
        :rtype: list of strings
        """
        return self._valid_roles

    @property
    def valid_slas(self):
        """Return a list of valid SLAs.

        :return: list of valid SLAs
        :rtype: list of strings
        """
        return self._valid_slas

    @property
    def valid_usage_types(self):
        """Return a list of valid usage types.

        :return: list of valid usage types
        :rtype: list of strings
        """
        return self._valid_usage_types

    @property
    def system_purpose_data(self):
        """System purpose data.

        A DBus structure holding information about system purpose,
        such as role, sla, usage and addons.

        :return: system purpose DBus structure
        :rtype: DBusData instance
        """
        return self._system_purpose_data

    def set_system_purpose_data(self, system_purpose_data):
        """Set system purpose data.

        Set the complete DBus structure containing system purpose data.

        :param system_purpose_data: system purpose data structure to be set
        :type system_purpose_data: DBus structure
        """
        self._system_purpose_data = system_purpose_data
        self.system_purpose_data_changed.emit()
        log.debug("System purpose data set to %s.", system_purpose_data)

    def _apply_syspurpose(self):
        """Apply system purpose information to the installation environment."""
        log.debug("subscription: Applying system purpose data")
        task = self.set_system_purpose_with_task()
        task.run()

    def set_system_purpose_with_task(self):
        """Set system purpose for the installed system with an installation task.
        :return: a DBus path of an installation task
        """
        rhsm_syspurpose_proxy = self.rhsm_observer.get_proxy(RHSM_SYSPURPOSE)
        task = SystemPurposeConfigurationTask(
            rhsm_syspurpose_proxy=rhsm_syspurpose_proxy,
            system_purpose_data=self.system_purpose_data
        )
        return task

    # subscription request

    @property
    def subscription_request(self):
        """Subscription request.

        A DBus structure holding data to be used to subscribe the system.

        :return: subscription request DBus structure
        :rtype: DBusData instance
        """
        # Return a deep copy of the subscription request that
        # has also been cleared of private data.
        # Thankfully the secret Dbus structures modules
        # has the get_public_copy() method that does just
        # that. It creates a deep copy & clears
        # all SecretData and SecretDataList instances.
        return get_public_copy(self._subscription_request)

    def set_subscription_request(self, subscription_request):
        """Set a subscription request.

        Set the complete DBus structure containing subscription
        request data.

        :param subscription_request: subscription request structure to be set
        :type subscription_request: DBus structure
        """
        self._replace_current_subscription_request(subscription_request)
        self.subscription_request_changed.emit()
        log.debug("A subscription request set: %s", str(self._subscription_request))

    @property
    def attached_subscriptions(self):
        """A list of attached subscriptions.

        The list holds DBus structures with each structure holding information about
        one attached subscription. A system that has been successfully registered and
        subscribed usually has one or more subscriptions attached.

        :return: list of DBus structures, one per attached subscription
        :rtype: list of AttachedSubscription instances
        """
        return self._attached_subscriptions

    def set_attached_subscriptions(self, attached_subscriptions):
        """Set the list of attached subscriptions.

        :param attached_subscriptions: list of attached subscriptions to be set
        :type attached_subscriptions: list of AttachedSubscription instances
        """
        self._attached_subscriptions = attached_subscriptions
        self.attached_subscriptions_changed.emit()
        # as there is no public setter in the DBus API, we need to emit
        # the properties changed signal here manually
        self.module_properties_changed.emit()
        log.debug("Attached subscriptions set: %s", str(self._attached_subscriptions))

    def _replace_current_subscription_request(self, new_request):
        """Replace current subscription request without loosing sensitive data.

        We need to do this to prevent blank SecretData & SecretDataList instances
        from wiping out previously set secret data. The instances will be blank
        every time a SubscriptionRequest that went through get_public_copy() comes
        back with the secret data fields unchanged.

        So what we do is depends on type of the incoming secret data:

        - SECRET_TYPE_NONE - use structure from new request unchanged,
                             clearing previously set data (if any)
        - SECRET_TYPE_HIDDEN - secret data has been set previously and
                               cleared when SubscriptionRequest was sent out;
                               put secret data from current request to the
                               new one to prevent it from being lost
                               (this will also switch the secret data
                                instance to SECRET_TYPE_TEXT so that
                                the Subscription module can read it
                                internally)
        - SECRET_TYPE_TEXT - this is new secret entry, we can keep it as is
        """
        current_request = self._subscription_request

        # Red Hat account password
        if new_request.account_password.type == SECRET_TYPE_HIDDEN:
            new_request.account_password = copy.deepcopy(
                current_request.account_password)

        # activation keys used together with an organization id
        if new_request.activation_keys.type == SECRET_TYPE_HIDDEN:
            new_request.activation_keys = copy.deepcopy(
                current_request.activation_keys)

        # RHSM HTTP proxy password
        if new_request.server_proxy_password.type == SECRET_TYPE_HIDDEN:
            new_request.server_proxy_password = copy.deepcopy(
                    current_request.server_proxy_password)

        # replace current request
        self._subscription_request = new_request

    @property
    def connect_to_insights(self):
        """Indicates if the target system should be connected to Red Hat Insights.

        :return: True to connect, False not to connect the target system to Insights
        :rtype: bool
        """
        return self._connect_to_insights

    def set_connect_to_insights(self, connect):
        """Set if the target system should be connected to Red Hat Insights.

        :param bool connect: set to True to connect, set to False not to connect
        """
        self._connect_to_insights = connect
        self.connect_to_insights_changed.emit()
        log.debug("Connect target system to Insights set to: %s", self._connect_to_insights)

    # registration status

    @property
    def registered(self):
        """Return True if the system has been registered.

        NOTE: Together with the subscription_attached property
              the registered property can be used to detect that
              the system is registered but has not subscription
              attached. This is generally a sign something went
              wrong, usually when trying to attach subscription.

        :return: True if the system has been registered, False otherwise
        :rtype: bool
        """
        return self._registered

    def set_registered(self, system_registered):
        """Set if the system is registered.

        :param bool system_registered: True if system has been registered, False otherwise
        """
        self._registered = system_registered
        self.registered_changed.emit()
        # as there is no public setter in the DBus API, we need to emit
        # the properties changed signal here manually
        self.module_properties_changed.emit()
        log.debug("System registered set to: %s", system_registered)

    # subscription status

    @property
    def subscription_attached(self):
        """Return True if a subscription has been attached to the system.

        :return: True if a subscription has been attached to the system, False otherwise
        :rtype: bool
        """
        return self._subscription_attached

    def set_subscription_attached(self, system_subscription_attached):
        """Set a subscription has been attached to the system.

        :param bool system_registered: True if subscription has been attached, False otherwise
        """
        self._subscription_attached = system_subscription_attached
        self.subscription_attached_changed.emit()
        # as there is no public setter in the DBus API, we need to emit
        # the properties changed signal here manually
        self.module_properties_changed.emit()
        log.debug("Subscription attached set to: %s", system_subscription_attached)

    # tasks

    def install_with_tasks(self):
        """Return the installation tasks of this module.

        Order of execution is important:
        - before transferring subscription tokens we need to restore
          the INFO log level in rhsm.conf or else target system will
          end up with RHSM logging in DEBUG mode
        - transfer subscription tokens
        - connect to insights, this can run only once subscription
          tokens are in place on the target system or else it would
          fail as Insights client needs the subscription tokens to
          authenticate to the Red Hat Insights online service

        :returns: list of installation tasks
        """
        return [
            RestoreRHSMDefaultsTask(
                rhsm_config_proxy=self.rhsm_observer.get_proxy(RHSM_CONFIG)
            ),
            TransferSubscriptionTokensTask(
                sysroot=conf.target.system_root,
                transfer_subscription_tokens=self.subscription_attached
            ),
            ConnectToInsightsTask(
                sysroot=conf.target.system_root,
                subscription_attached=self.subscription_attached,
                connect_to_insights=self.connect_to_insights
            )
        ]

    # RHSM DBus API access

    @property
    def rhsm_observer(self):
        """Provide access to the RHSM DBus service observer.

        This observer handles various peculiarities of the
        RHSM DBus API startup and should be used as the
        only access point to the RHSM Dbus API.

        If you need to RHSM DBus API object, just call the
        get_proxy() method of the observer with object
        identifier.

        :return: RHSM DBus API observer
        :rtype: RHSMObserver instance
        """
        return self._rhsm_observer

    def _flatten_rhsm_nested_dict(self, nested_dict):
        """Convert the GetAll() returned nested dict into a flat one.

        RHSM returns a nested dict with categories on top
        and category keys & values inside. This is not convenient
        for setting keys based on original values, so
        let's normalize the dict to the flat key based
        structure similar to what's used by SetAll().

        :param dict nested_dict: the nested dict returned by GetAll()
        :return: flat key/value dictionary, similar to format used by SetAll()
        :rtype: dict
        """
        flat_dict = {}
        for category_key, category_dict in nested_dict.items():
            for key, value in category_dict.items():
                flat_key = "{}.{}".format(category_key, key)
                flat_dict[flat_key] = value
        return flat_dict

    def get_rhsm_config_defaults(self):
        """Return RHSM config default values.

        We need to have these available in case the user decides
        to return to default values from a custom value at
        runtime.

        This method is lazy evaluated, the first call it fetches
        the full config dict from RHSM and subsequent calls are
        then served from cache.

        Due to this it is important not to set RHSM configuration
        values before first calling this method to populate the cache
        or else the method might return non-default (Anaconda overwritten)
        data.

        NOTE: While RHSM GetAll() DBus call returns a nested dictionary,
              we turn it into a flat key/value dict, in the same format SetAll()
              uses.

        :return : dictionary of default RHSM configuration values
        :rtype: dict
        """
        if self._rhsm_config_defaults is None:
            # config defaults cache not yet populated, do it now
            proxy = self.rhsm_observer.get_proxy(RHSM_CONFIG)
            # turn the variant into a dict with get_native()
            nested_dict = get_native(proxy.GetAll(""))
            # flatten the nested dict
            flat_dict = self._flatten_rhsm_nested_dict(nested_dict)
            self._rhsm_config_defaults = flat_dict
        return self._rhsm_config_defaults

    def set_rhsm_config_with_task(self):
        """Set RHSM config values based on current subscription request.

        :return: a DBus path of an installation task
        """
        # NOTE: we access self._subscription_request directly
        #       to avoid the sensitive data clearing happening
        #       in the subscription_request property getter
        rhsm_config_proxy = self.rhsm_observer.get_proxy(RHSM_CONFIG)
        task = SetRHSMConfigurationTask(rhsm_config_proxy=rhsm_config_proxy,
                                        rhsm_config_defaults=self.get_rhsm_config_defaults(),
                                        subscription_request=self._subscription_request)
        return task

    def register_username_password_with_task(self):
        """Register with username and password based on current subscription request.

        :return: a DBus path of an installation task
        """
        # NOTE: we access self._subscription_request directly
        #       to avoid the sensitive data clearing happening
        #       in the subscription_request property getter
        username = self._subscription_request.account_username
        password = self._subscription_request.account_password.value
        register_server_proxy = self.rhsm_observer.get_proxy(RHSM_REGISTER_SERVER)
        task = RegisterWithUsernamePasswordTask(rhsm_register_server_proxy=register_server_proxy,
                                                username=username,
                                                password=password)
        # if the task succeeds, it means the system has been registered
        task.succeeded_signal.connect(
            lambda: self.set_registered(True))
        return task

    def register_organization_key_with_task(self):
        """Register with organization and activation key(s) based on current subscription request.

        :return: a DBus path of an installation task
        """
        # NOTE: we access self._subscription_request directly
        #       to avoid the sensitive data clearing happening
        #       in the subscription_request property getter
        organization = self._subscription_request.organization
        activation_keys = self._subscription_request.activation_keys.value
        register_server_proxy = self.rhsm_observer.get_proxy(RHSM_REGISTER_SERVER)
        task = RegisterWithOrganizationKeyTask(rhsm_register_server_proxy=register_server_proxy,
                                               organization=organization,
                                               activation_keys=activation_keys)
        # if the task succeeds, it means the system has been registered
        task.succeeded_signal.connect(
            lambda: self.set_registered(True))
        return task

    def unregister_with_task(self):
        """Unregister the system.

        :return: a DBus path of an installation task
        """
        rhsm_unregister_proxy = self.rhsm_observer.get_proxy(RHSM_UNREGISTER)
        task = UnregisterTask(rhsm_unregister_proxy=rhsm_unregister_proxy)
        # we will no longer be registered and subscribed if the task is successful,
        # so set the corresponding properties appropriately
        task.succeeded_signal.connect(
            lambda: self.set_registered(False))
        task.succeeded_signal.connect(
            lambda: self.set_subscription_attached(False))
        # and clear attached subscriptions
        task.succeeded_signal.connect(
            lambda: self.set_attached_subscriptions([]))
        return task

    def attach_subscription_with_task(self):
        """Attach a subscription.

        This should only be run on a system that has been successfully registered.
        Attached subscription depends on system type, system purpose data
        and entitlements available for the account that has been used for registration.

        :return: a DBus path of an installation task
        """
        sla = self.system_purpose_data.sla
        rhsm_attach_proxy = self.rhsm_observer.get_proxy(RHSM_ATTACH)
        task = AttachSubscriptionTask(rhsm_attach_proxy=rhsm_attach_proxy,
                                      sla=sla)
        # if the task succeeds, it means a subscription has been attached
        task.succeeded_signal.connect(
            lambda: self.set_subscription_attached(True))
        return task

    def _set_system_subscription_data(self, system_subscription_data):
        """A helper method invoked in ParseAttachedSubscritionsTask completed signal.

        :param system_subscription_data: a named tuple holding attached subscriptions
                                         and final system purpose data
        """
        self.set_attached_subscriptions(system_subscription_data.attached_subscriptions)
        self.set_system_purpose_data(system_subscription_data.system_purpose_data)

    def parse_attached_subscriptions_with_task(self):
        """Parse attached subscriptions with task.

        Parse data about attached subscriptions and final system purpose data.
        This data is available as JSON strings via the RHSM DBus API.

        :return: a DBus path of an installation task
        """
        rhsm_entitlement_proxy = self.rhsm_observer.get_proxy(RHSM_ENTITLEMENT)
        rhsm_syspurpose_proxy = self.rhsm_observer.get_proxy(RHSM_SYSPURPOSE)
        task = ParseAttachedSubscriptionsTask(rhsm_entitlement_proxy=rhsm_entitlement_proxy,
                                              rhsm_syspurpose_proxy=rhsm_syspurpose_proxy)
        # if the task succeeds, set attached subscriptions and system purpose data
        task.succeeded_signal.connect(
            lambda: self._set_system_subscription_data(task.get_result())
        )
        return task

    def collect_requirements(self):
        """Return installation requirements for this module.

        :return: a list of requirements
        """
        requirements = []
        # check if we need the insights-client package, which is needed to connect the
        # target system to Red Hat Insights
        if self.subscription_attached and self.connect_to_insights:
            # establishing a connection to Red Hat Insights has been requested
            # and we need the insights-client package to be present in the
            # target system chroot for that
            requirements.append(
                Requirement.for_package(
                    "insights-client",
                    reason="Needed to connect the target system to Red Hat Insights."
                )
            )
        return requirements
