from parallels.plesk.source.plesk import messages
import errno
import re

from parallels.core.utils import plesk_utils
from parallels.core.utils.common import obj


# TODO for Expand implementation, move this class to a module in common

# ---------------------------------------- public only for Plesk migrator ----------------------------------------------
DnsServiceType = obj(BIND_LINUX='bind-l', BIND_WINDOWS='bind-w', MSDNS='msdns')


def getPhysicalDnsForwardingImpl(service_type, logger, migrator_server, plesk_id, source_node, runner):
	"""Return an object of appropriate class implementing DNS forwarding for that service.
	"""
	if service_type == DnsServiceType.MSDNS:
		return MsDnsForwarding(logger, migrator_server, plesk_id, source_node, runner)
	elif service_type == DnsServiceType.BIND_WINDOWS:
		return WindowsBindForwarding(logger, migrator_server, plesk_id, source_node, runner)
	elif service_type == DnsServiceType.BIND_LINUX:
		return LinuxBindForwarding(logger, migrator_server, plesk_id, runner)
	else:
		assert False, messages.INTERNAL_ERROR_UNKNOWN_DNS_SERVICE_TYPE % service_type


# ----------------- public only for migrators implementing their own DNS forwarding with this interface ----------------
class DnsForwardingBase(object):
	"""This class defines only the common initialization,

	And declares two public functions that should be defined separately for different services.
	"""
	def __init__(self, logger, migrator_server, plesk_id, runner):
		self._logger = logger
		self._migrator_server = migrator_server
		self._plesk_id = plesk_id
		self._runner = runner

	def add_slave_subdomain_zones(self, subdomains_masters):
		raise NotImplementedError()

	def remove_slave_subdomain_zones(self, subdomains):
		raise NotImplementedError()

# ---------------------------------------- this file's private code follows --------------------------------------------


class MsDnsForwarding(DnsForwardingBase):
	"""This class implements the 2 public functions of DnsForwardingBase, for Microsoft DNS
	"""
	def __init__(self, logger, migrator_server, plesk_id, source_node, runner):
		super(MsDnsForwarding, self).__init__(logger, migrator_server, plesk_id, runner)
		self._plesk_dir = source_node.plesk_dir

	def add_slave_subdomain_zones(self, subdomains_masters):
		def add(name, master):
			"""Add zone in slave mode, with specified master server
			Return error string (None if there is no error, actual string if there is a error)
			"""
			rc, stdout, stderr = self._runner.run_unchecked(
				ur"%s\admin\bin\dnscmd" % self._plesk_dir, ['/ZoneAdd', name.encode('idna'), '/Secondary', master]
			)
			if rc == 0:
				return None
			# Expected error that we're going to ignore, as it will occur
			# on when we set up DNS forwarding second time and further:
			# Command failed:  DNS_ERROR_ZONE_ALREADY_EXISTS     9609  (00002589)
			# return code is 137, which may be 9609 % 256, but may not. so we check the code in stdout as well
			if rc == 137 and "9609" in stdout:
				return None
			return stdout + stderr

		err_zones = {}
		for name, master in subdomains_masters.iteritems():
			error = add(name, master)
			if error is not None:
				errmsg = messages.COULD_NOT_ADD_ZONE_TO_DNS_ISSUE % (name, master, error)
				self._logger.error(errmsg)
				err_zones[name] = errmsg
		return err_zones

	def remove_slave_subdomain_zones(self, subdomains):
		def remove(name):
			"""Remove zone from DNS
			Return error string (None if there is no error, actual string if there is a error)
			"""
			rc, stdout, stderr = self._runner.run_unchecked(
				ur"%s\admin\bin\dnscmd" % self._plesk_dir, ['/ZoneDelete', name.encode('idna'), '/f']
			)
			if rc == 0:
				return None
			# Expected error that we're going to ignore, as it will occur on when
			# we undo DNS forwarding second time and further:
			# Command failed:  DNS_ERROR_ZONE_DOES_NOT_EXIST     9601  (00002581)
			# return code is 129, which may be 9601 % 256, but may not. so we check the code in stdout as well
			if rc == 129 and "9601" in stdout:
				return None
			return stdout + stderr

		err_zones = {}
		for name in subdomains:
			error = remove(name)
			if error is not None:
				errmsg = messages.COULD_NOT_REMOVE_ZONE_ISSUE % (name, error)
				self._logger.error(errmsg)
				err_zones[name] = errmsg
		return err_zones


class BindIndexEditor(object):
	"""This class implements editing the BIND zones index file: adding or removing zones from there.
	It either fails (with exception) or succeeds for all specified zones, so does not return the set of failed zones.
	"""
	def __init__(self, index_filename, prefix, newline):
		self._index_filename = index_filename
		self._prefix = prefix
		self._newline = newline

	def add_zones_to_index(self, zones_masters):
		zones_index = self._read_index()
		new_index = [
			line for line in zones_index
			if not self._line_refers_to_one_of_zones(line, zones_masters.keys()) and not len(line) == 0
		]
		for zone, master in zones_masters.iteritems():
			new_index.append(
				ur"""zone "{zone}" {{ type slave; file "{prefix}{zone}"; masters {{ {master}; }}; }};""".format(
					prefix=self._prefix, zone=zone.encode('idna'), master=master
				)
			)
		self._write_index(new_index)

	def remove_zones_from_index(self, zones):
		zones_index = self._read_index()
		new_index = [
			line for line in zones_index
			if not self._line_refers_to_one_of_zones(line, zones) and not len(line) == 0
		]
		self._write_index(new_index)

	def _read_index(self):
		try:
			with open(self._index_filename, 'r') as zones_file:
				zones_index = zones_file.read().split(self._newline)
		except IOError as e:
			if e.errno == errno.ENOENT:
				zones_index = []
			else:
				raise
		return zones_index

	@staticmethod
	def _line_refers_to_one_of_zones(line, zones):
		for zone in zones:
			pattern = u"""^zone "%s" {.*};""" % zone.encode('idna')
			if re.match(pattern, line):
				return True
		return False

	def _write_index(self, zones_index):
		with open(self._index_filename, 'w') as zones_file:
			content = self._newline.join(zones_index)
			if len(content) > 0:
				content += self._newline     # add trailing newline, as .join doesn't
			zones_file.write(content)


class BindForwarding(DnsForwardingBase):
	"""This class implements the 2 public functions of DnsForwardingBase, for BIND
	As such, it defines, what to do to add or remove subdomain zones, leaving unimplemented the HOW to do that
	"""
	def add_slave_subdomain_zones(self, subdomains_masters):
		try:
			# different remote path, different exceptions for ENOENT
			self._download_slave_subdomains_index()
			# different prefix and newline
			self._add_subdomains_to_index(subdomains_masters)
			# different remote path
			self._upload_slave_subdomains_index()
			# different remote path, include value and implementation how to add it
			self._include_index_to_named_conf()
			# different implementation
			self._restart_named()
			# operation did not fail - no failed subdomains
			return {}
		except Exception as e:
			self._logger.debug(messages.LOG_EXCEPTION, exc_info=e)
			self._logger.error(messages.LOG_COULD_NOT_ADD_SUBDOMAIN_SLAVE_DNS_ZONES_ISSUE, e)
			return {
				zone: messages.COULD_NOT_ADD_SUBDOMAIN_SLAVE_DNS_ZONE % (zone, e)
				for zone in subdomains_masters
			}

	def remove_slave_subdomain_zones(self, subdomains):
		try:
			self._download_slave_subdomains_index()
			self._remove_subdomains_from_index(subdomains)
			self._upload_slave_subdomains_index()
			self._restart_named()
			# operation did not fail - no failed subdomains
			return {}
		except Exception as e:
			self._logger.debug(messages.LOG_EXCEPTION, exc_info=e)
			self._logger.error(messages.LOG_COULD_NOT_REMOVE_SUBDOMAIN_DNS_ZONES, e)
			return {
				zone: messages.COULD_NOT_REMOVE_SUBDOMAIN_DNS_ZONE % (zone, e)
				for zone in subdomains
			}

	def _download_slave_subdomains_index(self):
		raise NotImplementedError()

	def _add_subdomains_to_index(self, subdomains_masters):
		raise NotImplementedError()

	def _remove_subdomains_from_index(self, subdomains):
		raise NotImplementedError()

	def _upload_slave_subdomains_index(self):
		raise NotImplementedError()

	def _include_index_to_named_conf(self):
		raise NotImplementedError()

	def _restart_named(self):
		raise NotImplementedError()


class LinuxBindForwarding(BindForwarding):
	"""This class implements the 6 private functions of BindForwarding, doing the actual changes, for BIND on Linux
	"""
	def __init__(self, logger, migrator_server, plesk_id, runner):
		super(LinuxBindForwarding, self).__init__(logger, migrator_server, plesk_id, runner)
		self._index_filename_local = migrator_server.get_session_file_path(u"subdomain_zones.%s" % plesk_id)
		self._index_filename_remote_relative = '/etc/subdomain_zones'
		self._named_run_root_d = plesk_utils.get_unix_conf_var(runner, 'NAMED_RUN_ROOT_D')
		self._index_filename_remote = u"%s%s" % (self._named_run_root_d, self._index_filename_remote_relative)
		self._index_editor = BindIndexEditor(self._index_filename_local, prefix='', newline='\n')

	def _download_slave_subdomains_index(self):
		try:
			self._runner.get_file(self._index_filename_remote, self._index_filename_local)
		except IOError as e:
			if e.errno == errno.ENOENT:
				self._logger.debug(u'(expected) Exception:', exc_info=e)
			else:
				raise

	def _add_subdomains_to_index(self, subdomains_masters):
		self._index_editor.add_zones_to_index(subdomains_masters)

	def _remove_subdomains_from_index(self, subdomains):
		self._index_editor.remove_zones_from_index(subdomains)

	def _upload_slave_subdomains_index(self):
		self._runner.upload_file(self._index_filename_local, self._index_filename_remote)

	def _include_index_to_named_conf(self):
		named_cf_remote = u"%s/etc/named.conf" % self._named_run_root_d
		named_cf_local = self._migrator_server.get_session_file_path(
			u"named.conf.%s" % (self._plesk_id,)
		)
		self._runner.get_file(named_cf_remote, named_cf_local)

		with open(named_cf_local, 'r') as f:
			named_cf_content = f.read()
		include_str = u"""include "%s";""" % self._index_filename_remote_relative
		if include_str not in named_cf_content:
			self._runner.sh(u"""(echo;echo '%s') >> "%s" """ % (include_str, named_cf_remote))

	def _restart_named(self):
		cmd = """
for startup_script in /etc/rc.d/named /etc/init.d/named /etc/init.d/bind9; do
	if [ -e $startup_script ]; then
		sh $startup_script restart
		exit $?
	fi
done
echo "Unable to restart BIND service - cannot find location of its startup script. Restart BIND service manually." 1>&2
exit 1"""
		self._runner.sh(cmd)


class WindowsBindForwarding(BindForwarding):
	"""This class implements the 6 private functions of BindForwarding, doing the actual changes, for BIND on Windows
	"""
	def __init__(self, logger, migrator_server, plesk_id, source_node, runner):
		super(WindowsBindForwarding, self).__init__(logger, migrator_server, plesk_id, runner)
		# needed in _include_index_to_named_conf
		self._plesk_dir = source_node.plesk_dir
		self._index_filename_local = migrator_server.get_session_file_path(
			u"subdomain_zones.%s" % (plesk_id,)
		)
		# needed in _include_index_to_named_conf
		self._index_filename_remote = ur"%s\dns\etc\subdomain_zones" % self._plesk_dir
		self._index_editor = BindIndexEditor(
			self._index_filename_local, prefix=ur"%s\dns\var\.slave." % self._plesk_dir, newline='\n\r'
		)

	def _download_slave_subdomains_index(self):
		try:
			self._runner.get_file(self._index_filename_remote, self._index_filename_local)
		except Exception as e:
			if u"NT_STATUS_OBJECT_NAME_NOT_FOUND opening remote file" in str(e):
				self._logger.debug(u'(expected) Exception:', exc_info=e)
			else:
				raise

	def _add_subdomains_to_index(self, subdomains_masters):
		self._index_editor.add_zones_to_index(subdomains_masters)

	def _remove_subdomains_from_index(self, subdomains):
		self._index_editor.remove_zones_from_index(subdomains)

	def _upload_slave_subdomains_index(self):
		self._runner.upload_file(self._index_filename_local, self._index_filename_remote)

	def _include_index_to_named_conf(self):
		named_cf_remote = ur"%s\dns\etc\named.conf" % self._plesk_dir
		named_cf_local = self._migrator_server.get_session_file_path(
			u"named.conf.%s" % (self._plesk_id,)
		)
		self._runner.get_file(named_cf_remote, named_cf_local)

		with open(named_cf_local, 'r') as f:
			named_cf_content = f.read()
		include_str = u"""include "%s";""" % self._index_filename_remote
		# reduce backslash during comparison so paths like "c:\a\\b" and "c:\a\b" are considered the same
		if self._reduce_char(include_str, '\\') not in self._reduce_char(named_cf_content, '\\'):
			# echo: puts an empty line, & is cmd's commands separator
			self._runner.sh(u"""cmd /c "(echo:& echo %s) >> "%s"" """ % (include_str, named_cf_remote))

	@staticmethod
	def _reduce_char(s, char):
		pair = '%s%s' % (char, char)
		while pair in s:
			s = s.replace(pair, char)
		return s

	def _restart_named(self):
		# service named could be stopped - ignore this error. TODO check other errors
		self._runner.sh_unchecked('net stop named')
		self._runner.sh('net start named')
