'''
The pyinfra facts API. Facts enable pyinfra to collect remote server state which
is used to "diff" with the desired state, producing the final commands required
for a deploy.
'''

from __future__ import division, unicode_literals

import re

from socket import (
    error as socket_error,
    timeout as timeout_error,
)

import click
import gevent
import six

from gevent.lock import BoundedSemaphore
from paramiko import SSHException

from pyinfra import logger
from pyinfra.api.connectors.util import split_combined_output
from pyinfra.api.util import (
    get_arg_value,
    log_error_or_warning,
    log_host_command_error,
    make_hash,
    print_host_combined_output,
    underscore,
)
from pyinfra.progress import progress_spinner


# Index of snake_case facts -> CamelCase classes
FACTS = {}
FACT_LOCK = BoundedSemaphore()

SUDO_REGEX = r'^sudo: unknown user:'
SU_REGEXES = (
    r'^su: user .+ does not exist',
    r'^su: unknown login',
)


def is_fact(name):
    return name in FACTS


def get_fact_class(name):
    return FACTS[name]


def get_fact_names():
    '''
    Returns a list of available facts in camel_case format.
    '''

    return list(six.iterkeys(FACTS))


class FactMeta(type):
    '''
    Metaclass to dynamically build the facts index.
    '''

    def __init__(cls, name, bases, attrs):
        if attrs.get('abstract'):
            return

        fact_name = underscore(name)
        cls.name = fact_name

        # Get the an instance of the fact, attach to facts
        FACTS[fact_name] = cls


@six.add_metaclass(FactMeta)
class FactBase(object):
    abstract = True

    shell_executable = None

    requires_command = None

    @staticmethod
    def default():
        '''
        Set the default attribute to be a type (eg list/dict).
        '''

    @staticmethod
    def process(output):
        return '\n'.join(output)

    def process_pipeline(self, args, output):
        return {
            arg: self.process([output[i]])
            for i, arg in enumerate(args)
        }


@six.add_metaclass(FactMeta)
class ShortFactBase(object):
    fact = None


def get_short_facts(state, short_fact, **kwargs):
    facts = get_facts(state, short_fact.fact.name, **kwargs)

    return {
        host: short_fact.process_data(data)
        for host, data in six.iteritems(facts)
    }


def _make_command(command_attribute, host_args):
    if callable(command_attribute):
        return command_attribute(*host_args)
    return command_attribute


def get_facts(state, name, args=None, ensure_hosts=None, apply_failed_hosts=True):
    '''
    Get a single fact for all hosts in the state.
    '''

    # Create an instance of the fact
    fact = get_fact_class(name)()

    if isinstance(fact, ShortFactBase):
        return get_short_facts(state, fact, args=args, ensure_hosts=ensure_hosts)

    logger.debug('Getting fact: {0} (ensure_hosts: {1})'.format(
        name, ensure_hosts,
    ))

    args = args or []

    # Apply args or defaults
    sudo = state.config.SUDO
    sudo_user = state.config.SUDO_USER
    su_user = state.config.SU_USER
    ignore_errors = state.config.IGNORE_ERRORS
    shell_executable = state.config.SHELL
    use_sudo_password = state.config.USE_SUDO_PASSWORD

    # Facts can override the shell (winrm powershell vs cmd support)
    if fact.shell_executable:
        shell_executable = fact.shell_executable

    # Timeout for operations !== timeout for connect (config.CONNECT_TIMEOUT)
    timeout = None

    # If inside an operation, fetch global arguments
    current_global_kwargs = state.current_op_global_kwargs
    if current_global_kwargs:
        sudo = current_global_kwargs['sudo']
        sudo_user = current_global_kwargs['sudo_user']
        use_sudo_password = current_global_kwargs['use_sudo_password']
        su_user = current_global_kwargs['su_user']
        ignore_errors = current_global_kwargs['ignore_errors']
        timeout = current_global_kwargs['timeout']

    # Make a hash which keeps facts unique - but usable cross-deploy/threads.
    # Locks are used to maintain order.
    fact_hash = make_hash((name, args, sudo, sudo_user, su_user, ignore_errors))

    # Already got this fact? Unlock and return them
    current_facts = state.facts.get(fact_hash, {})
    if current_facts:
        if not ensure_hosts or all(
            host in current_facts for host in ensure_hosts
        ):
            return current_facts

    with FACT_LOCK:
        # Add any hosts we must have, whether considered in the inventory or not
        # (these hosts might be outside the --limit or current op limit_hosts).
        hosts = set(state.inventory.iter_active_hosts())
        if ensure_hosts:
            hosts.update(ensure_hosts)

        # Execute the command for each state inventory in a greenlet
        greenlet_to_host = {}

        for host in hosts:
            if host in current_facts:
                continue

            # Generate actual arguments by passing strings as jinja2 templates
            host_args = [get_arg_value(state, host, arg) for arg in args]

            command = _make_command(fact.command, host_args)
            requires_command = _make_command(fact.requires_command, host_args)
            if requires_command:
                command = '! command -v {0} > /dev/null || ({1})'.format(
                    requires_command, command,
                )

            greenlet = state.fact_pool.spawn(
                host.run_shell_command,
                command,
                sudo=sudo,
                sudo_user=sudo_user,
                use_sudo_password=use_sudo_password,
                su_user=su_user,
                timeout=timeout,
                shell_executable=shell_executable,
                print_output=state.print_fact_output,
                print_input=state.print_fact_input,
                return_combined_output=True,
            )
            greenlet_to_host[greenlet] = host

        # Wait for all the commands to execute
        progress_prefix = 'fact: {0}'.format(name)
        if args:
            progress_prefix = '{0}{1}'.format(progress_prefix, args[0])

        with progress_spinner(
            greenlet_to_host.values(),
            prefix_message=progress_prefix,
        ) as progress:
            for greenlet in gevent.iwait(greenlet_to_host.keys()):
                host = greenlet_to_host[greenlet]
                progress(host)

        hostname_facts = {}
        failed_hosts = set()

        # Collect the facts and any failures
        for greenlet, host in six.iteritems(greenlet_to_host):
            status = False
            stdout = []

            try:
                status, combined_output_lines = greenlet.get()

            except (timeout_error, socket_error, SSHException) as e:
                failed_hosts.add(host)
                log_host_command_error(
                    host, e,
                    timeout=timeout,
                )

            stdout, stderr = split_combined_output(combined_output_lines)

            data = fact.default()

            if status:
                if stdout:
                    data = fact.process(stdout)

            elif stderr:
                first_line = stderr[0]
                if (
                    sudo_user
                    and state.will_add_user(sudo_user)
                    and re.match(SUDO_REGEX, first_line)
                ):
                    status = True
                if (
                    su_user
                    and state.will_add_user(su_user)
                    and any(re.match(regex, first_line) for regex in SU_REGEXES)
                ):
                    status = True

            if not status:
                failed_hosts.add(host)

                if not state.print_fact_output:
                    print_host_combined_output(host, combined_output_lines)

                log_error_or_warning(host, ignore_errors, description=(
                    'could not load fact: {0}{1}'
                ).format(name, args or ''))

            hostname_facts[host] = data

        log_name = click.style(name, bold=True)

        filtered_args = list(filter(None, args))
        if filtered_args:
            log = 'Loaded fact {0}: {1}'.format(log_name, tuple(filtered_args))
        else:
            log = 'Loaded fact {0}'.format(log_name)

        if state.print_fact_info:
            logger.info(log)
        else:
            logger.debug(log)

        # Check we've not failed
        if not ignore_errors and apply_failed_hosts:
            state.fail_hosts(failed_hosts)

        # Assign the facts
        state.facts.setdefault(fact_hash, {}).update(hostname_facts)

    return state.facts[fact_hash]


def get_host_fact(state, host, name):
    '''
    Wrapper around ``get_facts`` returning facts for one host or a function
    that does.
    '''

    # Expecting a function to return
    if callable(getattr(FACTS[name], 'command', None)):
        def wrapper(*args):
            fact_data = get_facts(state, name, args=args, ensure_hosts=(host,))
            return fact_data.get(host)
        return wrapper

    # Expecting the fact as a return value
    else:
        # Get the fact
        fact_data = get_facts(state, name, ensure_hosts=(host,))
        return fact_data.get(host)


def create_host_fact(state, host, name, data, args=None):
    fact_data = get_facts(state, name, args=args, ensure_hosts=(host,))
    fact_data[host] = data


def delete_host_fact(state, host, name, args=None):
    fact_data = get_facts(state, name, args=args, ensure_hosts=(host,))
    fact_data.pop(host, None)

2751b22d84b6759e6985ee1bcbcfc60158c54e44
<mask token>


class FactMeta(type):
    """
    Metaclass to dynamically build the facts index.
    """

    def __init__(cls, name, bases, attrs):
        if attrs.get('abstract'):
            return
        fact_name = underscore(name)
        cls.name = fact_name
        FACTS[fact_name] = cls


@six.add_metaclass(FactMeta)
class FactBase(object):
    abstract = True
    shell_executable = None
    requires_command = None

    @staticmethod
    def default():
        """
        Set the default attribute to be a type (eg list/dict).
        """

    @staticmethod
    def process(output):
        return '\n'.join(output)

    def process_pipeline(self, args, output):
        return {arg: self.process([output[i]]) for i, arg in enumerate(args)}


@six.add_metaclass(FactMeta)
class ShortFactBase(object):
    fact = None


def get_short_facts(state, short_fact, **kwargs):
    facts = get_facts(state, short_fact.fact.name, **kwargs)
    return {host: short_fact.process_data(data) for host, data in six.
        iteritems(facts)}


<mask token>


def delete_host_fact(state, host, name, args=None):
    fact_data = get_facts(state, name, args=args, ensure_hosts=(host,))
    fact_data.pop(host, None)

<mask token>


def get_fact_class(name):
    return FACTS[name]


<mask token>


class FactMeta(type):
    """
    Metaclass to dynamically build the facts index.
    """

    def __init__(cls, name, bases, attrs):
        if attrs.get('abstract'):
            return
        fact_name = underscore(name)
        cls.name = fact_name
        FACTS[fact_name] = cls


@six.add_metaclass(FactMeta)
class FactBase(object):
    abstract = True
    shell_executable = None
    requires_command = None

    @staticmethod
    def default():
        """
        Set the default attribute to be a type (eg list/dict).
        """

    @staticmethod
    def process(output):
        return '\n'.join(output)

    def process_pipeline(self, args, output):
        return {arg: self.process([output[i]]) for i, arg in enumerate(args)}


@six.add_metaclass(FactMeta)
class ShortFactBase(object):
    fact = None


def get_short_facts(state, short_fact, **kwargs):
    facts = get_facts(state, short_fact.fact.name, **kwargs)
    return {host: short_fact.process_data(data) for host, data in six.
        iteritems(facts)}


<mask token>


def delete_host_fact(state, host, name, args=None):
    fact_data = get_facts(state, name, args=args, ensure_hosts=(host,))
    fact_data.pop(host, None)

<mask token>


def is_fact(name):
    return name in FACTS


def get_fact_class(name):
    return FACTS[name]


<mask token>


class FactMeta(type):
    """
    Metaclass to dynamically build the facts index.
    """

    def __init__(cls, name, bases, attrs):
        if attrs.get('abstract'):
            return
        fact_name = underscore(name)
        cls.name = fact_name
        FACTS[fact_name] = cls


@six.add_metaclass(FactMeta)
class FactBase(object):
    abstract = True
    shell_executable = None
    requires_command = None

    @staticmethod
    def default():
        """
        Set the default attribute to be a type (eg list/dict).
        """

    @staticmethod
    def process(output):
        return '\n'.join(output)

    def process_pipeline(self, args, output):
        return {arg: self.process([output[i]]) for i, arg in enumerate(args)}


@six.add_metaclass(FactMeta)
class ShortFactBase(object):
    fact = None


def get_short_facts(state, short_fact, **kwargs):
    facts = get_facts(state, short_fact.fact.name, **kwargs)
    return {host: short_fact.process_data(data) for host, data in six.
        iteritems(facts)}


<mask token>


def get_facts(state, name, args=None, ensure_hosts=None, apply_failed_hosts
    =True):
    """
    Get a single fact for all hosts in the state.
    """
    fact = get_fact_class(name)()
    if isinstance(fact, ShortFactBase):
        return get_short_facts(state, fact, args=args, ensure_hosts=
            ensure_hosts)
    logger.debug('Getting fact: {0} (ensure_hosts: {1})'.format(name,
        ensure_hosts))
    args = args or []
    sudo = state.config.SUDO
    sudo_user = state.config.SUDO_USER
    su_user = state.config.SU_USER
    ignore_errors = state.config.IGNORE_ERRORS
    shell_executable = state.config.SHELL
    use_sudo_password = state.config.USE_SUDO_PASSWORD
    if fact.shell_executable:
        shell_executable = fact.shell_executable
    timeout = None
    current_global_kwargs = state.current_op_global_kwargs
    if current_global_kwargs:
        sudo = current_global_kwargs['sudo']
        sudo_user = current_global_kwargs['sudo_user']
        use_sudo_password = current_global_kwargs['use_sudo_password']
        su_user = current_global_kwargs['su_user']
        ignore_errors = current_global_kwargs['ignore_errors']
        timeout = current_global_kwargs['timeout']
    fact_hash = make_hash((name, args, sudo, sudo_user, su_user, ignore_errors)
        )
    current_facts = state.facts.get(fact_hash, {})
    if current_facts:
        if not ensure_hosts or all(host in current_facts for host in
            ensure_hosts):
            return current_facts
    with FACT_LOCK:
        hosts = set(state.inventory.iter_active_hosts())
        if ensure_hosts:
            hosts.update(ensure_hosts)
        greenlet_to_host = {}
        for host in hosts:
            if host in current_facts:
                continue
            host_args = [get_arg_value(state, host, arg) for arg in args]
            command = _make_command(fact.command, host_args)
            requires_command = _make_command(fact.requires_command, host_args)
            if requires_command:
                command = '! command -v {0} > /dev/null || ({1})'.format(
                    requires_command, command)
            greenlet = state.fact_pool.spawn(host.run_shell_command,
                command, sudo=sudo, sudo_user=sudo_user, use_sudo_password=
                use_sudo_password, su_user=su_user, timeout=timeout,
                shell_executable=shell_executable, print_output=state.
                print_fact_output, print_input=state.print_fact_input,
                return_combined_output=True)
            greenlet_to_host[greenlet] = host
        progress_prefix = 'fact: {0}'.format(name)
        if args:
            progress_prefix = '{0}{1}'.format(progress_prefix, args[0])
        with progress_spinner(greenlet_to_host.values(), prefix_message=
            progress_prefix) as progress:
            for greenlet in gevent.iwait(greenlet_to_host.keys()):
                host = greenlet_to_host[greenlet]
                progress(host)
        hostname_facts = {}
        failed_hosts = set()
        for greenlet, host in six.iteritems(greenlet_to_host):
            status = False
            stdout = []
            try:
                status, combined_output_lines = greenlet.get()
            except (timeout_error, socket_error, SSHException) as e:
                failed_hosts.add(host)
                log_host_command_error(host, e, timeout=timeout)
            stdout, stderr = split_combined_output(combined_output_lines)
            data = fact.default()
            if status:
                if stdout:
                    data = fact.process(stdout)
            elif stderr:
                first_line = stderr[0]
                if sudo_user and state.will_add_user(sudo_user) and re.match(
                    SUDO_REGEX, first_line):
                    status = True
                if su_user and state.will_add_user(su_user) and any(re.
                    match(regex, first_line) for regex in SU_REGEXES):
                    status = True
            if not status:
                failed_hosts.add(host)
                if not state.print_fact_output:
                    print_host_combined_output(host, combined_output_lines)
                log_error_or_warning(host, ignore_errors, description=
                    'could not load fact: {0}{1}'.format(name, args or ''))
            hostname_facts[host] = data
        log_name = click.style(name, bold=True)
        filtered_args = list(filter(None, args))
        if filtered_args:
            log = 'Loaded fact {0}: {1}'.format(log_name, tuple(filtered_args))
        else:
            log = 'Loaded fact {0}'.format(log_name)
        if state.print_fact_info:
            logger.info(log)
        else:
            logger.debug(log)
        if not ignore_errors and apply_failed_hosts:
            state.fail_hosts(failed_hosts)
        state.facts.setdefault(fact_hash, {}).update(hostname_facts)
    return state.facts[fact_hash]


def get_host_fact(state, host, name):
    """
    Wrapper around ``get_facts`` returning facts for one host or a function
    that does.
    """
    if callable(getattr(FACTS[name], 'command', None)):

        def wrapper(*args):
            fact_data = get_facts(state, name, args=args, ensure_hosts=(host,))
            return fact_data.get(host)
        return wrapper
    else:
        fact_data = get_facts(state, name, ensure_hosts=(host,))
        return fact_data.get(host)


def create_host_fact(state, host, name, data, args=None):
    fact_data = get_facts(state, name, args=args, ensure_hosts=(host,))
    fact_data[host] = data


def delete_host_fact(state, host, name, args=None):
    fact_data = get_facts(state, name, args=args, ensure_hosts=(host,))
    fact_data.pop(host, None)

<mask token>
FACTS = {}
FACT_LOCK = BoundedSemaphore()
SUDO_REGEX = '^sudo: unknown user:'
SU_REGEXES = '^su: user .+ does not exist', '^su: unknown login'


def is_fact(name):
    return name in FACTS


def get_fact_class(name):
    return FACTS[name]


def get_fact_names():
    """
    Returns a list of available facts in camel_case format.
    """
    return list(six.iterkeys(FACTS))


class FactMeta(type):
    """
    Metaclass to dynamically build the facts index.
    """

    def __init__(cls, name, bases, attrs):
        if attrs.get('abstract'):
            return
        fact_name = underscore(name)
        cls.name = fact_name
        FACTS[fact_name] = cls


@six.add_metaclass(FactMeta)
class FactBase(object):
    abstract = True
    shell_executable = None
    requires_command = None

    @staticmethod
    def default():
        """
        Set the default attribute to be a type (eg list/dict).
        """

    @staticmethod
    def process(output):
        return '\n'.join(output)

    def process_pipeline(self, args, output):
        return {arg: self.process([output[i]]) for i, arg in enumerate(args)}


@six.add_metaclass(FactMeta)
class ShortFactBase(object):
    fact = None


def get_short_facts(state, short_fact, **kwargs):
    facts = get_facts(state, short_fact.fact.name, **kwargs)
    return {host: short_fact.process_data(data) for host, data in six.
        iteritems(facts)}


def _make_command(command_attribute, host_args):
    if callable(command_attribute):
        return command_attribute(*host_args)
    return command_attribute


def get_facts(state, name, args=None, ensure_hosts=None, apply_failed_hosts
    =True):
    """
    Get a single fact for all hosts in the state.
    """
    fact = get_fact_class(name)()
    if isinstance(fact, ShortFactBase):
        return get_short_facts(state, fact, args=args, ensure_hosts=
            ensure_hosts)
    logger.debug('Getting fact: {0} (ensure_hosts: {1})'.format(name,
        ensure_hosts))
    args = args or []
    sudo = state.config.SUDO
    sudo_user = state.config.SUDO_USER
    su_user = state.config.SU_USER
    ignore_errors = state.config.IGNORE_ERRORS
    shell_executable = state.config.SHELL
    use_sudo_password = state.config.USE_SUDO_PASSWORD
    if fact.shell_executable:
        shell_executable = fact.shell_executable
    timeout = None
    current_global_kwargs = state.current_op_global_kwargs
    if current_global_kwargs:
        sudo = current_global_kwargs['sudo']
        sudo_user = current_global_kwargs['sudo_user']
        use_sudo_password = current_global_kwargs['use_sudo_password']
        su_user = current_global_kwargs['su_user']
        ignore_errors = current_global_kwargs['ignore_errors']
        timeout = current_global_kwargs['timeout']
    fact_hash = make_hash((name, args, sudo, sudo_user, su_user, ignore_errors)
        )
    current_facts = state.facts.get(fact_hash, {})
    if current_facts:
        if not ensure_hosts or all(host in current_facts for host in
            ensure_hosts):
            return current_facts
    with FACT_LOCK:
        hosts = set(state.inventory.iter_active_hosts())
        if ensure_hosts:
            hosts.update(ensure_hosts)
        greenlet_to_host = {}
        for host in hosts:
            if host in current_facts:
                continue
            host_args = [get_arg_value(state, host, arg) for arg in args]
            command = _make_command(fact.command, host_args)
            requires_command = _make_command(fact.requires_command, host_args)
            if requires_command:
                command = '! command -v {0} > /dev/null || ({1})'.format(
                    requires_command, command)
            greenlet = state.fact_pool.spawn(host.run_shell_command,
                command, sudo=sudo, sudo_user=sudo_user, use_sudo_password=
                use_sudo_password, su_user=su_user, timeout=timeout,
                shell_executable=shell_executable, print_output=state.
                print_fact_output, print_input=state.print_fact_input,
                return_combined_output=True)
            greenlet_to_host[greenlet] = host
        progress_prefix = 'fact: {0}'.format(name)
        if args:
            progress_prefix = '{0}{1}'.format(progress_prefix, args[0])
        with progress_spinner(greenlet_to_host.values(), prefix_message=
            progress_prefix) as progress:
            for greenlet in gevent.iwait(greenlet_to_host.keys()):
                host = greenlet_to_host[greenlet]
                progress(host)
        hostname_facts = {}
        failed_hosts = set()
        for greenlet, host in six.iteritems(greenlet_to_host):
            status = False
            stdout = []
            try:
                status, combined_output_lines = greenlet.get()
            except (timeout_error, socket_error, SSHException) as e:
                failed_hosts.add(host)
                log_host_command_error(host, e, timeout=timeout)
            stdout, stderr = split_combined_output(combined_output_lines)
            data = fact.default()
            if status:
                if stdout:
                    data = fact.process(stdout)
            elif stderr:
                first_line = stderr[0]
                if sudo_user and state.will_add_user(sudo_user) and re.match(
                    SUDO_REGEX, first_line):
                    status = True
                if su_user and state.will_add_user(su_user) and any(re.
                    match(regex, first_line) for regex in SU_REGEXES):
                    status = True
            if not status:
                failed_hosts.add(host)
                if not state.print_fact_output:
                    print_host_combined_output(host, combined_output_lines)
                log_error_or_warning(host, ignore_errors, description=
                    'could not load fact: {0}{1}'.format(name, args or ''))
            hostname_facts[host] = data
        log_name = click.style(name, bold=True)
        filtered_args = list(filter(None, args))
        if filtered_args:
            log = 'Loaded fact {0}: {1}'.format(log_name, tuple(filtered_args))
        else:
            log = 'Loaded fact {0}'.format(log_name)
        if state.print_fact_info:
            logger.info(log)
        else:
            logger.debug(log)
        if not ignore_errors and apply_failed_hosts:
            state.fail_hosts(failed_hosts)
        state.facts.setdefault(fact_hash, {}).update(hostname_facts)
    return state.facts[fact_hash]


def get_host_fact(state, host, name):
    """
    Wrapper around ``get_facts`` returning facts for one host or a function
    that does.
    """
    if callable(getattr(FACTS[name], 'command', None)):

        def wrapper(*args):
            fact_data = get_facts(state, name, args=args, ensure_hosts=(host,))
            return fact_data.get(host)
        return wrapper
    else:
        fact_data = get_facts(state, name, ensure_hosts=(host,))
        return fact_data.get(host)


def create_host_fact(state, host, name, data, args=None):
    fact_data = get_facts(state, name, args=args, ensure_hosts=(host,))
    fact_data[host] = data


def delete_host_fact(state, host, name, args=None):
    fact_data = get_facts(state, name, args=args, ensure_hosts=(host,))
    fact_data.pop(host, None)

'''
The pyinfra facts API. Facts enable pyinfra to collect remote server state which
is used to "diff" with the desired state, producing the final commands required
for a deploy.
'''

from __future__ import division, unicode_literals

import re

from socket import (
    error as socket_error,
    timeout as timeout_error,
)

import click
import gevent
import six

from gevent.lock import BoundedSemaphore
from paramiko import SSHException

from pyinfra import logger
from pyinfra.api.connectors.util import split_combined_output
from pyinfra.api.util import (
    get_arg_value,
    log_error_or_warning,
    log_host_command_error,
    make_hash,
    print_host_combined_output,
    underscore,
)
from pyinfra.progress import progress_spinner


# Index of snake_case facts -> CamelCase classes
FACTS = {}
FACT_LOCK = BoundedSemaphore()

SUDO_REGEX = r'^sudo: unknown user:'
SU_REGEXES = (
    r'^su: user .+ does not exist',
    r'^su: unknown login',
)


def is_fact(name):
    return name in FACTS


def get_fact_class(name):
    return FACTS[name]


def get_fact_names():
    '''
    Returns a list of available facts in camel_case format.
    '''

    return list(six.iterkeys(FACTS))


class FactMeta(type):
    '''
    Metaclass to dynamically build the facts index.
    '''

    def __init__(cls, name, bases, attrs):
        if attrs.get('abstract'):
            return

        fact_name = underscore(name)
        cls.name = fact_name

        # Get the an instance of the fact, attach to facts
        FACTS[fact_name] = cls


@six.add_metaclass(FactMeta)
class FactBase(object):
    abstract = True

    shell_executable = None

    requires_command = None

    @staticmethod
    def default():
        '''
        Set the default attribute to be a type (eg list/dict).
        '''

    @staticmethod
    def process(output):
        return '\n'.join(output)

    def process_pipeline(self, args, output):
        return {
            arg: self.process([output[i]])
            for i, arg in enumerate(args)
        }


@six.add_metaclass(FactMeta)
class ShortFactBase(object):
    fact = None


def get_short_facts(state, short_fact, **kwargs):
    facts = get_facts(state, short_fact.fact.name, **kwargs)

    return {
        host: short_fact.process_data(data)
        for host, data in six.iteritems(facts)
    }


def _make_command(command_attribute, host_args):
    if callable(command_attribute):
        return command_attribute(*host_args)
    return command_attribute


def get_facts(state, name, args=None, ensure_hosts=None, apply_failed_hosts=True):
    '''
    Get a single fact for all hosts in the state.
    '''

    # Create an instance of the fact
    fact = get_fact_class(name)()

    if isinstance(fact, ShortFactBase):
        return get_short_facts(state, fact, args=args, ensure_hosts=ensure_hosts)

    logger.debug('Getting fact: {0} (ensure_hosts: {1})'.format(
        name, ensure_hosts,
    ))

    args = args or []

    # Apply args or defaults
    sudo = state.config.SUDO
    sudo_user = state.config.SUDO_USER
    su_user = state.config.SU_USER
    ignore_errors = state.config.IGNORE_ERRORS
    shell_executable = state.config.SHELL
    use_sudo_password = state.config.USE_SUDO_PASSWORD

    # Facts can override the shell (winrm powershell vs cmd support)
    if fact.shell_executable:
        shell_executable = fact.shell_executable

    # Timeout for operations !== timeout for connect (config.CONNECT_TIMEOUT)
    timeout = None

    # If inside an operation, fetch global arguments
    current_global_kwargs = state.current_op_global_kwargs
    if current_global_kwargs:
        sudo = current_global_kwargs['sudo']
        sudo_user = current_global_kwargs['sudo_user']
        use_sudo_password = current_global_kwargs['use_sudo_password']
        su_user = current_global_kwargs['su_user']
        ignore_errors = current_global_kwargs['ignore_errors']
        timeout = current_global_kwargs['timeout']

    # Make a hash which keeps facts unique - but usable cross-deploy/threads.
    # Locks are used to maintain order.
    fact_hash = make_hash((name, args, sudo, sudo_user, su_user, ignore_errors))

    # Already got this fact? Unlock and return them
    current_facts = state.facts.get(fact_hash, {})
    if current_facts:
        if not ensure_hosts or all(
            host in current_facts for host in ensure_hosts
        ):
            return current_facts

    with FACT_LOCK:
        # Add any hosts we must have, whether considered in the inventory or not
        # (these hosts might be outside the --limit or current op limit_hosts).
        hosts = set(state.inventory.iter_active_hosts())
        if ensure_hosts:
            hosts.update(ensure_hosts)

        # Execute the command for each state inventory in a greenlet
        greenlet_to_host = {}

        for host in hosts:
            if host in current_facts:
                continue

            # Generate actual arguments by passing strings as jinja2 templates
            host_args = [get_arg_value(state, host, arg) for arg in args]

            command = _make_command(fact.command, host_args)
            requires_command = _make_command(fact.requires_command, host_args)
            if requires_command:
                command = '! command -v {0} > /dev/null || ({1})'.format(
                    requires_command, command,
                )

            greenlet = state.fact_pool.spawn(
                host.run_shell_command,
                command,
                sudo=sudo,
                sudo_user=sudo_user,
                use_sudo_password=use_sudo_password,
                su_user=su_user,
                timeout=timeout,
                shell_executable=shell_executable,
                print_output=state.print_fact_output,
                print_input=state.print_fact_input,
                return_combined_output=True,
            )
            greenlet_to_host[greenlet] = host

        # Wait for all the commands to execute
        progress_prefix = 'fact: {0}'.format(name)
        if args:
            progress_prefix = '{0}{1}'.format(progress_prefix, args[0])

        with progress_spinner(
            greenlet_to_host.values(),
            prefix_message=progress_prefix,
        ) as progress:
            for greenlet in gevent.iwait(greenlet_to_host.keys()):
                host = greenlet_to_host[greenlet]
                progress(host)

        hostname_facts = {}
        failed_hosts = set()

        # Collect the facts and any failures
        for greenlet, host in six.iteritems(greenlet_to_host):
            status = False
            stdout = []

            try:
                status, combined_output_lines = greenlet.get()

            except (timeout_error, socket_error, SSHException) as e:
                failed_hosts.add(host)
                log_host_command_error(
                    host, e,
                    timeout=timeout,
                )

            stdout, stderr = split_combined_output(combined_output_lines)

            data = fact.default()

            if status:
                if stdout:
                    data = fact.process(stdout)

            elif stderr:
                first_line = stderr[0]
                if (
                    sudo_user
                    and state.will_add_user(sudo_user)
                    and re.match(SUDO_REGEX, first_line)
                ):
                    status = True
                if (
                    su_user
                    and state.will_add_user(su_user)
                    and any(re.match(regex, first_line) for regex in SU_REGEXES)
                ):
                    status = True

            if not status:
                failed_hosts.add(host)

                if not state.print_fact_output:
                    print_host_combined_output(host, combined_output_lines)

                log_error_or_warning(host, ignore_errors, description=(
                    'could not load fact: {0}{1}'
                ).format(name, args or ''))

            hostname_facts[host] = data

        log_name = click.style(name, bold=True)

        filtered_args = list(filter(None, args))
        if filtered_args:
            log = 'Loaded fact {0}: {1}'.format(log_name, tuple(filtered_args))
        else:
            log = 'Loaded fact {0}'.format(log_name)

        if state.print_fact_info:
            logger.info(log)
        else:
            logger.debug(log)

        # Check we've not failed
        if not ignore_errors and apply_failed_hosts:
            state.fail_hosts(failed_hosts)

        # Assign the facts
        state.facts.setdefault(fact_hash, {}).update(hostname_facts)

    return state.facts[fact_hash]


def get_host_fact(state, host, name):
    '''
    Wrapper around ``get_facts`` returning facts for one host or a function
    that does.
    '''

    # Expecting a function to return
    if callable(getattr(FACTS[name], 'command', None)):
        def wrapper(*args):
            fact_data = get_facts(state, name, args=args, ensure_hosts=(host,))
            return fact_data.get(host)
        return wrapper

    # Expecting the fact as a return value
    else:
        # Get the fact
        fact_data = get_facts(state, name, ensure_hosts=(host,))
        return fact_data.get(host)


def create_host_fact(state, host, name, data, args=None):
    fact_data = get_facts(state, name, args=args, ensure_hosts=(host,))
    fact_data[host] = data


def delete_host_fact(state, host, name, args=None):
    fact_data = get_facts(state, name, args=args, ensure_hosts=(host,))
    fact_data.pop(host, None)
