import itertools
import os
import re
import socket
from collections import namedtuple, defaultdict
from contextlib import contextmanager

import parallels.plesk.source.plesk
from parallels.core.logging import get_logger
from parallels.core.migrator_config import MSSQLCopyMethod
from parallels.core.reports.model.issue import Issue
from parallels.core.runners.base import BaseRunner
from parallels.core.runners.entities import MSSQLConnectionSettings, ExecutionOptions
from parallels.core.utils import unique_name_generator
from parallels.core.utils import unix_utils, migrator_utils, windows_utils, database_utils
from parallels.core.utils.common import (
    format_list, group_by, safe_string_repr, obj, safe_format, format_multiline_list, group_value_by, open_no_inherit
)
from parallels.core.utils.database_utils import (
    DatabaseServerConnectionException, get_mysql_max_allowed_packet_size, get_mssql_copy_method, check_mssql_connection
)
from parallels.core.utils.entity import Entity
from parallels.core.utils.windows_utils import get_binary_full_path
from parallels.core.windows_rsync import RsyncClientNonZeroExitCode
from parallels.plesk.source.plesk import messages

NodesPair = namedtuple('NodesPair', ('source', 'target'))
SubscriptionsInfo = namedtuple('SubscriptionsInfo', (
    'subscription_name',
    'subscription_dir',
    'target_node',
    'source_ip'
))

logger = get_logger(__name__)


class InfrastructureChecks(object):
    """Check disk space and connections, format the report."""
    def __init__(self):
        self.checks_cache = {}

    def check_copy_content(self, check_type, subscriptions, report, check_function, content_type):
        result = []
        for nodes_pair_info, nodes_subscriptions in subscriptions:
            success, reason = self._check_cached(check_type, check_function, nodes_pair_info)
            result.append((nodes_subscriptions, success, reason))

        if content_type == 'web':
            self._format_copy_web_content_report(report, result)
        elif content_type == 'mail':
            self._format_copy_mail_content_report(report, result)

    def check_copy_web_content_per_domain(self, domains, report, check_function):
        result = []
        for nodes_pair_info, nodes_domains in domains:
            success, reason = self._check_cached('rsync', check_function, nodes_pair_info)
            result.append((nodes_domains, success, reason))

        self._format_copy_web_content_report_per_domain(report, result)

    def check_copy_db_content(self, check_type, subscriptions, report, check_function):
        result = []
        for nodes_pair_info, nodes_subscriptions in subscriptions:
            success, reason = self._check_cached(check_type, check_function, nodes_pair_info)
            result.append((nodes_subscriptions, success, reason))

        self._format_copy_db_content_report(report, result)

    def check_mssql_connection(self, subscriptions, report, check_function):
        result = []
        for (nodes_pair_info, mssql_connection_data), databases in subscriptions:
            success, reason = check_function(nodes_pair_info, mssql_connection_data)
            result.append((databases, success, reason))

        self._format_copy_db_content_report(report, result)

    def check_windows_mail_source_connection(self, subscriptions_by_source, report, checker):
        result = []
        for node, subscriptions in subscriptions_by_source.items():
            success, reason = checker.check(node)
            result.append((subscriptions, success, reason))

        self._format_copy_mail_content_report(report, result)

    def check_windows_mail_target_connection(self, subscriptions_by_target, report, checker):
        result = []
        for node, subscriptions in subscriptions_by_target.items():
            success, reason = checker.check(node)
            result.append((subscriptions, success, reason))

        self._format_copy_mail_content_report(report, result)

    def _check_cached(self, check_type, check_function, nodes_pair_info):
        if not (check_type, nodes_pair_info) in self.checks_cache:
            self.checks_cache[(check_type, nodes_pair_info)] = check_function(nodes_pair_info)
        return self.checks_cache[(check_type, nodes_pair_info)]

    @staticmethod
    def _format_copy_db_content_report(report, check_result):
        def format_databases(db_infos):
            db_type_pretty_name = {'mysql': 'MySQL', 'postgresql': 'PostgreSQL', 'mssql': 'MSSQL'}

            return ", ".join(
                "'%s' (%s)" % (
                    subscription, 
                    ", ".join([
                        "%s database(s): %s" % (
                            db_type_pretty_name.get(db_type, 'unknown'), format_list([d['db_name'] for d in dbs])
                        )
                        for db_type, dbs
                        in group_by(subscription_databases, lambda db_info: db_info['db_type']).items()
                    ])
                )
                for subscription, subscription_databases
                in group_by(db_infos, lambda db_info: db_info['subscription']).items()
            )

        for databases, success, reason in check_result:
            if not success:
                report.add_issue(
                    'infrastructure-databases', Issue.SEVERITY_ERROR,
                    messages.INFRASTRUCTURE_CHECK_DB_ISSUES % (reason, format_databases(databases))
                )
            else:
                report.add_issue('infrastructure-unix-databases', Issue.SEVERITY_INFO, reason)

    @classmethod
    def _format_copy_web_content_report(cls, report, check_result):
        cls._format_copy_content_report(report, check_result, issue_id='infrastructure-web', object_name='web content')

    @classmethod
    def _format_copy_mail_content_report(cls, report, check_result):
        cls._format_copy_content_report(
            report, check_result, issue_id='infrastructure-mail', object_name='mail messages'
        )

    @staticmethod
    def _format_copy_content_report(report, check_result, issue_id, object_name):
        for subscriptions, success, reason in check_result:
            if not success:
                report.add_issue(
                    issue_id, Issue.SEVERITY_ERROR,
                    messages.INFRASTRUCTURE_CHECK_CONTENT_ISSUES_SUBSCRIPTION % (
                        reason, object_name, format_list(subscriptions)
                    )
                )
            else:
                report.add_issue(issue_id, Issue.SEVERITY_INFO, reason)

    @staticmethod
    def _format_copy_web_content_report_per_domain(report, check_result):
        for domains, success, reason in check_result:
            if not success:
                report.add_issue(
                    'infrastructure-copy-web-content', Issue.SEVERITY_ERROR,
                    messages.INFRASTRUCTURE_CHECK_WEB_CONTENT_ISSUES_DOMAINS.format(
                        reason=reason, domains=format_list(domains)
                    )
                )
            else:
                report.add_issue('infrastructure-copy-web-content', Issue.SEVERITY_INFO, reason)


def check_main_node_disk_requirements(runner, subscriptions_count):
    mb = 1024 * 1024
    gb = 1024 * mb

    required = defaultdict(int)
    available = {}

    var_device, var_available = _get_available_disk_space(runner, '/var')
    available[var_device] = var_available
    usr_device, usr_available = _get_available_disk_space(runner, '/usr')
    available[usr_device] = usr_available

    required[var_device] += 1 * gb + 10 * mb * subscriptions_count
    required[usr_device] += 1 * gb + 10 * mb * subscriptions_count

    logger.debug(messages.DEBUG_DISK_SPACE_CHECK, subscriptions_count, dict(required), dict(available))
    checks_passed = True
    for device, device_required in required.items():
        if device_required > available[device]:
            logger.error(messages.LACK_OF_DISK_SPACE_ON_DEVICE.format(
                required=_format_bytes(device_required), device=device, available=_format_bytes(available[device])
            ))
            checks_passed = False
    
    return checks_passed


class FileCopyBetweenNodesChecker(object):
    _test_file_contents = 'test-file-contents'

    @staticmethod
    def get_command_name():
        """Override in child classes"""
        raise NotImplementedError()

    @staticmethod
    def _get_nodes_filepath(nodes):
        """
        Generate full path to test file on both source and target nodes using provided file name template

        :type nodes: NodesPair
        :rtype: tuple
        """
        filename = unique_name_generator.get_unique_filename(prefix='check-rsync-testfile-')
        source_filename = filename + '.source'
        target_filename = filename + '.target'

        source_temp_filepath = nodes.source.get_session_file_path(source_filename)
        target_temp_filepath = nodes.target.get_session_file_path(target_filename)

        # source_filename is returned due to migrator rsync specific configuration
        return source_temp_filepath, target_temp_filepath, source_filename


class WindowsFileCopyBetweenNodesRsyncChecker(FileCopyBetweenNodesChecker):
    def check(self, nodes, get_rsync):
        """
        :type nodes: NodesPair
        :type get_rsync: function

        :return:
            1st element: True if check passed successfully, False otherwise
            2nd element: check result message
        :rtype: tuple (bool, str | unicode)
        """

        message_formatter = CopySampleFileMessageFormatter(nodes)
        logger.info(message_formatter.format_check_log_message(self.get_command_name()))

        source_temp_filepath, target_temp_filepath, filename = self._get_nodes_filepath(nodes)
        with nodes.source.runner() as runner_source:
            runner_source.upload_file_content(source_temp_filepath, self._test_file_contents)

        try:
            rsync = get_rsync(nodes.source, nodes.target)
            rsync.sync(
                source_path='migrator/%s' % filename,
                target_path=windows_utils.convert_path_to_cygwin(target_temp_filepath),
            )
        except RsyncClientNonZeroExitCode as e:
            logger.exception()

            return Result.failure(message_formatter.format_failure(
                command_name=self.get_command_name(), 
                problem_description=message_formatter.format_windows_rsync_problem_description(),
                failed_command_description=message_formatter.format_command_description(
                    command_str=e.command, 
                    exit_code=e.exit_code, 
                    stdout=e.stdout, 
                    stderr=e.stderr
                )
            ))

        # remove file only if check finished successfully, so customer could try to run
        # the command himself to check what's wrong with configuration
        with nodes.target.runner() as runner_target:
            runner_target.remove_file(target_temp_filepath)
        with nodes.source.runner() as runner_source:
            runner_source.remove_file(source_temp_filepath)

        return Result.success(message_formatter.format_success(command_name=self.get_command_name()))

    @staticmethod
    def get_command_name():
        return 'rsync'


class UnixFileCopyBetweenNodesChecker(FileCopyBetweenNodesChecker):
    def check(self, nodes, ssh_key_pool):
        """
        :type nodes: NodesPair
        :type ssh_key_pool: parallels.core.utils.ssh_key_pool.SSHKeyPool

        :return:
            1st element: True if check passed successfully, False otherwise
            2nd element: check result message
        :rtype: tuple (bool, str | unicode)
        """
        message_formatter = CopySampleFileMessageFormatter(nodes)
        logger.info(message_formatter.format_check_log_message(self.get_command_name()))

        source_temp_filepath, target_temp_filepath, _ = self._get_nodes_filepath(nodes)

        with nodes.source.runner() as runner_source:
            runner_source.upload_file_content(source_temp_filepath, self._test_file_contents)
        key_pathname = ssh_key_pool.get(nodes.source, nodes.target).key_pathname

        with \
            nodes.source.runner() as runner_source, \
            nodes.target.runner() as runner_target:
            cmd, args = self._create_command(
                key_pathname, 
                runner_source, nodes.source.user(), nodes.source.ip(), 
                source_temp_filepath, target_temp_filepath, nodes.source.settings().ssh_auth.port
            )
            assert isinstance(runner_source, BaseRunner)
            assert isinstance(runner_target, BaseRunner)

            execution_results = runner_target.execute_command(
                cmd, args, ExecutionOptions(ignore_exit_code=True)
            )

            command_description = message_formatter.format_command_description(
                command_str=unix_utils.format_command(cmd, **args),
                exit_code=execution_results.exit_code,
                stdout=execution_results.stdout, stderr=execution_results.stderr
            )

            if execution_results.exit_code != 0:
                problem_description = messages.UNIX_COPY_FILE_CONNECTION_FAILED_SOLUTION.format(
                    ssh_port=nodes.source.settings().ssh_auth.port
                )

                return Result.failure(message_formatter.format_failure(
                    command_name=self.get_command_name(),
                    problem_description=problem_description,
                    failed_command_description=command_description
                ))

            target_file_contents = runner_target.get_file_contents(target_temp_filepath)
            if target_file_contents != self._test_file_contents:
                    problem_description = messages.INFRASTRUCTURE_INVALID_FILE_CONTENT % (
                        self.get_command_name(), self._test_file_contents, target_file_contents
                    )
                    return Result.failure(message_formatter.format_failure(
                        command_name=self.get_command_name(),
                        problem_description=problem_description,
                        failed_command_description=command_description
                    ))

            # remove file only if check finished successfully,
            # so customer could try to run the command himself to check what's wrong with configuration
            runner_target.remove_file(target_temp_filepath)
            runner_source.remove_file(source_temp_filepath)

        return Result.success(message_formatter.format_success(command_name=self.get_command_name()))

    @staticmethod
    def _create_command(
        key_pathname, source_runner, source_user, source_ip, source_temp_filepath, target_temp_filepath,
        source_port
    ):
        """Override in child classes"""
        raise NotImplementedError()


class UnixFileCopyBetweenNodesRsyncChecker(UnixFileCopyBetweenNodesChecker):
    @staticmethod
    def get_command_name():
        return 'rsync'

    @staticmethod
    def _create_command(
        key_pathname, source_runner, source_user, source_ip, source_temp_filepath, target_temp_filepath, source_port
    ):
        return migrator_utils.create_rsync_command(
            key_pathname=key_pathname, source_runner=source_runner, 
            source_user=source_user, source_ip=source_ip, 
            source_filepath=source_temp_filepath, target_filepath=target_temp_filepath,
            source_port=source_port
        )


class UnixFileCopyBetweenNodesScpChecker(UnixFileCopyBetweenNodesChecker):
    @staticmethod
    def get_command_name():
        return 'scp'

    @staticmethod
    def _create_command(
        key_pathname, source_runner, source_user, source_ip, source_temp_filepath, target_temp_filepath, source_port
    ):
        cmd = (
            '/usr/bin/scp -i {key_pathname} -P {source_port} '
            '-o PasswordAuthentication=no -o StrictHostKeyChecking=no -o GSSAPIAuthentication=no '
            '{source_user}@{source_ip}:{source_filename} {target_temp_filepath}'
        )
        args = dict(
            key_pathname=key_pathname,
            source_port=str(source_port),
            source_user=source_user,
            source_ip=source_ip,
            source_filename=source_temp_filepath,
            target_temp_filepath=target_temp_filepath
        )
        return cmd, args


class MysqlMaxAllowedPacketChecker(object):
    @classmethod
    def check(cls, source_database_server, target_database_server, report):
        """Check source and target max allowed packet for mysql database server.

        :type source_database_server: parallels.core.connections.database_server.DatabaseServer
        :type target_database_server: parallels.core.connections.database_server.DatabaseServer
        """

        failures = []
        source_db_max_allowed_packet = get_mysql_max_allowed_packet_size(source_database_server)
        target_db_max_allowed_packet = get_mysql_max_allowed_packet_size(target_database_server)
        
        logger.debug(
            messages.LOG_MAX_ALLOWED_MYSQL_PACKET,
            source_database_server.description(), source_db_max_allowed_packet
        )
        logger.debug(
            messages.LOG_MAX_ALLOWED_MYSQL_PACKET,
            target_database_server.description(), target_db_max_allowed_packet
        )

        if source_db_max_allowed_packet > target_db_max_allowed_packet:
            failures.append((
                source_db_max_allowed_packet,
                target_db_max_allowed_packet,
                source_database_server.description(),
                target_database_server.description()
            ))
        cls.make_report(failures, target_database_server.description(), report)

    @staticmethod
    def make_report(failures, description_target_database, report):
        if len(failures) > 0:
            report.add_issue(
                'infrastructure-max-allowed-packet', Issue.SEVERITY_ERROR,
                (
                    messages.MAX_ALLOWED_MYSQL_PACKET_ISSUE_INTRO +
                    "\n".join([
                        messages.MAX_ALLOWED_MYSQL_PACKET_ISSUE_DESCRIPTION.format(
                            source=_format_bytes(source_db_max_allowed_packet),
                            target=_format_bytes(target_db_max_allowed_packet),
                            source_host=source_host,
                            target_host=target_host
                        )
                        for source_db_max_allowed_packet, target_db_max_allowed_packet, source_host, target_host in failures
                    ]) +
                    "\n" + messages.SOLUTION_INCREASE_MAX_ALLOWED_PACKET + "\n"
                )
            )
        else:
            report.add_issue(
                'infrastructure-max-allowed-packet', Issue.SEVERITY_INFO,
                messages.MAX_ALLOWED_PACKET_OK.format(
                    description_target_database=description_target_database
                )
            )

    @staticmethod
    def _get_mysql_max_allow_packet_query():
        return u"SHOW VARIABLES LIKE 'max_allowed_packet'"

    @classmethod
    def _get_source_mysql_max_allowed_packet(cls, source_database_server):
        """
        :type source_database_server: parallels.core.connections.database_server.DatabaseServer
        """
        raise NotImplementedError()

    @classmethod
    def _get_target_mysql_max_allowed_packet(cls, target_database_server):
        """
        :type target_database_server: parallels.core.connections.database_server.DatabaseServer
        """
        raise NotImplementedError()


class UnixMysqlMaxAllowedPacketChecker(MysqlMaxAllowedPacketChecker):
    @classmethod
    def _get_source_mysql_max_allowed_packet(cls, source_database_server):
        """
        :type source_database_server: parallels.core.connections.database_server.DatabaseServer
        """
        with source_database_server.runner() as runner_source:
            return cls._mysql_max_allowed_packet_unix(
                runner_source, source_database_server.host(), source_database_server.port(),
                source_database_server.user(), source_database_server.password()
            )

    @classmethod
    def _get_target_mysql_max_allowed_packet(cls, target_database_server):
        """
        :type target_database_server: parallels.core.connections.database_server.DatabaseServer
        """
        with target_database_server.runner() as runner_target:
            return cls._mysql_max_allowed_packet_unix(
                runner_target, target_database_server.host(), target_database_server.port(),
                target_database_server.user(), target_database_server.password()
            )

    @classmethod
    def _mysql_max_allowed_packet_unix(cls, runner, host, port, login, password):
        """
        :type runner: parallels.core.runners.base.BaseRunner
        """
        command = ''
        stdin = None
        # workaround for Plesk feature - it does not tell default MySQL server's admin password
        if host == 'localhost' and port == 3306:
            command += u'MYSQL_PWD="$(cat /etc/psa/.psa.shadow)" '
        else:
            command += u'MYSQL_PWD="$(cat)" '
            stdin = password

        command += u"mysql -h {host} -P {port} -u{login} --silent --skip-column-names -e {query}"
        args = dict(host=host, port=port, login=login, query=cls._get_mysql_max_allow_packet_query())

        output = runner.execute_command(command, args, ExecutionOptions(stdin_content=stdin)).stdout

        return int(
            output[len("max_allowed_packet"):]
        )


class WindowsMysqlMaxAllowedPacketChecker(MysqlMaxAllowedPacketChecker):
    @classmethod
    def _get_source_mysql_max_allowed_packet(cls, source_database_server):
        """
        :type source_database_server: parallels.core.connections.database_server.DatabaseServer
        """
        source_database_server.panel_server.get_path_to_mysqldump()
        with source_database_server.panel_server.runner() as runner_source:
            return cls._mysql_max_allowed_packet_windows(
                runner_source, source_database_server.panel_server.get_path_to_mysql(),
                source_database_server.host(), source_database_server.port(), source_database_server.user(),
                source_database_server.password(),
                mysql_use_skip_secure_auth=source_database_server.panel_server.mysql_use_skip_secure_auth()
            )

    @classmethod
    def _get_target_mysql_max_allowed_packet(cls, target_database_server):
        """
        :type target_database_server: parallels.core.connections.database_server.DatabaseServer
        """
        target_mysql_bin=database_utils.get_windows_mysql_client(target_database_server.panel_server)
        with target_database_server.panel_server.runner() as runner_target:
            return cls._mysql_max_allowed_packet_windows(
                runner_target, target_mysql_bin, target_database_server.host(), target_database_server.port(),
                target_database_server.user(), target_database_server.password()
            )

    @classmethod
    def _mysql_max_allowed_packet_windows(
        cls, runner, mysql_bin, host, port, login, password, mysql_use_skip_secure_auth=False
    ):
        mysql_output = cls._execute_windows_mysql_one_value_query(
            runner, mysql_bin, host, port, login, password, cls._get_mysql_max_allow_packet_query(),
            mysql_use_skip_secure_auth
        ).strip()

        if mysql_output == 'NULL':
            return 0
        else:
            return int(mysql_output[len("max_allowed_packet"):])

    @staticmethod
    def _execute_windows_mysql_one_value_query(
        runner, mysql_bin, host, port, login, password, query, mysql_use_skip_secure_auth=False
    ):
        """
        :type runner: parallels.core.runners.base.BaseRunner 
        """
        command = (
            u'cmd.exe /C "{mysql_bin} -h {host} -P {port} -u{login} -p{password}'
            u'%s --silent --skip-column-names -e {query}"' % (
                u' --skip-secure-auth' if mysql_use_skip_secure_auth else ''
            )
        )
        args = dict(mysql_bin=mysql_bin, host=host, port=port, login=login, password=password, query=query)
        execution_results = runner.execute_command(command, args, ExecutionOptions(ignore_exit_code=True))
        if execution_results.exit_code != 0:
            raise Exception(
                messages.MYSQL_COMMAND_NON_ZERO_EXIT_CODE, command, args,
                safe_string_repr(execution_results.stdout), safe_string_repr(execution_results.stderr)
            )
        return execution_results.stdout

MSSQLConnectionData = namedtuple('MSSQLConnectionData', ('host', 'login', 'password'))


class MSSQLConnectionChecker(object):
    def __init__(self, mssql_copy_method):
        self._mssql_copy_method = mssql_copy_method

    def check(self, servers, databases, report):
        logger.info(self._format_check_log_message(servers))

        mssql_copy_method = get_mssql_copy_method(servers.source, servers.target, self._mssql_copy_method)
        if mssql_copy_method == MSSQLCopyMethod.NATIVE:
            # Check source MSSQL server from source panel server, target MSSQL server from target panel server
            success_target, reason_target = self._check_connection(servers.target, servers.target.panel_server)
            success_source, reason_source = self._check_connection(servers.source, servers.source.panel_server)
        elif mssql_copy_method == MSSQLCopyMethod.TEXT:
            # Check both source MSSQL server and target MSSQL server from target panel server
            success_target, reason_target = self._check_connection(servers.target, servers.target.panel_server)
            success_source, reason_source = self._check_connection(servers.source, servers.target.panel_server)
        else:
            assert False

        if not success_source or not success_target:
            reasons = []
            if not success_target:
                reasons.append(reason_target)
            if not success_source:
                reasons.append(reason_source)
            report.add_issue(
                'failed_mssql_connections', Issue.SEVERITY_ERROR, self._format_failure(servers, reasons, databases)
            )
        else:
            report.add_issue(
                'success_mssql_connections', Issue.SEVERITY_INFO, self._format_success(servers)
            )

    @staticmethod
    def _check_connection(database_server, utilities_server):
        try:
            check_mssql_connection(database_server, utilities_server)
        except DatabaseServerConnectionException as e:
            return False, safe_string_repr(e)

        return True, None

    @staticmethod
    def _format_success(servers):
        return safe_format(
            messages.SUCCESS_CHECK_MSSQL_CONNECTIONS,
            source=servers.source.description(), target=servers.target.description()
        )

    @staticmethod
    def _format_failure(servers, reasons, databases):
        affected_subscriptions = ", ".join(
            "'%s' (database(s) %s)" % (subscription, format_list(subscription_databases))
            for subscription, subscription_databases
            in group_value_by(
                databases,
                lambda db_info: db_info['subscription'],
                lambda db_info: db_info['db_name']
            ).items()
        )

        return safe_format(
            messages.UNABLE_TO_CONNECT_FROM_TARGET_MSSQL_SERVER,
            source=servers.source.description(), target=servers.target.description(),
            reasons=format_multiline_list(reasons),
            affected_subscriptions=affected_subscriptions
        )

    @staticmethod
    def _format_check_log_message(servers):
        return safe_format(
            messages.CHECK_CONNECTIONS_TO_COPY_MSSQL_DATABASES,
            source=servers.source.description(), target=servers.target.description()
        )


class PortChecker(object):
    def check(self, node):
        logger.info(self._format_log_message(node))

        ERRNO_CONNECTION_REFUSED = 111
        ERRNO_CONNECTION_TIMED_OUT = 110

        try:
            conn = socket.create_connection((node.ip(), self._get_port_number(node)))
            conn.close()
        except socket.error as e:
            if e.errno in (ERRNO_CONNECTION_REFUSED, ERRNO_CONNECTION_TIMED_OUT):
                return Result.failure(self._format_failure(node))
            else:
                raise

        return Result.success(self._format_success(node))

    @staticmethod
    def _get_port_number(node):
        raise NotImplementedError()

    def _format_failure(self, node):
        raise NotImplementedError()

    def _format_success(self, node):
        raise NotImplementedError()

    def _format_log_message(self, node):
        raise NotImplementedError()


class SourceNodeImapChecker(PortChecker):
    @staticmethod
    def _get_port_number(node):
        return 143

    def _format_log_message(self, node):
        return messages.LOG_CHECK_CONNECTION_BY_IMAP.format(
            node=node.description(),
            port_number=self._get_port_number(node)
        )

    def _format_failure(self, node):
        return messages.UNABLE_CONNECT_BY_IMAP_PORT.format(
            node=node.description(),
            port_number=self._get_port_number(node)
        )

    def _format_success(self, node):
        return messages.IMAP_CONNECTION_OK.format(
            node=node.description(),
            port_number=self._get_port_number(node)
        )


class SourceNodePop3Checker(PortChecker):
    @staticmethod
    def _get_port_number(node):
        return 110

    def _format_log_message(self, node):
        return messages.LOG_CONNECTION_BY_POP3.format(
            node=node.description(), 
            port_number=self._get_port_number(node)
        )

    def _format_failure(self, node):
        return messages.UNABLE_CONNECT_NODE_BY_POP3_PORT.format(
            node=node.description(), 
            port_number=self._get_port_number(node)
        )

    def _format_success(self, node):
        return messages.POP3_CONNECTION_OK.format(
            node=node.description(),
            port_number=self._get_port_number(node)
        )


class TargetNodeImapChecker(PortChecker):
    @staticmethod
    def _get_port_number(node):
        return 143

    def _format_log_message(self, node):
        return messages.LOG_CHECK_CONNECTION_BY_IMAP.format(
            node=node.description(), 
            port_number=self._get_port_number(node)
        )

    def _format_failure(self, node):
        return (
            messages.UNABLE_TO_CONNECT_TARGET_BY_IMAP_PORT).format(
            node=node.description(), 
            port_number=self._get_port_number(node)
        )

    def _format_success(self, node):
        return messages.IMAP_CONNECTION_OK.format(
            node=node.description(), 
            port_number=self._get_port_number(node)
        )


class CopySampleFileMessageFormatter(object):
    def __init__(self, nodes):
        self.nodes = nodes

    def format_failure(self, command_name, problem_description, failed_command_description):
        return messages.COPY_SAMPLE_FILE_FAILURE.format(
            command_name=command_name,
            source=self.nodes.source.description(),
            target=self.nodes.target.description(),
            problem_description=problem_description,
            failed_command_description=failed_command_description
        )

    def format_success(self, command_name):
        return messages.COPY_SAMPLE_FILE_OK.format(
            command_name=command_name,
            source=self.nodes.source.description(),
            target=self.nodes.target.description(),
        )

    def format_command_description(self, command_str, exit_code, stdout, stderr):
        return safe_format(
            messages.COMMAND_DESCRIPTION,
            target=self.nodes.target.description(),
            command_str=command_str, exit_code=exit_code, stdout=stdout, stderr=stderr
        )

    def format_windows_rsync_problem_description(self):
        return messages.WINDOWS_RSYNC_PROBLEM_SOLUTION

    def format_check_log_message(self, command_name):
        return messages.LOG_COPY_SAMPLE_FILE.format(
            command_name=command_name,
            source=self.nodes.source.description(),
            target=self.nodes.target.description()
        )


class Result(object):
    @staticmethod
    def failure(msg):
        return False, msg

    @staticmethod
    def success(msg):
        return True, msg


@contextmanager
def temporary_file(filename, contents):
    try:
        with open_no_inherit(filename, 'w') as f:
            f.write(contents)
        yield
    finally:
        os.remove(filename)


class DiskSpaceDatabaseAccess(Entity):
    def __init__(self, host, port, user, password):
        self._host = host
        self._port = port
        self._user = user
        self._password = password

    @property
    def host(self):
        """
        :rtype:
        """
        return self._host

    @property
    def port(self):
        """
        :rtype:
        """
        return self._port

    @property
    def user(self):
        """
        :rtype:
        """
        return self._user

    @property
    def password(self):
        """
        :rtype:
        """
        return self._password


class DiskSpaceDatabase(Entity):
    def __init__(self, src_access, db_target_node, db_name):
        self._src_access = src_access
        self._db_target_node = db_target_node
        self._db_name = db_name

    @property
    def src_access(self):
        """
        DiskSpaceDatabaseAccess (MSSQLConnectionData in case of MSSQL) for source server
        """
        return self._src_access

    @property
    def db_target_node(self):
        """
        :rtype: parallels.core.connections.database_server.DatabaseServer
        """
        return self._db_target_node

    @property
    def db_name(self):
        """
        :rtype:
        """
        return self._db_name


class UnixDiskSpaceSourcePleskNode(Entity):
    def __init__(self, node, web_domains, mail_domains, mysql_databases):
        self._node = node
        self._web_domains = web_domains
        self._mail_domains = mail_domains
        self._mysql_databases = mysql_databases

    @property
    def node(self):
        """
        :rtype:
        """
        return self._node

    @property
    def web_domains(self):
        """
        :rtype:
        """
        return self._web_domains

    @property
    def mail_domains(self):
        """
        :rtype:
        """
        return self._mail_domains

    @property
    def mysql_databases(self):
        """
        :rtype:
        """
        return self._mysql_databases


class UnixDiskSpaceChecker(object):
    def check(self, target_node, source_nodes, report):
        """Check source and target server disk space requirements.
        
        Calculate disk usage on source server for web hosting, mail, database.
        Run target server disk space usage calculation function, which updates
        the Report object.

        Args:
            target_node:
                target_panel_plesk.connections.target_server.PleskTargetServer
            source_nodes:
                a structure, that maps source servers to domains, which hosted
                on that servers
            report:
                report storage object parallels.core.checking.Report

        :type source_nodes: list[parallels.plesk.source.plesk.infrastructure_checks.checks.UnixDiskSpaceSourcePleskNode]
        :rtype: None
        """
        usage_source_web = 0
        usage_source_mail = 0
        usage_source_mysql_db = 0
        all_usage_source_mysql_db = [0]
        for source_node in source_nodes:
            usage_source_web += self._get_source_unix_domains_disk_usage_web(
                source_node.node, source_node.web_domains
            )
            usage_source_mail += self._get_source_unix_domains_disk_usage_mail(
                source_node.node, source_node.mail_domains
            )

            with source_node.node.runner() as runner_source:
                for db in source_node.mysql_databases:
                    source_db_server = db.source_database_server
                    single_usage_source_mysql_db = _estimate_mysql_database_size(
                        runner_source, source_db_server.host(), source_db_server.port(),
                        source_db_server.user(), source_db_server.password(), db.source_database_name
                    )
                    usage_source_mysql_db += single_usage_source_mysql_db
                    all_usage_source_mysql_db.append(single_usage_source_mysql_db)

        logger.debug(
            messages.LOG_TOTAL_DISK_USAGE_ON_SOURCE,
            target_node.description(), usage_source_web, usage_source_mail, usage_source_mysql_db
        )

        self.check_with_source_usages(
            target_node, report,
            usage_source_web, usage_source_mail, usage_source_mysql_db, max(all_usage_source_mysql_db),
            itertools.chain(*[source_node.web_domains for source_node in source_nodes]),
            itertools.chain(*[source_node.mail_domains for source_node in source_nodes]),
            itertools.chain(*[source_node.mysql_databases for source_node in source_nodes]),
        )

    def check_with_source_usages(
        self, target_node, report, usage_source_web, usage_source_mail,
        usage_source_mysql_db, max_usage_source_mysql_db, web_domains,
        mail_domains, mysql_databases
    ):
        """Check, if there is enough disk space on target server.
        
        Args:
            target_node:
                target server
            report: report Object to be updated
            usage_source_web, usage_source_mail,
            usage_source_mysql_db, max_usage_source_mysql_db:
                disk space consumed on source server by each service
            web_domains, mail_domains:
                list of domains, that use the service type
            mysql_databases:
                list of databases to be migrated
            
        Returns: None. The result is the new records added to the report
        object.
        """
        hosting_server = target_node.get_hosting_server()
        usage_target_web = self._get_unix_domains_disk_usage(
            target_node, web_domains, hosting_server.get_unix_vhost_dirs
        )
        usage_target_mail = self._get_unix_domains_disk_usage(
            target_node, mail_domains,
            hosting_server.get_unix_domain_mail_dirs
        )
        usage_target_mysql_db = self._get_target_mysql_databases_usage(
            target_node, mysql_databases
        )

        logger.debug(
            messages.LOG_TOTAL_DISK_USAGE_ON_TARGET,
            target_node.description(), usage_target_web, usage_target_mail, usage_target_mysql_db
        )

        with target_node.runner() as runner:
            assert isinstance(runner, BaseRunner)
            required = defaultdict(int)
            available = defaultdict(int)

            web_dir = target_node.vhosts_dir 
            if runner.file_exists(web_dir):
                web_device, web_available = _get_available_disk_space(runner, web_dir)
                required[web_device] += _positive(usage_source_web - usage_target_web)
                available[web_device] = web_available

            mail_dir = target_node.mail_dir 
            if runner.file_exists(mail_dir):
                mail_device, mail_available = _get_available_disk_space(runner, mail_dir)
                required[mail_device] += _positive(usage_source_mail - usage_target_mail)
                available[mail_device] = mail_available

            mysql_data_dir = self._get_mysql_data_dir(runner)
            if runner.file_exists(mysql_data_dir):
                mysql_device, mysql_available = _get_available_disk_space(runner, mysql_data_dir)
                required[mysql_device] += _positive(usage_source_mysql_db - usage_target_mysql_db)
                available[mysql_device] = mysql_available

            if runner.file_exists(target_node.get_session_dir_path()):
                session_device, session_available = _get_available_disk_space(
                    runner, target_node.get_session_dir_path()
                )
                # Check that there is enough space for temporary database dump file in migrator session directory.
                # Also note that database dump could take much less or much more than estimated,
                # it depends on database data.
                # But we just require this space to avoid disk space issues.
                required[session_device] += max_usage_source_mysql_db
                available[session_device] = session_available

        failures = []
        for device in required.keys():
            logger.debug(messages.LOG_REQUIRED_DISK_SPACE, device, required[device])
            logger.debug(messages.LOG_AVAILABLE_DISK_SPACE, device, available[device])
            if required[device] > available[device]:
                failures.append((device, required[device], available[device]))

        if len(failures) > 0:
            report.add_issue(
                'infrastructure-disk-space', Issue.SEVERITY_ERROR,
                (
                    messages.INSUFFICIENT_DISK_SPACE_TARGET_.format(
                        target=target_node.description()
                    ) +
                    "\n".join([
                        messages.DISK_SPACE_LACK.format(
                            device=device, required=_format_bytes(required_device),
                            available=_format_bytes(available_device)
                        )
                        for device, required_device, available_device in failures
                    ]) +
                    messages.INSUFFICIENT_DISK_SPACE_SOLUTION)
            )
        else:
            report.add_issue(
                'infrastructure-disk-space', Issue.SEVERITY_INFO,
                messages.ENOUGH_DISK_SPACE_FOR_CONTENT.format(target=target_node.description())
            )

    @classmethod
    def _get_source_unix_domains_disk_usage_web(cls, server, domains):
        diskusage_function = server.get_unix_vhost_dirs
        return cls._get_unix_domains_disk_usage(
            server, domains, diskusage_function
        )

    @classmethod
    def _get_source_unix_domains_disk_usage_mail(cls, server, domains):
        diskusage_function = server.get_unix_domain_mail_dirs
        return cls._get_unix_domains_disk_usage(
            server, domains, diskusage_function
        )

    @classmethod
    def _get_unix_domains_disk_usage(cls, server, domains, diskusage_function):
        def domain_disk_usage(domain, runner):
            """
            :type runner: parallels.core.runners.base.BaseRunner
            """
            directory_list = diskusage_function(runner, domain)
            diskspace = 0
            for dir in directory_list:
                if runner.file_exists(dir):
                    diskspace += cls._get_used_disk_space(runner, dir)
            return diskspace

        with server.runner() as runner:
            return sum([domain_disk_usage(domain, runner) for domain in domains])

    @staticmethod
    def _get_used_disk_space(runner, filepath):
        """
        :type runner: parallels.core.runners.base.BaseRunner 
        :type filepath: str | unicode 
        :rtype: int 
        """
        # Example output of 'du':
        # 1572864 /var/www/vhosts/example.com/
        #
        # We need just to take the first number
        return int(
            runner.execute_command(
                '/usr/bin/du -s --block-size=1 {filepath}', dict(filepath=filepath)
            ).stdout.split()[0]
        )
    
    @staticmethod
    def _get_target_mysql_databases_usage(target_node, databases):
        """
        :type databases: list[parallels.core.utils.database_utils.DatabaseInfo]
        """
        with target_node.runner() as runner:
            return sum([
                _estimate_mysql_database_size(
                    runner, db.target_database_server.host(), db.target_database_server.port(),
                    db.target_database_server.user(), db.target_database_server.password(),
                    db.source_database_name
                )
                for db in databases
            ])

    @classmethod
    def _get_mysql_data_dir(cls, runner):
        """
        :type runner: parallels.core.runners.base.BaseRunner
        """
        mycnf_filepath = '/etc/my.cnf'
        default_mysql_data_dir = '/var/lib/mysql'
        if runner.file_exists(mycnf_filepath):
            for line in runner.get_file_contents(mycnf_filepath).split(): 
                # simply look for the first "datadir=" occurrence
                # it won't work correctly if there are multiple sections with different datadir, 
                # however as probability of such situation is low, we don't consider that case
                # also comments on the same line as datadir parameter are not supported
                m = re.match('^datadir\s*=\s*(.*)$', line.strip())
                if m is not None:
                    return m.group(1)

            return default_mysql_data_dir  # datadir parameter not found
        else:
            return default_mysql_data_dir


class UnixSourceSessionDirDiskSpaceChecker(object):
    """Check that source server has enough disk space for temporary files"""

    @staticmethod
    def check(source_server, mysql_databases, report):
        """
        :type mysql_databases: list[parallels.core.utils.database_utils.DatabaseInfo]
        :type report: parallels.core.checking.Report
        """
        all_usage_source_mysql_db = [0]
        with source_server.runner() as runner:
            for db in mysql_databases:
                source_db_server = db.source_database_server
                all_usage_source_mysql_db.append(
                    _estimate_mysql_database_size(
                        runner, source_db_server.host(), source_db_server.port(),
                        source_db_server.user(), source_db_server.password(), db.source_database_name
                    )
                )
            max_usage = max(all_usage_source_mysql_db)
            session_device, session_available = _get_available_disk_space(runner, source_server.get_session_dir_path())
        if session_available < max_usage:
            report.add_issue(
                'infrastructure-disk-space', Issue.SEVERITY_ERROR,
                messages.SESSION_DIR_INSUFFICIENT_DISK_SPACE_SOURCE_FAILURE.format(
                    source=source_server.description()
                ) +
                messages.DISK_SPACE_LACK.format(
                    device=session_device, required=_format_bytes(max_usage), available=_format_bytes(session_available)
                ) +
                messages.SESSION_DIR_INSUFFICIENT_DISK_SPACE_SOURCE_SOLUTION
            )
        else:
            report.add_issue(
                'infrastructure-disk-space', Issue.SEVERITY_INFO,
                messages.SESSION_DIR_ENOUGH_DISK_SPACE_SOURCE.format(
                    source=source_server.description().capitalize()
                )
            )

WindowsDiskSpaceSourceNode = namedtuple('WindowsDiskSpaceSourcePleskNode', (
    'node', 'domains', 'mysql_databases', 'mssql_databases'
))


class WindowsDiskSpaceChecker(object):
    def check(self, target_node, mysql_bin, source_nodes, report):

        usage_source_web = 0
        # { db_server: [usage, ...] }
        usages_source_mysql_db = defaultdict(list)
        # { db_server: [usage, ...] }
        usages_source_mssql_db = defaultdict(list)
        target_mysql_databases = []
        target_mssql_databases = []

        for source_node in source_nodes:
            for db in source_node.mssql_databases:
                _, usage_source_mssql_db = _estimate_mssql_database_size(source_node, db.db_name)
                usages_source_mssql_db[db.db_target_node.host()].append(usage_source_mssql_db)
                target_mssql_databases.append(
                    obj(
                        target_node=target_node, db_name=db.db_name,
                        db_target_node=db.db_target_node))

        for source_node in source_nodes:
            du_script_path_source_node = source_node.node.get_session_file_path(self.disk_usage_script_name)
            with source_node.node.runner() as runner_source:
                runner_source.upload_file(self.disk_usage_script_local_path, du_script_path_source_node)
                usage_source_web += self._get_windows_domains_disk_usage_web(
                    source_node.node, source_node.domains, du_script_path_source_node
                )
                runner_source.remove_file(du_script_path_source_node)

                for db in source_node.mysql_databases:
                    usage_source_mysql_db = _estimate_mysql_database_size_windows_on_source_plesk(
                        source_node.node, db.src_access.host, db.src_access.port,
                        db.src_access.user, db.src_access.password, db.db_name
                    )
                    usages_source_mysql_db[(db.db_target_node.host(), db.db_target_node.port())].append(
                        usage_source_mysql_db
                    )
                    target_mysql_databases.append(
                        obj(
                            target_node=target_node,
                            db_name=db.db_name,
                            db_target_node=db.db_target_node
                        )
                    )

        self.check_with_source_usages(
            target_node, mysql_bin,
            usage_source_web, usages_source_mysql_db, usages_source_mssql_db,
            itertools.chain(*[source_node.domains for source_node in source_nodes]),
            target_mysql_databases,
            target_mssql_databases,
            report
        )

    def check_with_source_usages(
        self, target_node, mysql_bin, usage_source_web, usages_source_mysql_db, usages_source_mssql_db,
        domains, mysql_databases, mssql_databases, report
    ):
        required = defaultdict(int)
        available = defaultdict(int)

        du_script_path_target_node = target_node.get_session_file_path(self.disk_usage_script_name)
        with target_node.runner() as runner_target:
            runner_target.upload_file(self.disk_usage_script_local_path, du_script_path_target_node)

            usage_target_web = self._get_windows_domains_disk_usage_web(
                target_node, domains, du_script_path_target_node
            )

            # { db_server: usage }
            usages_target_mysql_db = defaultdict(int)
            # { db_server: disk_letter }
            disk_letters_mysql_db = {}
            # { db_server: usage }
            usages_target_mssql_db = defaultdict(int)
            # { db_server: disk_letter }
            disk_letters_mssql_db = {}
            for db in mysql_databases:
                usage_target_mysql_db = _estimate_mysql_database_size_windows_on_target_node(
                    runner_target, mysql_bin, db.db_target_node.host(), db.db_target_node.port(),
                    db.db_target_node.user(), db.db_target_node.password(), db.db_name
                )
                usages_target_mysql_db[(db.db_target_node.host(), db.db_target_node.port())] += usage_target_mysql_db

                db_disk_letter = _get_windows_disk_letter(
                    _get_windows_mysql_datadir_on_target_node(
                        runner_target, mysql_bin, db.db_target_node.host(), db.db_target_node.port(),
                        db.db_target_node.user(), db.db_target_node.password()
                    )
                )
                disk_letters_mysql_db[(db.db_target_node.host(), db.db_target_node.port())] = db_disk_letter

            for db in mssql_databases:
                db_disk_letter, usage_target_mssql_db = _estimate_mssql_database_size(db.db_target_node, db.db_name)

                usages_target_mssql_db[db.db_target_node.host()] += usage_target_mssql_db
                disk_letters_mssql_db[db.db_target_node.host()] = db_disk_letter

            for mysql_db_server in usages_source_mysql_db:
                required[disk_letters_mysql_db[mysql_db_server]] += (
                    _positive(sum(usages_source_mysql_db[mysql_db_server]) - usages_target_mysql_db[mysql_db_server])
                )
            for mssql_db_server in usages_source_mssql_db:
                required[disk_letters_mssql_db[mssql_db_server]] += (
                    _positive(sum(usages_source_mssql_db[mssql_db_server]) - usages_target_mssql_db[mssql_db_server])
                )

            session_disk_letter = _get_windows_disk_letter(target_node.get_session_dir_path())
            if len(usages_source_mysql_db) > 0:
                required[session_disk_letter] += max(itertools.chain(*usages_source_mysql_db.values()))

            # Starting from Plesk 11.5, dbbackup.exe puts database backup into
            # %PLESK_DIR%/PrivateTemp on the target node, then restores the backup.
            # We require disk space equal to maximum source database size for the disk where %PLESK_DIR% is located.
            plesk_dir_disk_letter = _get_windows_disk_letter(target_node.plesk_dir)
            if len(usages_source_mssql_db) > 0:
                required[plesk_dir_disk_letter] += max(itertools.chain(*usages_source_mssql_db.values()))

            runner_target.remove_file(du_script_path_target_node)
            web_content_disk_letter = _get_windows_disk_letter(target_node.vhosts_dir)
            required[web_content_disk_letter] += _positive(usage_source_web - usage_target_web)

            failures = []
            for disk_letter, required_for_disk in required.items():
                available_for_disk = _get_windows_disk_free_space(target_node, disk_letter)
                available[disk_letter] = available_for_disk
                logger.debug(messages.LOG_REQUIRED_DISK_SPACE_WINDOWS, disk_letter, required_for_disk)
                logger.debug(messages.LOG_AVAILABLE_DISK_SPACE_WINDOWS, disk_letter, available_for_disk)

                if required_for_disk > available_for_disk:
                    failures.append((disk_letter, required_for_disk, available_for_disk))

            if len(failures) > 0:
                report.add_issue(
                    'infrastructure-disk-space', Issue.SEVERITY_ERROR,
                    (
                        messages.INSUFFICIENT_DISK_SPACE_TARGET_WINDOWS_ISSUE.format(
                            target=target_node.description()
                        ) +
                        "\n".join([
                            messages.EXPECTED_DISK_SPACE_BY_WINDOWS_DISKS.format(
                                disk_letter=disk_letter, required=_format_bytes(required_for_disk),
                                available=_format_bytes(fail_available_for_disk)
                            )
                            for disk_letter, required_for_disk, fail_available_for_disk in failures
                        ]) +
                        messages.INSUFFICIENT_DISK_SPACE_TARGET_WINDOWS_SOLUTION
                    )
                )
            else:
                report.add_issue(
                    'infrastructure-disk-space', Issue.SEVERITY_INFO,
                    (
                        messages.ENOUGH_DISK_SPACE_FOR_CONTENT.format(
                            target=target_node.description()
                        )
                    )
                )

    @property
    def disk_usage_script_name(self):
        return 'folder_disk_usage.vbs'

    @property
    def disk_usage_script_local_path(self):
        return migrator_utils.get_package_scripts_file_path(
            parallels.plesk.source.plesk, self.disk_usage_script_name
        )

    @classmethod
    def _get_windows_domains_disk_usage_web(cls, server, domains, du_remote_script_path):
        def domain_disk_usage(domain):
            with server.runner() as runner:
                assert isinstance(runner, BaseRunner)
                domain_directory = server.get_vhost_dir(domain)
                if runner.file_exists(domain_directory):
                    return int(
                        runner.execute_command(
                            u"{cscript_bin} {script_path} //NoLogo {domain_directory}",
                            dict(
                                script_path=du_remote_script_path, domain_directory=domain_directory,
                                cscript_bin=get_binary_full_path(server, 'cscript')
                            )
                        ).stdout.strip()
                    )
                else:
                    return 0

        total_disk_usage = sum([domain_disk_usage(domain) for domain in domains])
        return total_disk_usage


class WindowsSourceSessionDirDiskSpaceChecker(object):
    """Check that source server has enough disk space for temporary files"""

    @staticmethod
    def check(source_server, mysql_databases, report):
        """
        :type mysql_databases: list[parallels.core.utils.database_utils.DatabaseInfo]
        :type report: parallels.core.checking.Report
        """
        all_usage_source_mysql_db = [0]
        for db in mysql_databases:
            source_db_server = db.source_database_server
            usage_source_mysql_db = _estimate_mysql_database_size_windows_on_source_plesk(
                source_server, source_db_server.host(), source_db_server.port(),
                source_db_server.user(), source_db_server.password(), db.source_database_name
            )
            all_usage_source_mysql_db.append(usage_source_mysql_db)

        max_usage = max(all_usage_source_mysql_db)

        session_disk_letter = _get_windows_disk_letter(source_server.get_session_dir_path())
        session_available = _get_windows_disk_free_space(source_server, session_disk_letter)

        logger.debug(
            messages.LOG_REQUIRED_DISK_SPACE_SOURCE, source_server.description(), session_disk_letter, max_usage
        )
        logger.debug(
            messages.LOG_AVAILABLE_DISK_SPACE_SOURCE, source_server.description(), session_disk_letter,
            session_available
        )

        if session_available < max_usage:
            report.add_issue(
                'infrastructure-disk-space', Issue.SEVERITY_ERROR,
                messages.INSUFFICIENT_WINDOWS_DISK_SPACE_SOURCE_ISSUE.format(
                    description=source_server.description()
                ) +
                messages.REQUIRED_DISK_SPACE_BY_LETTER.format(
                    disk_letter=session_disk_letter, required=_format_bytes(max_usage),
                    available=_format_bytes(session_available)
                ) +
                messages.INSUFFICIENT_WINDOWS_DISK_SPACE_SOURCE_SOLUTION
            )
        else:
            report.add_issue(
                'infrastructure-disk-space', Issue.SEVERITY_INFO,
                messages.ENOUGH_DISK_SPACE_WINDOWS_SOURCE.format(
                    source=source_server.description().capitalize()
                )
            )


def check_mysql_and_pgsql_connections(report, target_db_servers):
    """
    :type target_db_servers: list[parallels.core.connections.database_server.DatabaseServer]
    """
    logger.info(messages.LOG_CHECK_CONNECTIONS_TO_TARGET_DATABASE_SERVERS)
    db_report = report.subtarget(messages.REPORT_TITLE_CONNECTIONS_TO_TARGET_DATABASE_SERVERS, None)
    for server in target_db_servers:
        try:
            logger.debug(messages.LOG_CHECK_CONNECTION_TO_TARGET_DATABASE_SERVER, server.description())
            server.check_connection()
        except DatabaseServerConnectionException as e:
            logger.exception()
            db_report.add_issue(
                'target_database_server_connection_issue',
                Issue.SEVERITY_ERROR, safe_string_repr(e),
                messages.DATABASE_SERVER_CONNECTION_ISSUE
            )
        except Exception as e:
            logger.exception()
            db_report.add_issue(
                'target_database_server_connection_internal_error',
                Issue.SEVERITY_WARNING,
                messages.INTERNAL_ERROR_CHECKING_TARGET_DATABASE_SERVER % (
                    server.description(), safe_string_repr(e)
                ),
                messages.CHECK_SKIPPED
            )
        else:
            db_report.add_issue(
                'target_database_server_connection_ok',
                Issue.SEVERITY_INFO,
                messages.SUCCESSFULLY_CONNECTED_TO % server.description()
            )


def _get_available_disk_space(runner, filepath):
    """Get available disk space in bytes on filesystem where file or directory 'filepath' is located
    
    :type runner: parallels.core.runners.base.BaseRunner
    """
    return _get_device_available_bytes_from_df_output(
        runner.execute_command(
            # in certain cases df outputs usage in a format difficult to parse
            # (device and usage are on different lines), we use "-P" to make its behavior consistent
            '/bin/df -P {filepath}', dict(filepath=filepath)
        ).stdout
    )


def _get_device_available_bytes_from_df_output(output):
    """Parse output of 'df' Unix utility and return tuple (device, number available bytes)
    on the first filesystem of output"""

    lines = output.split("\n")
    if len(lines) < 2:
        raise Exception(messages.UNIX_DF_OUTPUT_PARSE_ISSUE_EXPECTED_MORE_LINES % (len(lines), output))

    # Example output:
    #
    # Filesystem         1024-blocks      Used Available Capacity Mounted on
    # /dev/hda1             27769928   1654556  24681976       7% /
    #
    # ^^^^^^^^^                                 ^^^^^^^^
    # we need the items above 

    fs_line_items = lines[1].split()
    if len(fs_line_items) < 4:
        raise Exception(messages.UNIX_DF_OUTPUT_PARSE_ISSUE_EXPECTED_AT_LEAST_ONE_ITEM % (fs_line_items,))

    return fs_line_items[0], int(fs_line_items[3]) * 1024


def _get_windows_disk_letter(path):
    """
    :type path: str 
    :rtype: str 
    """
    disk_letter, _ = path.split(':')
    return disk_letter.upper() 


def _get_windows_disk_free_space(server, disk_letter):
    # Sample output of fsutil utility:
    #
    # Total # of free bytes        : 31998562304
    # Total # of bytes             : 53684989952
    # Total # of avail free bytes  : 31998562304

    with server.runner() as runner:
        assert isinstance(runner, BaseRunner)
        output = runner.execute_command(u"{fsutil_bin} volume diskfree {disk_letter}:", dict(
            disk_letter=disk_letter,
            fsutil_bin=get_binary_full_path(server, 'fsutil')
        ), ExecutionOptions(output_encoding=runner.native_output_encoding)).stdout
    first_line = output.split("\n")[0]
    _, bytes_count_str = first_line.split(':')
    return int(bytes_count_str.strip())


def _estimate_mysql_database_size(runner, host, port, login, password, db_name):
    """Please consider that this is estimation only, so:
    - On the target box database could take more or less space
    - Database text dump file could take *much* more or *much* less space - it depends on data.

    However we can use this estimation as some minimal requirement for migration.
    
    :type runner: parallels.core.runners.base.BaseRunner
    """

    if (host, port, db_name) not in _estimate_mysql_database_size.cache:
        try:
            query = u"""
            SELECT
                COALESCE(SUM(data_length), 0)
            FROM information_schema.TABLES
            WHERE information_schema.TABLES.TABLE_SCHEMA = '{db_name}'
            """.format(db_name=db_name)

            command = ''
            stdin = None
            # workaround for Plesk feature - it does not tell default MySQL server's admin password
            if host == 'localhost' and port == 3306:
                command += u'MYSQL_PWD="$(cat /etc/psa/.psa.shadow)" '
            else:
                command += u'MYSQL_PWD="$(cat)" '
                stdin = password

            command += (
                u"mysql -h {host} -P {port} -u{login} " +
                u" --silent --skip-column-names -e {query}"
            )
            args = dict(host=host, port=port, login=login, query=query)

            _estimate_mysql_database_size.cache[(host, port, db_name)] = int(
                runner.execute_command(command, args, ExecutionOptions(stdin_content=stdin))
            )
        except Exception:
            # if there are connection problems - just consider database uses 0 bytes
            logger.exception()
            _estimate_mysql_database_size.cache[(host, port, db_name)] = 0

    return _estimate_mysql_database_size.cache[(host, port, db_name)]

_estimate_mysql_database_size.cache = dict()


def _estimate_mssql_database_size(server, db_name):
    try:
        def parse_disk_usage(s):
            sizes = { 
                '': 1,
                'KB': 1024,
                'MB': 1024 * 1024,
                'GB': 1024 * 1024 * 1024,
                'TB': 1024 * 1024 * 1024 * 1024,
            }
            m = re.match("(\d+(.\d+))\s*(KB|MB|GB|TB|)", s)
            if m is not None:
                return int(float(m.group(1)) * sizes[m.group(3)])
            else:
                raise Exception(messages.MSSQL_UNABLE_TO_PARSE_DISK_SPACE_SCRIPT_OUTPUT % (s,))

        usage_info = {}
        with server.runner() as runner:
            connection_settings = MSSQLConnectionSettings(
                host=server.host(),
                user=server.user(),
                password=server.password()
            )
            for row in runner.mssql_query(connection_settings, "EXEC sp_spaceused"):
                for col_name, col_value in row.items():
                    usage_info[col_name] = col_value

            for row in runner.mssql_query(connection_settings, "SELECT name, physical_name FROM sys.master_files"):
                if row.get('name') == db_name:
                    usage_info['db_file_location'] = row.get('physical_name')

        logger.fdebug(messages.DEBUG_MSSQL_DISK_USAGE_INFO, disk_usage_info=repr(usage_info))

        if 'database_size' not in usage_info or 'db_file_location' not in usage_info:
            raise Exception(safe_format(
                messages.MSSQL_UNABLE_TO_FIND_DB_SIZE_IN_SCRIPT_OUTPUT,
                db_name=db_name, server=server.description()
            ))

        return _get_windows_disk_letter(usage_info['db_file_location']), parse_disk_usage(usage_info['database_size'])
    except Exception:
        # for example, if database does not exist or there are connection
        # problems - just consider database uses 0 bytes not to break all checks
        logger.exception()
        return 'C', 0


def _estimate_mysql_database_size_windows_on_source_plesk(source_node, host, port, login, password, db_name):
    plesk_dir = source_node.plesk_dir
    mysql_bin = u'{plesk_dir}\\MySQL\\bin\\mysql'.format(plesk_dir=plesk_dir)
    with source_node.runner() as runner:
        return _estimate_mysql_database_size_windows(
            runner, mysql_bin, host, port, login, password, db_name
        )


def _estimate_mysql_database_size_windows_on_target_node(runner, mysql_bin, host, port, login, password, db_name):
    return _estimate_mysql_database_size_windows(
        runner, mysql_bin, host, port, login, password, db_name
    )


def _estimate_mysql_database_size_windows(runner, mysql_bin, host, port, login, password, db_name):
    try:
        mysql_output = _execute_windows_mysql_one_value_query(
            runner, mysql_bin, host, port, login, password, db_name, query=_get_estimate_mysql_database_size_query(db_name)
        ).strip()
        
        if mysql_output == 'NULL':
            return 0
        else:
            return int(mysql_output)
    except Exception:
        # for example, if database does not exist or there are connection
        # problems - just consider database uses 0 bytes not to break all checks
        logger.exception()
        logger.debug(messages.NOTICE_THAT_WE_IGNORE_EXCEPTION_AND_USE_0_AS_DISK_SPACE_USAGE)
        return 0


def _get_windows_mysql_datadir_on_target_node(runner, mysql_bin, host, port, login, password):
    """
    :type runner: parallels.core.runners.base.BaseRunner
    :rtype: str
    """
    query = u"SHOW VARIABLES WHERE Variable_name = 'datadir'"
    command = (
        u'cmd.exe /C "{mysql_bin} -h {host} -P {port} -u{login} -p{password} '
        u'--silent --skip-column-names -e {query}"'
    )
    args = dict(mysql_bin=mysql_bin, host=host, port=port, login=login, password=password, query=query)
    return runner.execute_command(command, args).stdout.strip().split()[1]


def _execute_windows_mysql_one_value_query(runner, mysql_bin, host, port, login, password, db_name, query):
    """
    :type runner: parallels.core.runners.base.BaseRunner
    """
    command = (
        u'{mysql_bin} -h {host} -P {port} -u{login} -p{password} {db_name} '
        u'--silent --skip-column-names -e {query}'
    )
    args = dict(mysql_bin=mysql_bin, host=host, port=port, login=login, password=password, db_name=db_name, query=query)
    execution_results = runner.execute_command(command, args, ExecutionOptions(ignore_exit_code=True))
    if execution_results.exit_code != 0:
        raise Exception(
            messages.MYSQL_COMMAND_NON_ZERO_EXIT_CODE % (
                command, args, safe_string_repr(execution_results.stdout), safe_string_repr(execution_results.stderr)
            )
        )
    return execution_results.stdout


def _get_estimate_mysql_database_size_query(db_name):
    query = (
        "SELECT SUM(data_length) FROM information_schema.TABLES "
        "WHERE information_schema.TABLES.TABLE_SCHEMA = '{db_name}'"
    )
    return query.format(db_name=db_name)


def _format_bytes(bytes_count):
    KB = 1024.0
    MB = 1024.0 * KB
    GB = 1024.0 * MB

    if bytes_count < KB:
        return u"%s bytes" % (bytes_count,)
    elif bytes_count < MB:
        return u"%.2fKB" % (bytes_count / KB)
    elif bytes_count < GB:
        return u"%.2fMB" % (bytes_count / MB)
    else:
        return u"%.2fGB" % (bytes_count / GB)


def _positive(i):
    return i if i > 0 else 0


def check_windows_copy_content_rsync_connection(subscriptions_info, get_rsync, content_type, report):
    """
    Arguments:
    - subscriptions_info - list of subscriptions with info about nodes,
    instance of SubscriptionsInfo
    - get_rsync - function to retrive rsync
    - content_type -  type of content for which testing connection between
    nodes
    - report - storage object parallels.core.checking.Report
    """
    NodesPair = namedtuple('NodesPair', ('target', 'source_ip'))
    # form up the list of subscriptions which will be affected
    # for each pair of <target, source_ip>, try retrieving the list of web files of any one related subscription
    subscriptions_by_nodes_pair = group_by(
        subscriptions_info, lambda si: NodesPair(target=si.target_node, source_ip=si.source_ip)
    )

    for nodes_info, subscriptions in subscriptions_by_nodes_pair.items():
        try:
            rsync = get_rsync(None, nodes_info.target, nodes_info.source_ip)
            if content_type == 'web':
                rsync.list_files('vhosts/%s/' % subscriptions[0].subscription_dir)
            elif content_type == 'mail':
                rsync.list_files('migrator/')
        except RsyncClientNonZeroExitCode as e:
            logger.exception()

            report.add_issue(
                'infrastructure-web', Issue.SEVERITY_ERROR,
                messages.RSYNC_CONNECTION_WINDOWS_COPY_CONTENT_FAILURE % (
                    nodes_info.target.description(),
                    nodes_info.source_ip,
                    safe_string_repr("\n".join(e.stderr.strip().split("\n")[-5:])),
                    content_type,
                    format_list([s.subscription_name for s in subscriptions])
                ),
                messages.RSYNC_CONNECTION_WINDOWS_COPY_CONTENT_SOLUTION)
            return

        report.add_issue(
            'infrastructure-web', Issue.SEVERITY_INFO,
            messages.RSYNC_CONNECTION_WINDOWS_COPY_CONTENT_SUCCESS % (
                nodes_info.target.description(),
                nodes_info.source_ip
            )
        )
