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

from parallels.common.utils import unix_utils, migrator_utils, windows_utils, database_utils
from parallels.common.checking import Problem
from parallels.common import checking
from parallels.common.migrator_config import MailImapEncryption
from parallels.common.utils.database_utils import DatabaseServerConnectionException
from parallels.common.windows_rsync import RsyncClientNonZeroExitCode

from parallels.utils import format_list, group_by, safe_string_repr, obj
from parallels.common.powershell import Powershell

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

logger = logging.getLogger(__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_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.iteritems():
			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.iteritems():
			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 databases])) 
						for db_type, databases in group_by(subscription_databases, lambda d: d['db_type']).iteritems()
					])
				)
				for subscription, subscription_databases in group_by(db_infos, lambda d: d['subscription']).iteritems()
			)

		for databases, success, reason in check_result:
			if not success:
				report.add_issue(
					Problem(
						'infrastructure-databases', Problem.ERROR, 
						("%s\nFix found issues and run the transfer-accounts command again.\n" + 
						"If the issues are not fixed, it will be not possible to transfer databases for the following subscription(s): %s"
						) % (reason, format_databases(databases)) 
					), u""
				)
			else:
				report.add_issue(Problem('infrastructure-unix-databases', Problem.INFO, reason), u"")

	@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(
					Problem(
						issue_id, Problem.ERROR, 
						("%s\nFix found issues and run the transfer-accounts command again.\n" + 
						"If the issues are not fixed, it will be not possible to transfer %s for the following subscription(s): %s"
						) % (reason, object_name, format_list(subscriptions)) 
					), u""
				)
			else:
				report.add_issue(Problem(issue_id, Problem.INFO, reason), u"")


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("Target main node node disk space check - subscriptions count: %s, required: %r, available: %r", subscriptions_count, dict(required), dict(available))
	checks_passed = True
	for device, device_required in required.iteritems():
		if device_required > available[device]:
			logger.error("Required to have more than {required} on {device} device, available: {available}.".format(
				required=_format_bytes(device_required), device=device, available=_format_bytes(available[device])
			))
			checks_passed = False
	
	return checks_passed


class WindowsFileCopyBetweenNodesRsyncChecker(object):
	def check(self, nodes, filename, local_temp_filepath, get_rsync):
		"""
		Arguments:
			nodes - instance of NodesPair class
			source_temp_filename - name of temporary file in migration session directory of the source server
			source_temp_filepath - full path to a temporary file in migration session directory of the source server
		Returns tuple with:
			1st element: True if check passed successfully, False otherwise
			2nd element: None if check passed successfully, string describing failure reason otherwisersync_binary_path - path of rsync.exe on the target node
		"""
		message_formatter = CopySampleFileMessageFormatter(nodes)
		logger.info(message_formatter.format_check_log_message(self.get_command_name()))

		target_temp_filepath = nodes.target.get_session_file_path(filename)
		source_temp_filepath = nodes.source.get_session_file_path(filename)

		with nodes.source.runner() as runner_source:
			with temporary_file(local_temp_filepath, contents='test-file-contents'):
				runner_source.upload_file(local_temp_filepath, source_temp_filepath)

		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.debug("Exception, ", exc_info=True)

			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(object):
	def check(self, nodes, filename, local_temp_filepath, ssh_key_pool):
		"""
		:type ssh_key_pool: parallels.common.utils.ssh_key_pool.SSHKeyPool

		Arguments:
			nodes - instance of NodesPair class
			source_user - check connection to source server under this user
		Returns tuple with:
			1st element: True if check passed successfully, False otherwise
			2nd element: None if check passed successfully, string describing failure reason otherwisersync_binary_path - path of rsync.exe on the target node
		"""
		message_formatter = CopySampleFileMessageFormatter(nodes)
		logger.info(message_formatter.format_check_log_message(self.get_command_name()))

		source_temp_filepath = nodes.source.get_session_file_path(filename)
		target_temp_filepath = nodes.target.get_session_file_path(filename)

		test_file_contents = 'test-file-contents'
		with temporary_file(local_temp_filepath, test_file_contents):
			with nodes.source.runner() as runner_source:
				runner_source.upload_file(local_temp_filepath, source_temp_filepath)
		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,
			)
			exit_code, stdout, stderr = runner_target.run_unchecked(cmd, args)

			command_description = message_formatter.format_command_description(
				command_str=unix_utils.format_command_list(cmd, args),
				exit_code=exit_code, stdout=stdout, stderr=stderr
			)

			if exit_code != 0:
				problem_description = (
					u"Please check that:\n"
					u"1) There are no firewall rules blocking SSH connections from the target node to the source server.\n"
					u"TCP port 22 on the source server is open for incoming connections, TCP port 22 on the target node is open for outgoing connections.\n"
					u"2) The SSH service is running on the source server.\n"
					u"3) The source server allows using the key-based SSH authentication for the target node."
				)

				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 != test_file_contents:
					problem_description = (
						u"Unexpected error: invalid content of file was transferred with '%s'\n" % (self.get_command_name(),) +
						u"Expected content: %s\n" % (test_file_contents,) +
						u"Actual content on target node: %s\n" % (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 get_command_name():
		"""Override in child classes"""
		raise NotImplementedError()

	@staticmethod
	def _create_command(key_pathname, source_runner, source_user, source_ip, source_temp_filepath, target_temp_filepath):
		"""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):
		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
		)


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):
		cmd = '/usr/bin/scp'
		args = [
			'-i', key_pathname, '-o', 'StrictHostKeyChecking=no', '-o', 'GSSAPIAuthentication=no',
			u"{source_user}@{source_ip}:{source_filename}".format(
				source_user=source_user, 
				source_ip=source_ip, 
				source_filename=source_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."""

		failures = []
		source_db_max_allowed_packet = cls._get_source_mysql_max_allowed_packet(source_database_server)
		target_db_max_allowed_packet = cls._get_target_mysql_max_allowed_packet(target_database_server)
		
		logger.debug(
			u"Max allowed packet for mysql on '%s': %s bytes",
			source_database_server.description(),
			source_db_max_allowed_packet
		)
		logger.debug(
			u"Max allowed packet for mysql on '%s': %s bytes",
			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(
				Problem(
					'infrastructure-max-allowed-packet', Problem.ERROR,
					(
						"Some database objects might not be restored.\n" +
						"\n".join([
							" - The 'max_allowed_packet' parameter on {source_host} is {source}, which is bigger than on {target_host}, where it is {target}.".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
						]) +
						"\nPlease increase the 'max_allowed_packet' value on the destination database server.\n"
					)
				), u""
			)
		else:
			report.add_issue(
				Problem(
					'infrastructure-max-allowed-packet', Problem.INFO,
					(
						u"{description_target_database} has enough max allowed packet for all MySQL databases.".format(
							description_target_database=description_target_database
						)
					)
				), u""
			)

	@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):
		raise NotImplementedError()

	@classmethod
	def _get_target_mysql_max_allowed_packet(cls, target_database_server):
		raise NotImplementedError()


class UnixMysqlMaxAllowedPacketChecker(MysqlMaxAllowedPacketChecker):
	@classmethod
	def _get_source_mysql_max_allowed_packet(cls, source_database_server):
		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):
		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.login(), target_database_server.password()
				)

	@classmethod
	def _mysql_max_allowed_packet_unix(cls, runner, host, port, login, password):
		# workaround for Plesk feature - it does not tell default MySQL server's admin password
		if host == 'localhost' and port == 3306:
			password_param = u" -p\"`cat /etc/psa/.psa.shadow`\""
		else:
			password_param = u" -p{password}"

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

		return int(runner.sh(command, args)[len("max_allowed_packet"):])


class WindowsMysqlMaxAllowedPacketChecker(MysqlMaxAllowedPacketChecker):
	@classmethod
	def _get_source_mysql_max_allowed_packet(cls, source_database_server):
		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()
				)
	@classmethod
	def _get_target_mysql_max_allowed_packet(cls, target_database_server):
		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.login(), target_database_server.password()
				)

	@classmethod
	def _mysql_max_allowed_packet_windows(cls, runner, mysql_bin, host, port, login, password):
		mysql_output = cls._execute_windows_mysql_one_value_query(
			runner, mysql_bin, host, port, login, password, cls._get_mysql_max_allow_packet_query()
		).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):
		command = ur'cmd.exe /C "{mysql_bin} -h {host} -P {port} -u{login} -p{password} --silent --skip-column-names -e {query}"'
		args = dict(mysql_bin=mysql_bin, host=host, port=port, login=login, password=password, query=query)
		exit_code, stdout, stderr = runner.sh_unchecked(command, args)
		if exit_code != 0:
			raise Exception("MySQL command '%s' with args %r returned non-zero exit code.\nStdout:\n%s\nStderr:\n%s", command, args, safe_string_repr(stdout), safe_string_repr(stderr))
		return stdout

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


class MSSQLConnectionChecker:
	def check(self, nodes, mssql_connection_data, local_script_filepath, script_filename):
		target_script_temp_filepath = nodes.target.get_session_file_path(script_filename)
		logger.info(self._format_check_log_message(nodes, mssql_connection_data))

		with open(local_script_filepath, 'r') as f:
			with nodes.target.runner() as runner_target:
				runner_target.upload_file_content(target_script_temp_filepath, f.read())

		with nodes.target.runner() as runner_target:
			args = {
				'dataSource' : mssql_connection_data.host,
				'login' : mssql_connection_data.login,
				'pwd' : mssql_connection_data.password
			}
			exit_code, stdout, stderr = Powershell(runner_target, input_format_none=True).execute_script_unchecked(
				target_script_temp_filepath, args
			)
			if exit_code != 0 or stderr != '':
				cmd = Powershell(runner_target, input_format_none=True).get_command_string(
					target_script_temp_filepath, args
				)
				return Result.failure(self._format_script_failure(
					nodes, mssql_connection_data, cmd, exit_code, stdout, stderr
				))
			else:
				if stdout == 'OK\n':
					return Result.success(self._format_success(nodes, mssql_connection_data))
				else:
					return Result.failure(self._format_connection_failure(nodes, mssql_connection_data, stdout))

	@staticmethod
	def _format_success(nodes, mssql_connection_data):
		return (
			u"Successfully connected from {target} "
			u"to MSSQL server '{mssql_host}' (user '{mssql_user}') located on {source}"
		).format(
			mssql_host=mssql_connection_data.host, mssql_user=mssql_connection_data.login,
			target=nodes.target.description(), source=nodes.source.description(),
		)

	@staticmethod
	def _format_connection_failure(nodes, mssql_connection_data, connection_error_message):
		return (
			u"Unable to connect from {target} "
			u"to MSSQL server '{mssql_host}' (user '{mssql_user}') located on {source}.\n\n"
			u"Please check that:\n"
			u"1) There are no firewall rules blocking MSSQL connections from the target node to the source server.\n"
			u"2) Remote connections to MSSQL server are allowed.\n"
			u"3) MSSQL server is running.\n\n"
			u"Connection error message: {connection_error_message}"
		).format(
			mssql_host=mssql_connection_data.host, mssql_user=mssql_connection_data.login,
			target=nodes.target.description(), source=nodes.source.description(),
			connection_error_message=connection_error_message
		)

	@staticmethod
	def _format_script_failure(nodes, mssql_connection_data, command_str, exit_code, stdout, stderr):
		return (
			u"Unable to check connection from {target} "
			u"to MSSQL server '{mssql_host}' (user '{mssql_user}') located on {source}.\n"
			u"Command the migrator tried to execute on {target} was: {command_str}\n"
			u"exit code: {exit_code}\n"
			u"stdout: {stdout}\n"
			u"stderr: {stderr}\n"
		).format(
			mssql_host=mssql_connection_data.host, mssql_user=mssql_connection_data.login,
			target=nodes.target.description(), source=nodes.source.description(),
			command_str=command_str, exit_code=exit_code, stdout=stdout, stderr=stderr
		)

	@staticmethod
	def _format_check_log_message(nodes, mssql_connection_data):
		return (
			u"Check connection from {target} "
			u"to MSSQL server '{mssql_host}' (user '{mssql_user}') located on {source}"
		).format(
			mssql_host=mssql_connection_data.host, mssql_user=mssql_connection_data.login,
			target=nodes.target.description(), source=nodes.source.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):
		if node.mail_imap_encryption() == MailImapEncryption.SSL:
			return 993
		else: # no encryption and TLS
			return 143

	def _format_log_message(self, node):
		return (
			u"Check connection to {node} by IMAP ({port_number} port)"
		).format(
			node=node.description(),
			port_number=self._get_port_number(node)
		)

	def _format_failure(self, node):
		return (
			u"Unable to connect to {node} by IMAP ({port_number} port).\n"
			u"Please check that:\n"
			u"1) There are no firewall rules blocking IMAP connections from the current server to the source server.\n"
			u"2) IMAP server is running on the source server\n"
			u"3) If you have enforced access by SSL, make sure you "
			"specified correct value for 'imap-encryption' option in config.ini\n"
			u"4) If you don't have IMAP on the source server, configure the tool\n"
			u"to copy Inbox messages only with 'copy-mail-content: messages'\n"
			u"option in corresponding section of configuration file.\n"
			u"In that case migration tools will use POP3 to copy messages"
		).format(
			node=node.description(),
			port_number=self._get_port_number(node)
		)

	def _format_success(self, node):
		return (
			"Successfully connected to {node} by IMAP ({port_number} port)."
		).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 (
			u"Check connection to {node} by POP3 ({port_number} port)"
		).format(
			node=node.description(), 
			port_number=self._get_port_number(node)
		)

	def _format_failure(self, node):
		return (
			u"Unable to connect to {node} by POP3 ({port_number} port).\n"
			u"Please check that:\n"
			u"1) There are no firewall rules blocking POP3 connections from the current server to the source server.\n"
			u"2) POP3 server is running on the source server."
		).format(
			node=node.description(), 
			port_number=self._get_port_number(node)
		)

	def _format_success(self, node):
		return (
			"Successfully connected to {node} by POP3 ({port_number} port)."
		).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 (
			u"Check connection to {node} by IMAP ({port_number} port)"
		).format(
			node=node.description(), 
			port_number=self._get_port_number(node)
		)

	def _format_failure(self, node):
		return (
			u"Unable to connect to {node} by IMAP ({port_number} port).\n"
			u"Please check that:\n"
			u"1) There are no firewall rules blocking IMAP connections from the current server to the target node.\n"
			u"2) IMAP server is running on the target node"
		).format(
			node=node.description(), 
			port_number=self._get_port_number(node)
		)

	def _format_success(self, node):
		return (
			"Successfully connected to {node} by IMAP ({port_number} port)."
		).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 (
			u"Unable to copy the sample file from {source} to {target} using '{command_name}'.\n\n" +
			u"{problem_description}\n\n" +
			u"The following command has failed:\n"
			u"{failed_command_description}"
		).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 u"Successfully copied the sample file using '{command_name}' from {source} to {target}".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 (
			u"Command the migrator tried to execute on {target} was: {command_str}\n"
			u"exit code: {exit_code}\n"
			u"stdout: {stdout}\n"
			u"stderr: {stderr}\n"
		).format(
			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 (
			u"Please check that:\n"
			u"1) There are no firewall rules blocking rsync connections from the target node to the source server.\n"
			u"TCP port 873 on the source server is open for incoming connections, TCP port 873 on the target node is open for outgoing connections.\n"
			u"2) rsync server is configured on the source server according to the Migration Guide.\n"
			u"3) rsync server is started on the source server."
		)

	def format_unix_rsync_problem_description(self):
		return (
			u"Please check that:\n"
			u"1) There are no firewall rules blocking SSH connections from the target node to the source server.\n"
			u"TCP port 22 on the source server is open for incoming connections, TCP port 22 on the target node is open for outgoing connections.\n"
			u"2) The SSH service is running on the source server.\n"
			u"3) The source server allows using the key-based SSH authentication for the target node."
		)

	def format_unix_rsync_invalid_file_content_problem_description(self, command_name, expected_file_contents, actual_file_contents):
		return (
			u"Unexpected error: invalid content of file was transferred with '{command_name}'\n"
			u"Expected content: {expected_file_contents}\n"
			u"Actual content on target node: {actual_file_contents}\n"
		).format(command_name=command_name, expected_file_contents=expected_file_contents, actual_file_contents=actual_file_contents)

	def format_check_log_message(self, command_name):
		return (
			u"Copy the sample file from {source} "
			u"to {target} using '{command_name}'"
		).format(
			command_name=command_name,
			source=self.nodes.source.description(),
			target=self.nodes.target.description()
		)


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

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

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

DiskSpaceDatabaseAccess = namedtuple('DiskSpaceDatabaseAccess', ('host', 'port', 'login', 'password',))
DiskSpaceDatabase = namedtuple('DiskSpaceDatabase', (
	'src_access',  # DiskSpaceDatabaseAccess (MSSQLConnectionData in case of MSSQL) for source server
	'db_target_node',  # PleskDatabaseTargetServer (PleskDatabaseTargetServer in case of PPA) for target server
	'db_name'
))
UnixDiskSpaceSourcePleskNode = namedtuple('UnixDiskSpaceSourcePleskNode', ('node', 'web_domains', 'mail_domains', '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.common.checking.Report
		Returns: 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:
					single_usage_source_mysql_db = _estimate_mysql_database_size(
						runner_source, db.src_access.host, db.src_access.port, db.src_access.login, db.src_access.password, db.db_name
					)
					usage_source_mysql_db += single_usage_source_mysql_db
					all_usage_source_mysql_db.append(single_usage_source_mysql_db)

		logger.debug(
			u"Total disk usage on the source nodes for domains migrated to node %s:\n- Web: %s bytes\n- Mail: %s bytes\n- MySQL databases: %s bytes",
			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(
			u"Total disk usage on the target node for domains migrated to %s:\n- Web: %s bytes\n- Mail: %s bytes\n- MySQL databases: %s bytes",
			target_node.description(), usage_target_web, usage_target_mail, usage_target_mysql_db
		)

		with target_node.runner() as runner:
			required = defaultdict(int)
			available = defaultdict(int)

			web_dir = target_node.vhosts_dir 
			if self._file_exists(runner, 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 self._file_exists(runner, 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 self._file_exists(runner, 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 self._file_exists(runner, target_node.session_dir()):
				session_device, session_available = _get_available_disk_space(runner, target_node.session_dir())
				# 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(u"Required disk space for '%s' device: %s bytes", device, required[device])
			logger.debug(u"Available disk space for '%s' device: %s bytes", device, available[device])
			if required[device] > available[device]:
				failures.append((device, required[device], available[device]))

		if len(failures) > 0:
			report.add_issue(
				Problem(
					'infrastructure-disk-space', Problem.ERROR, 
					(
						u"Insufficient disk space on {target}:\n".format(
							target=target_node.description()
						) +
						"\n".join([
							u"- '{device}': expected to have more than {required} for migrated content, actually have {available}.".format(
								device=device, required=_format_bytes(required_device), available=_format_bytes(available_device)
							)
							for device, required_device, available_device in failures
						]) +
						u"\nTo resolve the issue you could:\n"
						u"1) Increase disk space on the node.\n"
						u"2) Migrate subscriptions to another server. "
						u"\n"
						u"Fix found issues and run the transfer-accounts command again.\n" 
					)
				), u""
			)
		else:
			report.add_issue(
				Problem(
					'infrastructure-disk-space', Problem.INFO, 
					(
						u"{target} has enough disk space for content.".format(
							target=target_node.description()
						)
					)
				), u""
			)

	@staticmethod
	def _file_exists(runner, filepath):
		exit_code, _, _ = runner.run_unchecked('/usr/bin/test', ['-e', filepath])
		return exit_code == 0

	@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):
			directory_list = diskusage_function(runner, domain)
			diskspace = 0
			for dir in directory_list:
				if cls._file_exists(runner, 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):
		# Example output of 'du':
		# 1572864 /var/www/vhosts/example.com/
		#
		# We need just to take the first number
		return int(
			runner.run('/usr/bin/du', ['-s', '--block-size=1', filepath]).split()[0]
		)
	
	@staticmethod
	def _get_target_mysql_databases_usage(target_node, databases):
		with target_node.runner() as runner:
			return sum([
				_estimate_mysql_database_size(
					runner, db.db_target_node.host(), db.db_target_node.port(),
					db.db_target_node.login(), db.db_target_node.password(),
					db.db_name
				)
				for db in databases
			])

	@classmethod
	def _get_mysql_data_dir(cls, runner):
		mycnf_filepath = '/etc/my.cnf'
		default_mysql_data_dir =  '/var/lib/mysql'
		if cls._file_exists(runner, 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):
	def check(self, source_node, mysql_databases, report):
		all_usage_source_mysql_db = [0]
		with source_node.runner() as runner:
			for db in mysql_databases:
				all_usage_source_mysql_db.append(
					_estimate_mysql_database_size(runner, db.src_access.host, db.src_access.port, db.src_access.login, db.src_access.password, db.db_name)
				)
			max_usage = max(all_usage_source_mysql_db)
			session_device, session_available = _get_available_disk_space(runner, source_node.session_dir())
		if session_available < max_usage:
			report.add_issue(
				Problem(
					'infrastructure-disk-space', Problem.ERROR, 
					(
						u"Insufficient disk space on {source}:\n".format(
							source=source_node.description()
						) +
						u"- '{device}': expected to have more than {required} for migrated content, actually have {available}.".format(
							device=session_device, required=_format_bytes(max_usage), available=_format_bytes(session_available)
						) + 
						u"\nTo resolve the issue you could:\n"
						u"1) Free disk space for the partition.\n"
						u"2) Change 'session-dir' config.ini parameter for the server so it points to directory located on partition that has enough disk space.\n"
						u"\n"
						u"Fix found issues and run the transfer-accounts command again.\n" 
					)
				), u""
			)
		else:
			report.add_issue(
				Problem(
					'infrastructure-disk-space', Problem.INFO, 
					(
						u"{source} has enough disk space for temporary files.".format(
							source=source_node.description().capitalize()
						)
					)
				), u""
			)

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


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

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

		for source_node in source_nodes:
			du_script_path_source_node = source_node.node.get_session_file_path(
				os.path.basename(du_local_script_path)
			)
			mssql_disk_usage_script_path_source_node = source_node.node.get_session_file_path(
				os.path.basename(mssql_disk_usage_local_script_path)
			)
			with source_node.node.runner() as runner_source:
				runner_source.upload_file(du_local_script_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.login, 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
						)
					)


				runner_source.upload_file(mssql_disk_usage_local_script_path, mssql_disk_usage_script_path_source_node)
				for db in source_node.mssql_databases:
					_, usage_source_mssql_db = _estimate_mssql_database_size(
						mssql_disk_usage_script_path_source_node,
						runner_source, db.src_access.host, db.src_access.login, db.src_access.password, 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
						)
					)

		self.check_with_source_usages(
			target_node, mysql_bin,
			du_local_script_path, 
			mssql_disk_usage_local_script_path, 
			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,
		du_local_script_path,
		mssql_disk_usage_local_script_path, 
		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(
			os.path.basename(du_local_script_path)
		)
		mssql_disk_usage_script_path_target_node = target_node.get_session_file_path(
			os.path.basename(mssql_disk_usage_local_script_path)
		)

		with target_node.runner() as runner_target:
			with open(du_local_script_path, 'r') as f:
				runner_target.upload_file_content(du_script_path_target_node, f.read())

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

			with open(mssql_disk_usage_local_script_path, 'r') as f:
				runner_target.upload_file_content(mssql_disk_usage_script_path_target_node, f.read())

			usages_target_mysql_db = defaultdict(int)	# { db_server: usage }
			disk_letters_mysql_db = {}			# { db_server: disk_letter }
			usages_target_mssql_db = defaultdict(int)	# { db_server: usage }
			disk_letters_mssql_db = {}			# { db_server: disk_letter }
			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.login(), 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.login(), 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(
					mssql_disk_usage_script_path_target_node,
					runner_target, db.db_target_node.host(), db.db_target_node.login(), db.db_target_node.password(), 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.session_dir())
			if len(usages_source_mysql_db) > 0:
				required[session_disk_letter] += max(itertools.chain(*usages_source_mysql_db.values()))

			# Starting from Plesk/PPA 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)
			runner_target.remove_file(mssql_disk_usage_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.iteritems():
				available_for_disk = _get_windows_disk_free_space(runner_target, disk_letter)
				available[disk_letter] = available_for_disk
				logger.debug(u"Required disk space for '%s:' disk: %s bytes", disk_letter, required_for_disk)
				logger.debug(u"Available disk space for '%s:' disk: %s bytes", 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(
					Problem(
						'infrastructure-disk-space', Problem.ERROR, 
						(
							u"Insufficient disk space on {target}:\n".format(target=target_node.description()) + 
							"\n".join([
								u"- Disk {disk_letter}: - expected to have more than {required} for migrated content, actually have {available}\n".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
							]) +
							u"\nTo resolve the issue you could:\n"
							u"1) Increase disk space on the node.\n"
							u"2) Migrate subscriptions to another server. "
							u"\n"
							u"Fix found issues and run the transfer-accounts command again.\n" 
						)
					), u""
				)
			else:
				report.add_issue(
					Problem(
						'infrastructure-disk-space', Problem.INFO, 
						(
							u"{target} has enough disk space for web content.".format(
								target=target_node.description()
							)
						)
					), u""
				)

	@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:
				domain_directory = server.get_vhost_dir(domain)
				if windows_utils.file_exists(runner, domain_directory):
					return int(
						runner.sh(
							u"cmd.exe /C cscript {script_path} //NoLogo {domain_directory}", dict(
								script_path=du_remote_script_path, domain_directory=domain_directory)
						).strip()
						)
				else:
					return 0

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


class WindowsSourceSessionDirDiskSpaceChecker(object):
	def check(self, source_node, mysql_databases, report):
		all_usage_source_mysql_db = [0]
		for db in mysql_databases:
			with source_node.runner() as runner:
				usage_source_mysql_db = _estimate_mysql_database_size_windows_on_source_plesk(
					source_node, db.src_access.host, db.src_access.port,
					db.src_access.login, db.src_access.password, db.db_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_node.session_dir())
		with source_node.runner() as runner:
			session_available = _get_windows_disk_free_space(runner, session_disk_letter)

		logger.debug(u"Required disk space for temporary files on %s, '%s:' disk: %s bytes", source_node.description(), session_disk_letter, max_usage)
		logger.debug(u"Available disk space on %s, '%s:' disk: %s bytes", source_node.description(), session_disk_letter, session_available)

		if session_available < max_usage:
			report.add_issue(
				Problem(
					'infrastructure-disk-space', Problem.ERROR, 
					(
						u"Insufficient disk space on {description}:\n".format(
							description=source_node.description()
						) +
						u"- Disk {disk_letter}: - expected to have more than {required} for migrated content, actually have {available}.".format(
							disk_letter=session_disk_letter, required=_format_bytes(max_usage), available=_format_bytes(session_available)
						) + 
						u"\nTo resolve the issue you could:\n"
						u"1) Free disk space on the disk.\n"
						u"2) Change 'session-dir' config.ini parameter for the server so it points to directory located on a disk that has enough space.\n"
						u"\n"
						u"Fix found issues and run the transfer-accounts command again.\n" 
					)
				), u""
			)
		else:
			report.add_issue(
				Problem(
					'infrastructure-disk-space', Problem.INFO, 
					(
						u"{source} has enough disk space for temporary files.".format(
							source=source_node.description().capitalize()
						)
					)
				), u""
			)


def check_database_server_connections(report, target_db_servers):
	logger.info(u"Check connections to target database servers")
	db_report = report.subtarget(u"Connections to target database servers", None)
	for server in target_db_servers:
		try:
			logger.debug("Check connection to target database server %s", server.description())
			server.check_connection()
		except DatabaseServerConnectionException as e:
			logger.debug("Exception: ", exc_info=True)
			db_report.add_issue(
				checking.Problem(
					'target_database_server_connection_issue',
					checking.Problem.ERROR, str(e),
				),
				u"1) Check that database server is configured and working.\n"
				u"2) Check that you can create database from target panel for that database server\n"
				u"3) Check that there are no firewall rules that may block connection to the server\n"
				u"4) Check that credentials specified in the target panel are correct for that database server"
			)
		except Exception as e:
			logger.debug("Exception: ", exc_info=True)
			db_report.add_issue(
				checking.Problem(
					'target_database_server_connection_internal_error',
					checking.Problem.WARNING,
					"Internal error: failed to check connection to target database server %s: %s" % (
						server.description(), str(e)
					),
				),
				u"The check was skipped."
			)
		else:
			db_report.add_issue(
				checking.Problem(
					'target_database_server_connection_ok',
					checking.Problem.INFO,
					"Successfully connected to %s" % server.description(),
				), ""
			)


def _get_available_disk_space(runner, filepath):
	"""Get available disk space in bytes on filesystem where file or directory 'filepath' is located"""
	return _get_device_available_bytes_from_df_output(
		runner.run('/bin/df', [
			'-P', # 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
			filepath
		])
	)


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(u"Expected to have more than 2 lines from 'df' utility, got %s. 'df' utility output: %s" % (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(u"Expected to have at least 4 item in filesystem line of 'df' utility. Parsed filesystem line items as: %r" % (fs_line_items,))

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


def _get_windows_disk_letter(path):
	disk_letter, _ = path.split(':')
	return disk_letter.upper() 


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

	output = runner.sh(u"fsutil volume diskfree {disk_letter}:", dict(disk_letter=disk_letter))
	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.
	"""

	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)

			# workaround for Plesk feature - it does not tell default MySQL server's admin password
			if host == 'localhost' and port == 3306:
				password_param = u" -p\"`cat /etc/psa/.psa.shadow`\""
			else:
				password_param = u" -p{password}"

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

			_estimate_mysql_database_size.cache[(host, port, db_name)] = int(
				runner.sh(command, args)
			)
		except Exception as err:
			# if there are connection problems - just consider database uses 0 bytes
			logger.debug(u"Exception:", exc_info=err)
			_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(script_path, runner, host, login, password, 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("Unable to parse string that should contain disk usage. String: %s" % (s,))

		# "-InputFormat none" is required for powershell not to hang
		# See http://stackoverflow.com/questions/4238192/running-powershell-from-msdeploy-runcommand-does-not-exit/
		# for more details
		cmd = "powershell -InputFormat none -ExecutionPolicy Unrestricted -File {script_path} -hostName {host} -database {database} -login {login} -pwd {password}"
		args = dict(
			script_path=script_path,
			host=host, database=db_name, login=login, password=password
		)

		usage_info = {}
		error_code, stdout, stderr = runner.sh_unchecked(cmd, args)
		for line in stdout.split("\n"):
			line = line.strip()
			if line != '':
				name, value = line.split(" ", 1)
				usage_info[name] = value

		if 'database_size' not in usage_info or 'db_file_location' not in usage_info:
			raise Exception(
				u"Unable to find 'database_size' or/and 'db_file_location' parameter(s) in MSSQL disk usage script's output.\nCommand: %s\nExit code: %s\nstdout: %s\nstderr: %s" % (
					windows_utils.format_command(cmd, **args), error_code, stdout, stderr)
			)

		return _get_windows_disk_letter(usage_info['db_file_location']), parse_disk_usage(usage_info['database_size'])
	except Exception as err:
		# for example, if database does not exist or there are connection problems - just consider database uses 0 bytes not to break all checks
		logger.debug(u"Exception:", exc_info=err)
		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 = ur"{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 as err:
		# for example, if database does not exist or there are connection
		# problems - just consider database uses 0 bytes not to break all checks
		logger.debug(u"Exception:", exc_info=err)
		logger.debug(
			"As exception occured - we just consider database uses 0 bytes not to break all checks. It is very likely that database simply does not exist which makes command to fail."
		)
		return 0


def _get_windows_mysql_datadir_on_target_node(runner, mysql_bin, host, port, login, password):
	query = u"SHOW VARIABLES WHERE Variable_name = 'datadir'"
	command = ur'cmd.exe /C "{mysql_bin} -h {host} -P {port} -u{login} -p{password} --silent --skip-column-names -e {query}"'
	args = dict(mysql_bin=mysql_bin, host=host, port=port, login=login, password=password, query=query)
	return runner.sh(command, args).strip().split()[1]


def _execute_windows_mysql_one_value_query(runner, mysql_bin, host, port, login, password, db_name, query):
	command = ur'cmd.exe /C "{mysql_bin} -h {host} -P {port} -u{login} -p{password} {db_name} --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)
	exit_code, stdout, stderr = runner.sh_unchecked(command, args)
	if exit_code != 0:
		raise Exception("MySQL command '%s' with args %r returned non-zero exit code.\nStdout:\n%s\nStderr:\n%s", command, args, safe_string_repr(stdout), safe_string_repr(stderr))
	return stdout


def _get_estimate_mysql_database_size_query(db_name):
	return u"SELECT SUM(data_length) FROM information_schema.TABLES WHERE information_schema.TABLES.TABLE_SCHEMA = '{db_name}'".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.common.checking.Report
	"""
	NodesPair = namedtuple('NodesPair', ('target', 'source_ip'))
	# form up the list of subscriptions which will be affected
	# for each pair of <source_ip, ppa_host_id>, 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.iteritems():
		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.debug("Exception, ", exc_info=True)

			report.add_issue(
				checking.Problem('infrastructure-web', checking.Problem.ERROR,
					u"Unable to check the rsync connection between %s and the source server at %s:\n%s\nWhile this problem persists, it may not be possible to copy the %s content of the following subscriptions:%s" % (
						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])
					)
				),
				u"""Make sure that:
1) There are no firewall rules blocking rsync connections from the PPA node to the source server:
the incoming connections on the TCP port 873 of the source server are allowed, the outgoing connections on the TCP port 873 of the PPA service node are allowed.
2) The rsync service is configured on the source server according to the Migration Guide.
3) The rsync service is started on the source server.
4) The following command can be successfully executed on the PPA service node:
{cmd}""".format(cmd=e.command))
			return

		report.add_issue(
			checking.Problem('infrastructure-web', checking.Problem.INFO,
				u"Successfully checked the rsync connection between %s and the source server at %s" % (
					nodes_info.target.description(),
					nodes_info.source_ip
				)
			),
			u""
		)
