import contextlib
import functools
import os
import re
import time

from leapp import reporting
from leapp.exceptions import StopActorExecutionError
from leapp.libraries.common import repofileutils
from leapp.libraries.common.config import get_env
from leapp.libraries.stdlib import api, CalledProcessError
from leapp.models import RHSMInfo

_RE_REPO_UID = re.compile(r'Repo ID:\s*([^\s]+)')
_RE_RELEASE = re.compile(r'Release:\s*([^\s]+)')
_RE_SKU_CONSUMED = re.compile(r'SKU:\s*([^\s]+)')
_ATTEMPTS = 5
_RETRY_SLEEP = 5
_DEFAULT_RHSM_REPOFILE = '/etc/yum.repos.d/redhat.repo'

SCA_TEXT = "Content Access Mode is set to Simple Content Access"


def _rhsm_retry(max_attempts, sleep=None):
    """
    A decorator to retry executing a function/method if unsuccessful.

    The function/method execution is considered unsuccessful when it raises StopActorExecutionError.

    :param max_attempts: Maximum number of attempts to execute the decorated function/method.
    :param sleep: Time to wait between attempts. In seconds.
    """
    def impl(f):
        @functools.wraps(f)
        def wrapper(*args, **kwargs):
            attempts = 0
            while True:
                attempts += 1
                try:
                    return f(*args, **kwargs)
                except StopActorExecutionError:
                    if max_attempts <= attempts:
                        api.current_logger().warning(
                            'Attempt %d of %d to perform %s failed. Maximum number of retries has been reached.',
                            attempts, max_attempts, f.__name__)
                        raise
                    if sleep:
                        api.current_logger().info(
                            'Attempt %d of %d to perform %s failed - Retrying after %s seconds',
                            attempts, max_attempts, f.__name__, str(sleep))
                        time.sleep(sleep)
                    else:
                        api.current_logger().info(
                            'Attempt %d of %d to perform %s failed - Retrying...', attempts, max_attempts, f.__name__)
        return wrapper
    return impl


@contextlib.contextmanager
def _handle_rhsm_exceptions(hint=None):
    """
    Context manager based function that handles exceptions of `run` for the subscription-manager calls.
    """
    try:
        yield
    except OSError as e:
        api.current_logger().error('Failed to execute subscription-manager executable')
        raise StopActorExecutionError(
            message='Unable to execute subscription-manager executable: {}'.format(str(e)),
            details={
                'hint': 'Please ensure subscription-manager is installed and executable.'
            }
        )
    except CalledProcessError as e:
        _def_hint = (
            'Please ensure you have a valid RHEL subscription and your network is up.'
            ' If you are using proxy for Red Hat subscription-manager, please make sure'
            ' it is specified inside the /etc/rhsm/rhsm.conf file.'
            ' Or use the --no-rhsm option when running leapp, if you do not want to'
            ' use subscription-manager for the in-place upgrade and you want to'
            ' deliver all target repositories by yourself or using RHUI on public cloud.'
        )
        raise StopActorExecutionError(
            message='A subscription-manager command failed to execute',
            details={
                'details': str(e),
                'stderr': e.stderr,
                'hint': hint or _def_hint
            }
        )


def skip_rhsm():
    """Check whether we should skip RHSM related code."""
    return get_env('LEAPP_NO_RHSM', '0') == '1'


def with_rhsm(f):
    """Decorator to allow skipping RHSM functions by executing a no-op."""
    @functools.wraps(f)
    def wrapper(*args, **kwargs):
        if not skip_rhsm():
            return f(*args, **kwargs)
        return None
    return wrapper


@with_rhsm
def get_attached_skus(context):
    """
    Retrieve the list of the SKUs the system is attached to with the subscription-manager.

    :param context: An instance of a mounting.IsolatedActions class
    :type context: mounting.IsolatedActions class
    :return: SKUs the current system is attached to.
    :rtype: List(string)
    """
    with _handle_rhsm_exceptions():
        result = context.call(['subscription-manager', 'list', '--consumed'], split=False)
        return _RE_SKU_CONSUMED.findall(result['stdout'])


@with_rhsm
def get_rhsm_status(context):
    """
    Retrieve "subscription-manager status" output.

    :param context: An instance of a mounting.IsolatedActions class
    :type context: mounting.IsolatedActions class
    :return: "subscription manager status" output
    :rtype: String
    """
    with _handle_rhsm_exceptions():
        result = context.call(['subscription-manager', 'status'])
        return result['stdout']


def is_manifest_sca(context):
    """
    Check if SCA manifest is used in Satellite.

    :param context: An instance of a mounting.IsolatedActions class
    :type context: mounting.IsolatedActions class
    :return: True if SCA manifest is used else False
    :rtype: Boolean
    """
    return SCA_TEXT in get_rhsm_status(context)


def get_available_repo_ids(context):
    """
    Retrieve repo ids of all the repositories available through the subscription-manager.

    :param context: An instance of a mounting.IsolatedActions class
    :type context: mounting.IsolatedActions class
    :return: Repositories that are available to the current system through the subscription-manager
    :rtype: List(string)
    """
    # Regenerated redhat.repo file ...
    # FIXME: try to come up with something less invasive than yum clean all
    # but still safe to call..
    cmd = ['yum', 'clean', 'all']
    try:
        context.call(cmd)
    except CalledProcessError as exc:
        raise StopActorExecutionError(
            'Unable to use yum successfully',
            details={'details': str(exc), 'stderr': exc.stderr}
        )

    repofiles = repofileutils.get_parsed_repofiles(context)

    # TODO: move this functionality out! Create check actor that will do
    # the inhibit. The functionality is really not good here in the current
    # shape of the leapp-repository. See the targetuserspacecreator and
    # systemfacts actor if this is moved out.
    # Issue: #486
    _inhibit_on_duplicate_repos(repofiles)
    rhsm_repos = []
    for rfile in repofiles:
        if rfile.file == _DEFAULT_RHSM_REPOFILE and rfile.data:
            rhsm_repos = [repo.repoid for repo in rfile.data]
            rhsm_repos.sort()
            break

    list_separator_fmt = '\n    - '
    if rhsm_repos:
        api.current_logger().info('The following repoids are available through RHSM:{0}{1}'
                                  .format(list_separator_fmt, list_separator_fmt.join(rhsm_repos)))
    else:
        api.current_logger().info('There are no repos available through RHSM.')
    return rhsm_repos


def _inhibit_on_duplicate_repos(repofiles):
    """
    Inhibit the upgrade if any repoid is defined multiple times.

    When that happens, it not only shows misconfigured system, but then
    we can't get details of all the available repos as well.
    """
    duplicates = repofileutils.get_duplicate_repositories(repofiles).keys()

    if not duplicates:
        return
    list_separator_fmt = '\n    - '
    api.current_logger().warning(
        'The following repoids are defined multiple times:{0}{1}'
        .format(list_separator_fmt, list_separator_fmt.join(duplicates))
    )

    reporting.create_report([
        reporting.Title('A YUM/DNF repository defined multiple times'),
        reporting.Summary(
            'The following repositories are defined multiple times:{0}{1}'
            .format(list_separator_fmt, list_separator_fmt.join(duplicates))
        ),
        reporting.Severity(reporting.Severity.MEDIUM),
        reporting.Groups([reporting.Groups.REPOSITORY]),
        reporting.Groups([reporting.Groups.INHIBITOR]),
        reporting.Remediation(hint='Remove the duplicate repository definitions.')
    ])


@with_rhsm
def get_enabled_repo_ids(context):
    """
    Retrieve repo ids of all the repositories enabled through the subscription-manager.

    :param context: An instance of a mounting.IsolatedActions class
    :type context: mounting.IsolatedActions class
    :return: Repositories that are enabled on the current system through the subscription-manager.
    :rtype: List(string)
    """
    with _handle_rhsm_exceptions():
        result = context.call(['subscription-manager', 'repos', '--list-enabled'], split=False)
        return _RE_REPO_UID.findall(result['stdout'])


@with_rhsm
@_rhsm_retry(max_attempts=_ATTEMPTS, sleep=_RETRY_SLEEP)
def unset_release(context):
    """
    Unset the configured release from the subscription-manager.

    :param context: An instance of a mounting.IsolatedActions class
    :type context: mounting.IsolatedActions class
    """
    with _handle_rhsm_exceptions():
        context.call(['subscription-manager', 'release', '--unset'], split=False)


@with_rhsm
@_rhsm_retry(max_attempts=_ATTEMPTS, sleep=_RETRY_SLEEP)
def set_release(context, release):
    """
    Set the release (RHEL minor version) through the subscription-manager.

    :param context: An instance of a mounting.IsolatedActions class
    :type context: mounting.IsolatedActions class
    :param release: Release to set the subscription-manager to.
    :type release: str
    """
    with _handle_rhsm_exceptions():
        context.call(['subscription-manager', 'release', '--set', release], split=False)


@with_rhsm
def get_release(context):
    """
    Retrieves the release the subscription-manager has been pinned to, if applicable.

    :param context: An instance of a mounting.IsolatedActions class
    :type context: mounting.IsolatedActions class
    :return: Release the subscription-manager is set to.
    :rtype: string
    """
    with _handle_rhsm_exceptions():
        result = context.call(['subscription-manager', 'release'], split=False)
        result = _RE_RELEASE.findall(result['stdout'])
        return result[0] if result else ''


@with_rhsm
@_rhsm_retry(max_attempts=_ATTEMPTS, sleep=_RETRY_SLEEP)
def refresh(context):
    """
    Calls 'subscription-manager refresh'

    :param context: An instance of a mounting.IsolatedActions class
    :type context: mounting.IsolatedActions class
    """
    with _handle_rhsm_exceptions():
        context.call(['subscription-manager', 'refresh'], split=False)


@with_rhsm
def get_existing_product_certificates(context):
    """
    Retrieves information about existing product certificates on the system.

    :param context: An instance of a mounting.IsolatedActions class
    :type context: mounting.IsolatedActions class
    :return: Paths to product certificates that are currently installed on the system.
    :rtype: List(string)
    """
    certs = []
    for path in ('/etc/pki/product', '/etc/pki/product-default'):
        if not os.path.isdir(context.full_path(path)):
            continue
        curr_certs = [os.path.join(path, f) for f in os.listdir(context.full_path(path))
                      if os.path.isfile(os.path.join(context.full_path(path), f))]
        if curr_certs:
            certs.extend(curr_certs)
    return certs


# DO NOT SET the with_rhsm decorator for this function
def set_container_mode(context):
    """
    Put RHSM into the container mode.

    Inside the container, we have to ensure the RHSM is not used AND that host
    is not affected. If the RHSM is not set into the container mode, the host
    could be affected and the generated repo file in the container could be
    affected as well (e.g. when the release is set, using rhsm, on the host).

    We want to put RHSM into the container mode always when /etc/rhsm and
    /etc/pki/entitlement directories exists, even when leapp is executed with
    --no-rhsm option. If any of these directories are missing, skip other
    actions - most likely RHSM is not installed in such a case.

    :param context: An instance of a mounting.IsolatedActions class
    :type context: mounting.IsolatedActions class
    """
    if not context.is_isolated():
        api.current_logger().error('Trying to set RHSM into the container mode'
                                   'on host. Skipping the action.')
        return
    # TODO(pstodulk): check "rhsm identity" whether system is registered
    # and the container mode should be required
    if (not os.path.exists(context.full_path('/etc/rhsm'))
            or not os.path.exists(context.full_path('/etc/pki/entitlement'))):
        api.current_logger().warning(
            'Cannot set the container mode for the subscription-manager as'
            ' one of required directories is missing. Most likely RHSM is not'
            ' installed. Skipping other actions.'
        )
        return

    try:
        context.call(['ln', '-s', '/etc/rhsm', '/etc/rhsm-host'])
        context.call(['ln', '-s', '/etc/pki/entitlement', '/etc/pki/entitlement-host'])
    except CalledProcessError:
        raise StopActorExecutionError(
                message='Cannot set the container mode for the subscription-manager.')


@with_rhsm
def switch_certificate(context, rhsm_info, cert_path):
    """
    Perform all actions needed to switch the passed RHSM product certificate.

    This function will copy the certificate to /etc/pki/product, and /etc/pki/product-default if necessary, and
    remove other product certificates from there.

    :param context: An instance of a mounting.IsolatedActions class
    :type context: mounting.IsolatedActions class
    :param rhsm_info: An instance of the RHSMInfo model
    :type rhsm_info: RHSMInfo model
    :param cert_path: Path to the product certificate to switch to
    :type cert_path: string
    """
    for existing in rhsm_info.existing_product_certificates:
        try:
            context.remove(existing)
        except OSError:
            api.current_logger().warning('Failed to remove existing certificate: %s', existing, exc_info=True)

    for path in ('/etc/pki/product', '/etc/pki/product-default'):
        if os.path.isdir(context.full_path(path)):
            context.copy_to(cert_path, os.path.join(path, os.path.basename(cert_path)))


@with_rhsm
def scan_rhsm_info(context):
    """
    Gather all the RHSM information of the source system.

    It's not intended for gathering RHSM info about the target system within a container.

    :param context: An instance of a mounting.IsolatedActions class
    :type context: mounting.IsolatedActions class
    :return: An instance of an RHSMInfo model.
    :rtype: RHSMInfo model
    """
    info = RHSMInfo()
    info.attached_skus = get_attached_skus(context)
    info.available_repos = get_available_repo_ids(context)
    info.enabled_repos = get_enabled_repo_ids(context)
    info.release = get_release(context)
    info.existing_product_certificates.extend(get_existing_product_certificates(context))
    info.sca_detected = is_manifest_sca(context)
    return info
