from parallels.plesks_migrator import messages
import logging
from contextlib import closing
import itertools
import os
from parallels.common.target_panels import TargetPanels

from parallels.utils import cached
from parallels.utils import obj
from parallels.utils import group_by_id

from parallels.plesk_api import operator as plesk_ops
from parallels.plesk_api.core import PleskError
from parallels.common.utils import migrator_utils
from parallels.common.utils import yaml_utils
from parallels.common import MigrationError

import parallels.plesks_migrator
from parallels.plesks_migrator.server import PleskSourceServer
from parallels.common.migrator import Migrator as CommonMigrator
from parallels.common.migrator import trace
from parallels.common.logging_context import log_context
from parallels.common import version_tuple
from parallels.common.utils import plesk_api_utils
from parallels.common.checking import Problem, PlainReport

from . import dns_timing
from parallels.plesks_migrator.content.mail import PleskCopyMailContent
from parallels.plesk_api.operator.dns9 import DnsOperator9
from parallels.plesk_api.operator.domain_alias import DomainAliasOperator
from . import dns_forwarding
from dns_forwarding import DnsServiceType
from parallels.plesks_migrator import connections

from parallels.plesks_migrator.session_files import PleskSessionFiles
from parallels.plesks_migrator.workflow import FromPleskWorkflow

logger = logging.getLogger(__name__)


class Migrator(CommonMigrator):

	def _load_connections_configuration(self):
		conn = connections.PleskMigratorConnections(
			self.config, self._get_target_panel(), self._get_migrator_server()
		)
		self.external_db_servers = conn.get_external_db_servers()
		return conn

	def _create_workflow(self):
		return FromPleskWorkflow()

	def _create_session_files(self):
		return PleskSessionFiles(self.conn, self._get_migrator_server())

	# ======================== CLI micro commands =============================

	def transfer_wpb_sites(self, options, finalize=True):
		self.action_runner.run(
			self.workflow.get_shared_action('sync-web-content-assets').get_path('transfer-wpb-sites')
		)

	def transfer_vdirs(self, options, finalize=True):
		self.action_runner.run(
			self.workflow.get_path(
				'transfer-accounts/copy-content/web/transfer-virtual-directories'
				)
		)
		self.action_runner.run(
			self.workflow.get_path(
				'transfer-accounts/copy-content/web/transfer-mime-types'
			)
		)
		self.action_runner.run(
			self.workflow.get_path(
				'transfer-accounts/copy-content/web/transfer-error-documents'
			)
		)

	# ======================== DNS forwarding =================================

	def _forward_dns(self, root_report):
		for server_id in self.conn.get_dns_servers():
			logger.info(messages.LOG_SET_UP_DNS_FORWARDING_ON_PLESK_SERVER, server_id)

			err_zones = {}
			domains_info = self._get_hosted_domains_info(server_id)
			# domains_masters and subdomains_masters should be the two separate maps,
			# as domains and subdomains w/o hosting are handled differently
			domains_masters = {}
			subdomains_masters = {}
			for domain_info in domains_info:
				try:
					target_dns_ip = self._get_subscription_first_dns_ip(
						domain_info.subscription_name, domain_info.domain_name
					)
				except Exception as e:
					errmsg = messages.COULD_NOT_GET_TARGET_DNS_SERVER % (
						domain_info.domain_name, e
					)
					logger.error(errmsg)
					err_zones[domain_info.domain_name] = errmsg
					continue

				if (
					domain_info.domain_type == 'subdomain' and
					(domain_info.dns_zone is None or not domain_info.dns_zone.enabled) and
					self.target_panel != TargetPanels.PPA
				):
					# do not set DNS forwarding for subdomains hosted in main domain's zone when migrating to Plesk
					continue

				if domain_info.domain_type == 'subdomain' and domain_info.dns_zone is None:
					subdomains_masters[domain_info.domain_name] = target_dns_ip
				else:
					domains_masters[domain_info.domain_name] = target_dns_ip

			err_zones.update(
				self._switch_domains_dns_to_slave(domains_info, domains_masters, server_id)
			)
			if self.target_panel == TargetPanels.PPA:
				# In PPA each subdomain is transfered to a separate zone,
				# while on source panel its records could be served by domain's zone.
				# In such situation it is required to explicitly add subdomain slave zones
				# to the source panel's DNS.
				err_zones.update(
					self._add_slave_subdomain_zones(subdomains_masters, server_id)
				)
			if len(err_zones) > 0:
				self._add_forwarding_issues(
					root_report, server_id, err_zones, domains_info,
					messages.FAILED_DNS_FORWARDING_SET_UP_MANUAL_SOLUTION
				)

	def _undo_dns_forwarding(self, root_report):
		for server_id, server_settings in self.conn.get_dns_servers().iteritems():
			logger.info(messages.LOG_UNDO_DNS_FORWARDING, server_id)

			err_zones = {}
			domains_info = self._get_hosted_domains_info(server_id)

			domains_masters = {}
			subdomains = []
			for domain_info in domains_info:
				try:
					target_dns_ip = self._get_subscription_first_dns_ip(
						domain_info.subscription_name, domain_info.domain_name
					)
				except Exception as e:
					errmsg = messages.COULD_NOT_GET_TARGET_DNS_SERVER % (
						domain_info.domain_name, e
					)
					logger.error(errmsg)
					err_zones[domain_info.domain_name] = errmsg
					continue

				if (
					domain_info.domain_type == 'subdomain' and
					(domain_info.dns_zone is None or not domain_info.dns_zone.enabled) and
					self.target_panel != TargetPanels.PPA
				):
					# do not set DNS forwarding for subdomains hosted in main domain's zone when migrating to Plesk
					continue

				if domain_info.domain_type == 'subdomain' and domain_info.dns_zone is None:
					subdomains.append(domain_info.domain_name)
				else:
					domains_masters[domain_info.domain_name] = target_dns_ip
			err_zones.update(
				self._switch_domains_dns_to_master(domains_info, domains_masters, server_id)
			)
			err_zones.update(
				self._remove_slave_subdomain_zones(subdomains, server_id)
			)
			if len(err_zones) > 0:
				backup_filename = self.get_path_to_raw_plesk_backup_by_settings(server_settings)
				self._add_forwarding_issues(
					root_report, server_id, err_zones, domains_info,
					messages.FAILED_DNS_FORWARDING_UNDO_MANUAL_SOLUTION % backup_filename
				)

	def _get_hosted_domains_info(self, plesk_id):
		domains_info = set()

		def add_domain_info(domain, domain_type, subscription_name):
			if domain.dns_zone is not None and domain.dns_zone.zone_type == 'slave':
				# changing master servers for a zone in slave mode is generally incorrect -
				# customer may be hosting sites, or some additional services, outside of source Plesk
				# and they would become broken if we forward DNS queries for such zone to PPA
				logger.debug(messages.LOG_SKIP_SETTING_UP_DNS_FORWARDING_FOR_SLAVE, domain.name)
			elif any([
				# we don't set forwarding for regular zones with disabled DNS,
				# but we do so for subdomains' zones, because after transfer to PPA
				# they become separate and enabled
				domain_type == 'subdomain',
				domain.dns_zone is not None and domain.dns_zone.enabled,
			]):
				domains_info.update([
					obj(
						domain_name=domain.name,
						domain_type=domain_type,
						dns_zone=domain.dns_zone,
						subscription_name=subscription_name,
						plesk_id=plesk_id
					)
				])

		with closing(self.load_raw_plesk_backup(self.source_plesks[plesk_id])) as backup:
			for subscription in backup.iter_all_subscriptions():
				# subscription, domains and subdomains
				for domain in itertools.chain([subscription], backup.iter_sites(subscription.name)):
					add_domain_info(
						domain,
						'subdomain' if getattr(domain, 'parent_domain_name', None) is not None else 'domain',
						subscription.name
					)
				# domain aliases
				for domain_alias in backup.iter_aliases(subscription.name):
					add_domain_info(domain_alias, 'domain_alias', subscription.name)

		return domains_info

	def _switch_domains_dns_to_slave(self, domains_info, domains_masters, plesk_id):
		jobs = {}
		for domain_info in domains_info:
			if domain_info.domain_name not in domains_masters:
				continue
			# in average, it should result in 2 CLI commands per zone
			jobs[domain_info.domain_name] = obj(
				domain_info=domain_info,
				masters_to_remove=[rec.dst for rec in domain_info.dns_zone.iter_dns_records() if rec.rec_type == 'master'],
				masters_to_add=[domains_masters[domain_info.domain_name]],
				type_to_set='slave',
				state_to_set=None if domain_info.dns_zone.enabled else 'on',
			)
		return self._change_zones(jobs, plesk_id)

	def _switch_domains_dns_to_master(self, domains_info, domains_masters, plesk_id):
		jobs = {}
		for domain_info in domains_info:
			if domain_info.domain_name not in domains_masters:
				continue
			jobs[domain_info.domain_name] = obj(
				domain_info=domain_info,
				masters_to_remove=[domains_masters[domain_info.domain_name]],
				masters_to_add=[rec.dst for rec in domain_info.dns_zone.iter_dns_records() if rec.rec_type == 'master'],
				type_to_set='master',
				state_to_set=None if domain_info.dns_zone.enabled else 'off',
			)
		return self._change_zones(jobs, plesk_id)

	def _add_forwarding_issues(self, root_report, plesk_id, err_zones, domains_info, solution):
		plain_report = PlainReport(root_report, *self._extract_source_objects_info())
		domains_by_name = group_by_id(domains_info, lambda di: di.domain_name)
		for zone, errmsg in err_zones.iteritems():
			subscription_name = domains_by_name[zone].subscription_name
			plain_report.add_subscription_issue(
				plesk_id, subscription_name,
				Problem('dns_forwarding_issue', Problem.ERROR, errmsg),
				solution
			)

	def _get_dns_type(self, plesk_id, runner):
		if self.source_plesks[plesk_id].is_windows:
			get_windows_dns_type_bat = 'get_windows_dns_type.bat'
			script_filename_local = migrator_utils.get_package_scripts_file_path(
				parallels.plesks_migrator, get_windows_dns_type_bat
			)
			source_server = self._get_source_node(plesk_id)
			script_filename_source = source_server.get_session_file_path(get_windows_dns_type_bat)
			runner.upload_file(script_filename_local, script_filename_source)

			stdout = runner.sh(ur'cmd.exe /C "{script_filename_source}"', dict(script_filename_source=script_filename_source))
			windows_dns_type = stdout.strip().split("\n")[0]
			if windows_dns_type == 'bind':
				return DnsServiceType.BIND_WINDOWS
			else:
				return windows_dns_type  # msdns, or some unsupported type
		else:
			return DnsServiceType.BIND_LINUX

	def _add_slave_subdomain_zones(self, subdomains_masters, plesk_id):
		if len(subdomains_masters) == 0:
			return {}  # no subdomains - nothing to process and no errors

		source_node = self._get_source_node(plesk_id)
		with source_node.runner() as runner:
			dns_type = self._get_dns_type(plesk_id, runner)
			if dns_type not in [DnsServiceType.BIND_LINUX, DnsServiceType.BIND_WINDOWS, DnsServiceType.MSDNS]:
				logger.error(messages.LOG_UNABLE_ADD_SUBDOMAIN_ZONES_UNKNOWN_DNS_SERVICE, dns_type)
				return {
					zone: (
						messages.UNABLE_ADD_SUBDOMAIN_ZONES_UNKNOWN_DNS_SERVICE % (
							zone, plesk_id, dns_type
						)
					)
					for zone in subdomains_masters
				}

			impl = dns_forwarding.getPhysicalDnsForwardingImpl(
				dns_type, logger, self._get_migrator_server(), plesk_id, source_node, runner
			)
			return impl.add_slave_subdomain_zones(subdomains_masters)

	def _remove_slave_subdomain_zones(self, subdomains, plesk_id):
		if len(subdomains) == 0:
			return {}

		source_node = self._get_source_node(plesk_id)
		with source_node.runner() as runner:
			dns_type = self._get_dns_type(plesk_id, runner)
			if dns_type not in [DnsServiceType.BIND_LINUX, DnsServiceType.BIND_WINDOWS, DnsServiceType.MSDNS]:
				logger.error(messages.LOG_UNABLE_REMOVE_SUBDOMAIN_ZONES_UNKNOWN_DNS_SERVICE, dns_type)
				return {
					zone: (
						messages.UNABLE_REMOVE_SUBDOMAIN_ZONES_UNKNOWN_DNS_SERVICE % (
							zone, plesk_id, dns_type
						)
					)
					for zone in subdomains
				}

			impl = dns_forwarding.getPhysicalDnsForwardingImpl(
				dns_type, logger, self._get_migrator_server(), plesk_id, source_node, runner
			)
			return impl.remove_slave_subdomain_zones(subdomains)

	def _change_zones(self, jobs, plesk_id):
		"""Dispatch the request to change DNS zones:
		Plesk 8 and 9 for Windows can't work through CLI, so they work through API
		All other Plesk versions work through CLI.
		Plesks using CLI:
		Plesk for Windows shall receive domain name idn-encoded
		Plesk for Unix can receive domain name not encoded
		(and at least old PfU versions don't recognize it idn-encoded).
		"""
		source_server = self.conn.get_source_node(plesk_id)
		return self._change_zones_api(jobs, plesk_id, source_server.plesk_api())

	def _change_zones_api(self, jobs, plesk_id, plesk_api_client):
		"""Mass zone manipulations common for DNS "forwarding" setup and undo - using API
		"""
		err_zones = {}
		logger.debug(messages.LOG_CHANGE_DNS_ZONE_ON_PLESK, len(jobs), plesk_id)

		for zone, job in jobs.iteritems():
			try:
				domain_id = self._find_domain_id(zone, job.domain_info.domain_type, plesk_api_client)
			except PleskError as e:
				logger.debug(messages.LOG_EXCEPTION, exc_info=e)
				errmsg = (
					messages.COULD_NOT_GET_DOMAIN_IDENTIFIER_FROM_PLESK_API % (zone, e)
				)
				err_zones[zone] = errmsg
				continue
			if domain_id is None:
				errmsg = messages.COULD_NOT_GET_DOMAIN_IDENTIFIER % zone
				err_zones[zone] = errmsg
				continue

			err_zones.update(
				self._change_zone_api(zone, job, domain_id, plesk_api_client)
			)

		return err_zones

	def _find_domain_id(self, domain_name, domain_type, plesk_api_client):
		if domain_type == 'domain':
			if version_tuple(plesk_api_client.api_version) < (1, 6, 0, 0):  # Plesk 8, filter named: domain_name
				filter = plesk_ops.DomainOperator.FilterByName8([domain_name])
			elif version_tuple(plesk_api_client.api_version) < (1, 6, 3, 0):  # Plesk 9, filter named: domain-name
				filter = plesk_ops.DomainOperator.FilterByDomainName([domain_name])
			else:
				filter = plesk_ops.SiteOperator.FilterByName([domain_name])

			if version_tuple(plesk_api_client.api_version) < (1, 6, 3, 0):  # Plesk 8 and 9
				operator = plesk_ops.DomainOperator
				dataset = [plesk_ops.DomainOperator.Dataset.GEN_INFO]
			else:
				operator = plesk_ops.SiteOperator
				dataset = [plesk_ops.SiteOperator.Dataset.GEN_INFO]

			domain_ids = {
				result.data[1].gen_info.name: result.data[0] for result in plesk_api_client.send(
					operator.Get(
						filter=filter,
						dataset=dataset
					)
				)
			}
		elif domain_type == 'subdomain':
			# we use domain_name.lower() as domain name in subdomain's filter due to Plesks less than 11.5 
			# distinguish only lower case for IDN domains
			filter = plesk_ops.SubdomainOperator.FilterByName([domain_name.lower()])
			domain_ids = {
				result.data[1].name: result.data[0] for result in plesk_api_client.send(
					plesk_ops.SubdomainOperator.Get(
						filter=filter,
					)
				)
			}
		else:	# domain_type == 'domain_alias':
			if version_tuple(plesk_api_client.api_version) < (1, 6 ,3, 0):	# Plesk 8 and 9
				operator = DomainAliasOperator
				filter = DomainAliasOperator.FilterByName([domain_name])
			else:
				operator = plesk_ops.AliasOperator
				filter = plesk_ops.AliasOperator.FilterByName([domain_name])

			domain_ids = {
				result.data[1].name: result.data[0] for result in plesk_api_client.send(
					operator.Get(
						filter=filter
					)
				)
			}
			
		if domain_name in domain_ids:
			return domain_ids[domain_name]
		elif domain_type == 'domain_alias':
			# domain aliases in Plesk 8 for Windows backup are already idn-encoded.
			# IDN encoding loses original characters case, so can't just decode from idn and compare.
			# Now if we didn't find a domain by raw name, and it's a domain alias,
			# let's also search by idn-encoded name
			idn_domain_ids = { d.encode('idna'): data for d, data in domain_ids.iteritems() }
			if domain_name in idn_domain_ids:
				return idn_domain_ids[domain_name]
		else:
			return None

	def _change_zone_api(self, zone, job, domain_id, plesk_api_client):
		logger.debug(u"Change zone '%s'", zone)
		old_api = version_tuple(plesk_api_client.api_version) < (1, 6, 3, 0)  # in Plesk 10, there was change from domain to site
		# prepare filters
		if job.domain_info.domain_type in ['domain', 'subdomain']:
			domain_id_filter = DnsOperator9.FilterByDomainId(
				[domain_id]) if old_api else plesk_ops.DnsOperator.FilterBySiteId([domain_id]
			)
			target_id = DnsOperator9.AddMasterServer.TargetDomainId(id=domain_id) if old_api else plesk_ops.DnsOperator.AddMasterServer.TargetSiteId(id=domain_id)
		else: 	# job.domain_info.domain_type == 'domain_alias':
			domain_id_filter = DnsOperator9.FilterByDomainAliasId([domain_id]) if old_api else plesk_ops.DnsOperator.FilterBySiteAliasId([domain_id])
			target_id = DnsOperator9.AddMasterServer.TargetDomainAliasId(id=domain_id) if old_api else plesk_ops.DnsOperator.AddMasterServer.TargetSiteAliasId(id=domain_id)

		operator = DnsOperator9 if old_api else plesk_ops.DnsOperator
		err_zones = {}

		# always enable the zone - as zone management operations require it to be enabled
		enable_result = plesk_api_client.send(
			operator.Enable(domain_id_filter)
		)
		if not enable_result[0].ok:
			errmsg = (
				messages.COULD_NOT_ENABLE_DNS_ZONE_OF_DOMAIN % (
					zone, enable_result[0].code, enable_result[0].message
				)
			)
			err_zones[zone] = errmsg

		# if there are records to remove or add, switch zone into slave mode
		if job.type_to_set == 'slave' or len(job.masters_to_add) > 0 or len(job.masters_to_remove) > 0:
			mode_change_result = plesk_api_client.send(
				operator.Switch(domain_id_filter, 'slave')
			)
			if not mode_change_result[0].ok:
				errmsg = (
					messages.COULD_NOT_SWITCH_TO_SLAVE_DNS_SERVICE % (
						zone, mode_change_result[0].code, mode_change_result[0].message
					)
				)
				err_zones[zone] = errmsg

		zone_master_records = {
			result.data.ip_address: result.data.id
			for result in plesk_api_client.send(
				operator.GetMasterServer(filter=domain_id_filter)
			)
		}
		for master in job.masters_to_remove:
			if master in zone_master_records:
				record_id = zone_master_records[master]
				rec_id_filter = operator.FilterById([record_id])
				result = plesk_api_client.send(
					operator.DelMasterServer(filter=rec_id_filter)
				)
				if result[0].ok:
					del zone_master_records[master]
				else:
					errmsg = (
						messages.COULD_NOT_UNASSIGN_DNS_SERVER % (
							zone, result[0].code, result[0].message
						)
					)
					err_zones[zone] = errmsg
		for master in job.masters_to_add:
			if master not in zone_master_records:
				record_id = (
					plesk_api_client.send(
						operator.AddMasterServer(target=target_id, ip_address=master)
					)
				)[0]
				zone_master_records[master] = record_id

		if job.type_to_set == 'master':
			mode_change_result = plesk_api_client.send(
				operator.Switch(domain_id_filter, 'master')
			)
			if not mode_change_result[0].ok:
				errmsg = (
					messages.COULD_NOT_SWITCH_DNS_TO_MASTER % (
						zone, mode_change_result[0].code, mode_change_result[0].message
					)
				)
				err_zones[zone] = errmsg

		if job.state_to_set == 'off':
			disable_result = plesk_api_client.send(
				operator.Disable(domain_id_filter)
			)
			if not disable_result[0].ok:
				errmsg = (
					messages.COULD_NOT_DISABLE_DNS_ZONE % (
						zone, disable_result[0].code, disable_result[0].message
					)
				)
				err_zones[zone] = errmsg

		return err_zones

	# ======================== Set low DNS timings =================================

	def set_low_dns_timings(self, options):
		"""Set low DNS SOA records timing values and store old timing values to a file,
		so DNS switching won't cause big downtime
		"""

		self._check_connections(options)
		dns_timings_file = self.global_context.session_files.get_dns_timings_file()
		if os.path.exists(dns_timings_file):
			raise MigrationError(
				messages.FILE_WITH_TIMINGS_ALREADY_EXISTS % dns_timings_file
			)

		self.action_runner.run(
			self.workflow.get_shared_action('fetch-source'), 'fetch-source'
		)
		self._set_low_dns_timings_step()

	@trace(u'set-low-dns-timings', u'set low DNS timings')
	def _set_low_dns_timings_step(self):
		new_ttl = self.config.getint('GLOBAL', 'zones-ttl')

		logger.info(u"Get old DNS timing values")
		old_timings = self._get_dns_timings()

		dns_timings_file = self.global_context.session_files.get_dns_timings_file()
		logger.info(messages.LOG_STORE_OLD_DNS_TIMINGS_TO, dns_timings_file)
		yaml_utils.write_yaml(dns_timings_file, old_timings)

		logger.info(u"Set low DNS timing values")
		self._set_dns_timings(new_ttl)

	def _get_dns_timings(self):
		"""Get DNS zone timing values from source DNS servers.
		"""
		old_timings = dict()
		for plesk_id, _ in self.source_plesks.iteritems():
			logger.debug(messages.LOG_GET_DNS_TIMINGS_FROM, plesk_id)
			source_server = self.conn.get_source_node(plesk_id)
			with log_context(plesk_id):
				backup = self.load_raw_plesk_backup(self.source_plesks[plesk_id])
				old_timings[plesk_id] = dns_timing.get_dns_timings(
					source_server.plesk_api(), backup
				)

		return old_timings

	def _set_dns_timings(self, new_ttl):
		"""Set given TTL values for all zones on all DNS servers.
		"""
		for plesk_id, _ in self.source_plesks.iteritems():
			logger.debug(messages.LOG_SET_DNS_TIMINGS, plesk_id)
			source_server = self.conn.get_source_node(plesk_id)
			with log_context(plesk_id):
				plesk_api = source_server.plesk_api()
				backup = self.load_raw_plesk_backup(self.source_plesks[plesk_id])
				dns_timing.set_low_dns_timings(plesk_api, backup, new_ttl)

	# ======================== Copy mail content ==============================

	def _copy_mail_content_single_subscription(self, migrator_server, subscription, issues):
		return PleskCopyMailContent(
			get_rsync=self._get_rsync,
			use_psamailbackup=self._use_psmailbackup()
		).copy_mail(self.global_context, migrator_server, subscription, issues)

	# ======================== Utility methods ==============================

	def shallow_dump_supported(self, source_id):
		return True

	@cached
	def _get_source_node(self, node_id):
		node_settings = self._get_source_servers()[node_id]
		return PleskSourceServer(node_id, node_settings, self._get_migrator_server())

	def _get_rsync(self, source_server, target_server, source_ip=None):
		# This method does not depend on source_ip, this argument is necessary for H-Sphere/Helm
		return self.global_context.rsync_pool.get(source_server, target_server, source_server.vhosts_dir)

	def _is_subscription_suspended_on_source(self, source_server, subscription_name):
		"""Detect if subscription is suspended on the source Panel (specified by "source_settings" argument)
		Here is implementation for Plesk, should be overriden in H-Sphere
		"""
		return plesk_api_utils.is_subscription_suspended(
			source_server.plesk_api(), subscription_name
		)
