import logging
from contextlib import closing
import itertools
import os

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(u"Set up DNS forwarding on Plesk '%s'", 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:
					ppa_dns_info = self._get_subscriptions_first_dns_info(domain_info.domain_name.encode('idna'))
				except Exception as e:
					errmsg = u"Could not get PPA DNS server for zone '%s', error is '%s'" % (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:
					subdomains_masters[domain_info.domain_name] = ppa_dns_info['ip_address']
				else:
					domains_masters[domain_info.domain_name] = ppa_dns_info['ip_address']

			err_zones.update(
				self._switch_domains_dns_to_slave(domains_info, domains_masters, server_id)
			)
			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, u"Configure this zone on the source server to be served in slave mode, with the corresponding PPA DNS server as master.")

	def _undo_dns_forwarding(self, root_report):
		for server_id, server_settings in self.conn.get_dns_servers().iteritems():
			logger.info(u"Undo DNS forwarding on Plesk '%s'", server_id)

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

			domains_masters = {}
			subdomains = []
			for domain_info in domains_info:
				try:
					ppa_dns_info = self._get_subscriptions_first_dns_info(domain_info.domain_name.encode('idna'))
				except Exception as e:
					errmsg = u"Could not get PPA DNS server for zone '%s', error is '%s'" % (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:
					subdomains.append(domain_info.domain_name)
				else:
					domains_masters[domain_info.domain_name] = ppa_dns_info['ip_address']
			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, u"Restore DNS hosting settings manually or from Plesk backup file '%s'" % 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(u"Skip setting up the DNS forwarding for domain '%s' zone which is already in slave mode", domain.name)
			elif any([
				domain_type == 'subdomain', # 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.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(u"Unable to add subdomain zones in slave mode: unknown DNS service type '%s'", dns_type)
				return {
					zone: u"Unable to add a slave DNS zone for subdomain '%s': Plesk server '%s' has DNS service of unknown/unsupported type '%s'" % (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(u"Unable to remove subdomain zones: unknown DNS service type '%s'", dns_type)
				return {
					zone: u"Unable to remove the DNS zone of subdomain '%s': Plesk server '%s' has DNS service of unknown/unsupported type '%s'" % (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(u"Change %d zone(s) on Plesk '%s'", 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(u"Exception:", exc_info=e)
				errmsg = u"Could not get identifier of domain '%s' on source server using Plesk API, error message: '%s'" % (zone, e)
				err_zones[zone] = errmsg
				continue
			if domain_id is None:
				errmsg = u"Could not get identifier of domain '%s' on source server using Plesk API" % 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 = u"Could not enable the DNS zone of domain '%s', error code: %s, error message: '%s'" % (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 = u"Could not switch to 'slave' the DNS service mode for domain '%s', error code: %s, error message: '%s'" % (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 = u"Could not unassign the DNS server serving domain '%s' in master mode, error code: %s, error message: '%s'" % (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 = u"Could not switch to 'master' the DNS service mode for domain '%s', error code: %s, error message: '%s'" % (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 = u"Could not disable the DNS zone of domain '%s', error code: %s, error message: '%s'" % (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(
				u"File '%s' with old DNS timing values already exists. Rewrite is not performed not to loose old timing settings" \
				% (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(u"Store old DNS timing values to '%s'", 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(u"Get old DNS timing values from '%s' Plesk", 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(u"Set low DNS timing values on '%s' Plesk", 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
		)
