from parallels.core import messages
import logging
import os
import sys
import datetime
import posixpath
from functools import wraps
from contextlib import contextmanager
from collections import namedtuple

from parallels.core.utils.common import cached, draw_tree, utf8_tree_symbols, ascii_tree_symbols
from parallels.core.utils.common.logging import BleepingLogger
from parallels.core.registry import Registry
from parallels.core.utils.common_constants import SSH_DEFAULT_PORT
from parallels.core.utils.windows_utils import check_windows_national_symbols_command
from parallels.core.utils.windows_utils import path_join as windows_path_join
from parallels.core import MigrationError, MigrationNoContextError
from parallels.core.utils import unix_utils, plesk_utils
from parallels.core.utils.config_utils import get_option
from parallels.core.logging_context import log_context
from parallels.core.utils.steps_profiler import get_default_steps_profiler

logger = logging.getLogger(__name__)


def copy_vhost_content_unix(
	source_ip, source_user, runner_source, runner_target,
	subscription_name, key_pathname, skip_if_source_dir_not_exists=False, exclude=None
):
	if exclude is None:
		exclude = []

	source_vhost_dir = plesk_utils.get_unix_vhost_dir(runner_source, subscription_name)
	if unix_utils.file_exists(runner_source, source_vhost_dir):
		target_vhost_dir = plesk_utils.get_unix_vhost_dir(runner_target, subscription_name)
		copy_directory_content_unix(
			source_ip, source_user, runner_source, runner_target,
			source_vhost_dir, target_vhost_dir, key_pathname, exclude
		)
	else:
		if skip_if_source_dir_not_exists:
			# source virtual host directory does not exists, so there is nothing to copy 
			return
		else:
			raise MigrationError(
				messages.ERROR_WHILE_COPYING_VIRTUAL_HOSTS_CONTENT % (
					source_vhost_dir, source_ip
				)
			)


def copy_vhost_system_content_unix(
	source_ip, source_user, runner_source, runner_target, subscription_name, key_pathname
):
	source_vhost_dir = plesk_utils.get_unix_vhost_system_dir(runner_source, subscription_name)
	target_vhost_dir = plesk_utils.get_unix_vhost_system_dir(runner_target, subscription_name)

	copy_directory_content_unix(
		source_ip, source_user, runner_source, runner_target, source_vhost_dir, target_vhost_dir, key_pathname
	)


def copy_directory_content_unix(
	source_ip, source_user, runner_source, runner_target, source_path, target_path, 
	key_pathname, exclude=None, skip_if_source_dir_not_exists=False,
	rsync_additional_args=None,
	user_mapping=None,
	group_mapping=None,
	source_rsync_bin=None, source_port=SSH_DEFAULT_PORT
):
	"""
	:type user_mapping: dict[basestring, basestring]
	:type group_mapping: dict[basestring, basestring]
	"""
	if exclude is None:
		exclude = []
	if user_mapping is None:
		user_mapping = {}
	if group_mapping is None:
		group_mapping = {}

	if unix_utils.file_exists(runner_source, source_path):
		_copy_content_unix(
			source_ip, source_user, runner_source, runner_target, source_path, target_path, 
			key_pathname, content_type='d', exclude=exclude, 
			rsync_additional_args=rsync_additional_args,
			user_mapping=user_mapping,
			group_mapping=group_mapping,
			source_rsync_bin=source_rsync_bin,
			source_port=source_port
		)
	else:
		if skip_if_source_dir_not_exists:
			# source directory does not exists, so there is nothing to copy 
			return
		else:
			raise MigrationError(
				messages.ERROR_WHILE_COPYING_CONTENT_DIRECTORY_S % (
					source_path, source_ip
				)
			)

DEFAULT_RSYNC_PATH = '/usr/bin/rsync'


def detect_rsync_path(runner, node_info_string):
	if unix_utils.file_exists(runner, DEFAULT_RSYNC_PATH):
		return DEFAULT_RSYNC_PATH
	else:
		hsphere_rsync_path = '/hsphere/shared/bin/rsync'
		if unix_utils.file_exists(runner, hsphere_rsync_path):
			return hsphere_rsync_path
		else:
			raise MigrationError(
				messages.UNABLE_TO_FIND_RSYNC_BINARY % (
					node_info_string, DEFAULT_RSYNC_PATH, hsphere_rsync_path
				)
			)


def create_rsync_command(
	key_pathname, source_runner, source_user, source_ip, 
	source_filepath, target_filepath, exclude=None,
	rsync_additional_args=None,
	source_rsync_bin=None,
	source_port=SSH_DEFAULT_PORT
):
	if exclude is None:
		exclude = []

	cmd = '/usr/bin/rsync'

	if rsync_additional_args is None:
		rsync_additional_args = []

	args = [ 
		"-e",
		"ssh -i {key} -p {port} -o StrictHostKeyChecking=no -o GSSAPIAuthentication=no".format(
			key=key_pathname,
			port=source_port
		),
		"--archive"
	] + rsync_additional_args + [
		u"{source_user}@{source_ip}:{source_filepath}".format(
			source_user=source_user,
			source_ip=source_ip,
			source_filepath=source_filepath
		),
		u"%s" % (target_filepath,),
	] + [u"--exclude=%s" % ex for ex in exclude]

	if source_rsync_bin is None:
		# detect rsync binary location on the source node
		source_rsync_bin = detect_rsync_path(
			source_runner, source_ip
		)
		if source_rsync_bin != DEFAULT_RSYNC_PATH: # non-default path
			args = ["--rsync-path=%s" % (source_rsync_bin,)] + args

	return cmd, args


def _copy_content_unix(
	source_ip, source_user, runner_source, runner_target, 
	source_path, target_path, key_pathname, content_type='d',
	exclude=None, rsync_additional_args=None,
	user_mapping=None, group_mapping=None,
	source_rsync_bin=None, source_port=SSH_DEFAULT_PORT
):
	"""
	:type user_mapping: dict[basestring, basestring]
	:type group_mapping: dict[basestring, basestring]
	"""
	if exclude is None:
		exclude = []
	if user_mapping is None:
		user_mapping = {}
	if group_mapping is None:
		group_mapping = {}

	if content_type == 'd':
		source_path += '/'
		target_path += '/'

	cmd, args = create_rsync_command(
		key_pathname, runner_source, source_user, 
		source_ip, source_path, target_path, exclude,
		rsync_additional_args,
		source_rsync_bin, source_port=source_port
	)
	runner_target.run(cmd, args)

	for source_user, target_user in user_mapping.iteritems():
		logger.debug(messages.CHANGE_FILE_PERMISSIONS_ACCORDING_USER_MAP)
		unix_utils.map_copy_owner_user(
			runner_source, runner_target, source_path, target_path, source_user, target_user
		)
	for source_group, target_group in group_mapping.iteritems():
		logger.debug(messages.CHANGE_FILE_PERMISSIONS_ACCORDING_GROUP_MAP)
		unix_utils.map_copy_owner_group(
			runner_source, runner_target, source_path, target_path, source_group, target_group
		)


def vhost_dir_exists_unix(runner, vhost_name):
	vhost_dir = plesk_utils.get_unix_vhost_dir(runner, vhost_name)
	exit_code, _, _ = runner.run_unchecked("/usr/bin/test",  ["-d", vhost_dir])
	return exit_code == 0


def vhost_system_dir_exists_unix(runner, vhost_name):
	vhost_system_dir = plesk_utils.get_unix_vhost_system_dir(runner, vhost_name)
	exit_code, _, _ = runner.run_unchecked("/usr/bin/test",  ["-d", vhost_system_dir])
	return exit_code == 0


def trace(op_name, message):
	def decorator(method):
		@wraps(method)
		def wrapper(self, *args, **kw):
			with trace_step(op_name, message, profile=True):
				return method(self, *args, **kw)
		return wrapper
	return decorator


@contextmanager
def trace_step(op_name, message, profile=False, log_level='info', compound=True):
	if log_level == 'info':
		log_function = logger.info
	elif log_level == 'debug':
		log_function = logger.debug
	else:
		raise Exception("Invalid log level '%s'" % log_level)

	if compound:
		# put an empty line before the block
		log_function(u"")
		log_function(messages.TRACE_START, message)
	else:
		log_function(message)
	try:
		with log_context(op_name):
			if profile:
				with get_default_steps_profiler().measure_time(
					op_name, message
				):
					yield
			else:
				yield
		if compound:
			log_function(messages.TRACE_FINISH, message)
	except MigrationNoContextError:
		if compound:
			log_function(messages.TRACE_FINISH, message)
		raise
	except:
		logger.error(messages.TRACE_ABORT, message)
		raise


class DbServerInfo(namedtuple('DbServerInfo', ('dbtype', 'host', 'port', 'login', 'password'))):
	@staticmethod
	def from_plesk_api(db_server_info):
		"""Convert from plesk_api.DbServerInfo to DbServerInfo"""
		return DbServerInfo(
			dbtype=db_server_info.dbtype,
			host=db_server_info.host,
			port=db_server_info.port,
			login=db_server_info.admin,
			password=db_server_info.password
		)

	@staticmethod
	def from_plesk_backup(db_server_info):
		"""Convert plesks_migrator.data_model.DatabaseServer to DbServerInfo"""
		return DbServerInfo(
			dbtype=db_server_info.dbtype,
			host=db_server_info.host,
			port=db_server_info.port,
			login=db_server_info.login,
			password=db_server_info.password
		)


@contextmanager
def plain_passwords_enabled(plesk_runner):
	def run_not_critical_command(cmd, description):
		exit_code, stdout, stderr = plesk_runner.sh_unchecked(cmd)
		if exit_code != 0:
			logger.error(messages.FAILED_TO_SET_PLAIN_PASSWORDS, description, cmd, stdout, stderr)

	get_value_cmd = u"/usr/local/psa/bin/settings -g allow_api_show_plain_passwords"
	exit_code, plain_passwords_enabled, stderr = plesk_runner.sh_unchecked(get_value_cmd)
	plain_passwords_enabled = plain_passwords_enabled.strip()

	if exit_code != 0:
		logger.error(
			messages.FAILED_TO_DETERMINE_PLESK_API_SHOW_PASSWORD,
			get_value_cmd, plain_passwords_enabled, stderr
		)
		plain_passwords_enabled = 'false'

	if plain_passwords_enabled != 'true':
		run_not_critical_command(
			"/usr/local/psa/bin/settings -s allow_api_show_plain_passwords=true",
			messages.ALLOW_PLESK_API_SHOW_PLAIN_PASSWORDS)
	try:
		yield
	finally:
		if plain_passwords_enabled != 'true':
			run_not_critical_command(
				"/usr/local/psa/bin/settings -d allow_api_show_plain_passwords",
				messages.DISABLE_PLESK_API_SHOWING_PLAIN_PASSWORDS)


def format_report(report, minor_issues=None, report_file_path=None, show_no_issue_branches=True):
	"""Print issues report to stdout as tree with the use of pseudographics.
	show_no_issue_branches - whether to show the branches without issues
	minor_issues - do not show the no-issue nodes of the specified types, even if show_no_issue_branches is True
	"""
	if minor_issues is None:
		minor_issues = set([])

	def format_issue(issue):
		formatted_issue = u"%s: %s" % (issue.problem.severity, issue.problem.description)
		if issue.solution is not None:
			formatted_issue += u"\n%s" % issue.solution
		return formatted_issue

	def report_to_tree(report):
		def keep_report(report):
			if not show_no_issue_branches and not report.has_issues():
				return False
			if report.type in minor_issues and len(report.children) == 0 and len(report.issues) == 0:
				return False
			return True

		return (
			u"%s '%s'" % (report.type, report.name) if report.name is not None else report.type,
			[
				item for item in
				[(format_issue(i), []) for i in report.issues] +
				[report_to_tree(c) for c in report.children.values() if keep_report(c)]
			]
		)
	tree = report_to_tree(report)

	if sys.stdout.encoding == 'UTF-8':
		tree_symbols = utf8_tree_symbols
	else:
		tree_symbols = ascii_tree_symbols
	tree_report = draw_tree(tree, spacing=1, symbols=tree_symbols)
	return tree_report


def save_report(tree_report, report_file_path):
	report_file_path = u'%s%s' % (report_file_path, datetime.datetime.now().strftime(".%Y.%m.%d.%H.%M.%S"))
	with open(report_file_path, 'w') as f:
		f.write(tree_report.encode('utf-8'))
	return report_file_path


def _make_winexe_command(access_data, remote_command):
	check_windows_national_symbols_command(remote_command)
	return [
		u"winexe", 
		u"--debug-stderr",
		u"--reinstall",
		u"-U", "%s%%%s" % (access_data.windows_auth.username, access_data.windows_auth.password),
		u"//%s" % (access_data.ip,),
		remote_command
	]


def clean_up_vhost_dir(subscription_name, runner, is_windows):
	"""Remove all garbage in subscription's virtual host directory. This
	garbage is created because of default virtual host skeleton. This could
	cause problems.  For example, skeleton contains httpdocs/index.html page.
	We run rsync without --delete option during content migration. If there is
	no index.html on source node, index.html from skeleton is left as is.  So,
	if there was index.php page on source node, which usually have less
	priority than index.html, then main page of a site is replaced with
	index.html template (if you type http://example.com/ after migration, you
	will get http://example.com/index.html, but not
	http://example.com/index.php as before migration). So we should remove the
	garbage. Better way - ask Plesk to implement ability to create site with
	"empty" skeleton.
	"""

	if is_windows:
		_clean_up_windows_vhost_dir(subscription_name, runner)
	else:
		_clean_up_unix_vhost_dir(subscription_name, runner)


def _clean_up_unix_vhost_dir(subscription_name, runner):
	vhost_dir = plesk_utils.get_unix_vhost_dir(runner, subscription_name)

	full_dir_to_clean_path = posixpath.join(vhost_dir, 'httpdocs') 
	runner.run("/usr/bin/find", [
		full_dir_to_clean_path, 
		# not to include the directory itself, as find puts '.' if mindepth is
		# not defined
		"-mindepth", "1",
		# so find won't fail with '/usr/bin/find:
		# /var/www/vhosts/a.tld/httpdocs/b/c.html: No such file or directory'
		# when it removes directory and only then tries to remove files inside
		# the directory 
		"-maxdepth", "1", 
		"-exec", "rm", "-rf", "{}", "+"]
	)


def _clean_up_windows_vhost_dir(subscription_name, runner):
	vhost_dir = plesk_utils.get_windows_vhost_dir(runner, subscription_name)
	runner.clean_directory(windows_path_join(vhost_dir, 'httpdocs'))


def get_package_root_path(package):
	"""Get path to Python package root directory"""
	dirs = [p for p in package.__path__]
	assert all(d == dirs[0] for d in dirs)
	return dirs[0]


def get_package_extras_file_path(package, filename):
	"""Get path to extras file of Python package"""
	return os.path.join(
		get_package_root_path(package), 'extras', filename
	)


def get_package_scripts_file_path(package, filename):
	"""Get path to scripts file of Python package"""
	return os.path.join(
		get_package_root_path(package), 'scripts', filename
	)


def version_to_tuple(version):
	return tuple(map(int, (version.split("."))))


def split_unix_path(path):
	"""Split path by directory name and file name

	Returns tuple (directory name, file name)

	:rtype tuple
	"""
	last_slash_pos = path.rfind('/')
	directory = path[:last_slash_pos]
	filename = path[last_slash_pos+1:]
	return directory, filename


def normalize_domain_name(domain_name):
	"""Normalize domain name - convert to lowercase punycode

	:rtype basestring
	"""
	return domain_name.encode('idna').lower()


@contextmanager
def upload_temporary_file_content(runner, filename, content):
	"""Upload contents to temporary file on remote server"""
	runner.upload_file_content(filename, content)
	yield
	runner.remove_file(filename)


def get_customer_name_in_report(customer):
	name = customer.login
	if customer.personal_info is not None:
		details = ', '.join(
			x for x in [
				customer.personal_info.first_name,
				customer.personal_info.last_name,
				customer.personal_info.email
			] if x is not None and x != ''
		)
		if details != "":
			name += " (%s)" % details

	return name


def get_bleeping_logger(logger):
	"""Create and return enhanced logger object."""
	default_context_length = 10
	log_context_length = get_option(
		'GLOBAL', 'log-message-context-length', default_context_length)
	return BleepingLogger(logger, context_length=log_context_length)


@cached
def get_version():
	version_file_name = os.path.join(Registry.get_instance().get_base_dir(), 'version')
	if not os.path.exists(version_file_name):
		return 'DEVELOPMENT'
	else:
		return read_version_from_file(version_file_name)


def read_version_from_file(version_file_name):
	with open(version_file_name, 'r') as version_file:
		return version_file.read().strip()