"""
Data model over dump.PleskBackup* classes to work with different versions of Plesk backup files
with an object interface. Examples of objects: Subscription, Subdomain, DnsZone.
Each object usually contains:
- several attributes
    - some of them are read-only
    - others could be changed, and the changes are put into backup file
- methods to change the object in backup file, for example remove some information
"""
import base64
import itertools
from collections import namedtuple

from parallels.core import messages
from parallels.core.dump.entity.aps_application import ApsApplication
from parallels.core.dump.entity.mail_account import MailAccount
from parallels.core.dump.entity.odbc_dsn import OdbcDsn
from parallels.core.dump.entity.password import Password, get_password, set_password
from parallels.core.logging import get_logger
from parallels.core.utils.base64_utils import base64_decode
from parallels.core.utils.common import (
    parse_bool, if_not_none, obj, group_by_id, default, cached, is_empty)
from parallels.core.utils.common.ip import is_ipv4, is_ipv6, collapse_ipv4_mask
from parallels.core.utils.common.xml import elem, text_elem
from parallels.core.utils.entity import Entity
from parallels.core.utils.gz_utils import gzdecode
from parallels.core.utils.migrator_utils import safe_idn_decode

logger = get_logger(__name__)


def parse_subscription_ips(ip_nodes):
    return parse_plesk_ips(ip_nodes)


def parse_plesk_ips(ip_nodes):
    ipv4 = []
    ipv6 = []
    for ip_node in ip_nodes:
        ip_address = ip_node.findtext('ip-address')
        ip_type = ip_node.findtext('ip-type')
        if is_ipv4(ip_address):
            ipv4.append(obj(ip=ip_address, ip_type=ip_type))
        elif is_ipv6(ip_address):
            ipv6.append(obj(ip=ip_address, ip_type=ip_type))
        else:
            raise Exception(messages.UNKNOWN_IP_ADDRESS_TYPE % ip_address)

    assert 0 <= len(ipv4)
    assert 0 <= len(ipv6)

    return Ips(
        v4=ipv4[0].ip if len(ipv4) != 0 else None,
        v4_type=ipv4[0].ip_type if len(ipv4) != 0 else None,
        v6=ipv6[0].ip if len(ipv6) != 0 else None,
        v6_type=ipv6[0].ip_type if len(ipv6) != 0 else None,
    )


def parse_logrotation_node(node):
    """
    :rtype: parallels.core.dump.data_model.LogRotation
    """
    if node is not None:
        return LogRotation(
            enabled=node.attrib['enabled'] == 'true',
            compress=node.attrib['compress'] == 'true',
            max_logfiles_number=int(node.attrib['max-number-of-logfiles']),
            max_size=if_not_none(node.findtext('logrotation-maxsize'), int),
            period=if_not_none(node.find('logrotation-period'), lambda n: n.attrib['period']),
            email=node.attrib.get('email'),
        )
    else:
        # default log rotation params
        return LogRotation(
            enabled=False, compress=False, max_logfiles_number=0,
            # XXX why should backup reader ever bother about the features not supported by target panel?
            # Log rotation can not be disabled in CBM, and either max_size or period must be provided. 
            # Max size must be greater than 0 (otherwise provisioning task fails), use 10240KB that is a default value in CBM.
            max_size=10240 * 1024,
            period=None, email=None
        )


def parse_php_settings_node(node):
    """
    :rtype: dict[str | unicode, str | unicode]
    """
    if node is not None:
        return dict((item.findtext('name'), item.findtext('value')) for item in node.findall('setting'))
    else:
        return dict()


def parse_ip(node):
    return IpAddress(
        address=node.findtext('ip-address'),
        type=node.findtext('ip-type'),
    )


def parse_system_ip(node, is_windows):
    ip = parse_ip(node.find('ip'))

    mask_str = node.findtext('ip-netmask')

    try:
        mask = int(mask_str)
    except ValueError:
        if is_ipv4(ip.address):
            # Windows Plesk stores IPv4 network mask in expanded form.
            try:
                mask = collapse_ipv4_mask(mask_str)
            except ValueError:
                mask = None
        else:
            mask = None

    return SystemIpAddress(
        address=ip.address,
        mask=mask,
        interface=node.findtext('ip-interface'),
        type=ip.type,
    )


class Client(object):
    def __init__(
        self, node, login, contact, subscriptions, personal_info, auxiliary_user_roles, auxiliary_users,
        is_enabled, external_id=None, creation_date=None
    ):
        self._node = node
        self._login = login
        self._contact = contact
        self._subscriptions = subscriptions
        self._personal_info = personal_info
        self._auxiliary_user_roles = auxiliary_user_roles
        self._auxiliary_users = auxiliary_users
        self._is_enabled = is_enabled
        self._external_id = external_id
        self._creation_date = creation_date

    @property
    def login(self):
        return self._login

    @login.setter
    def login(self, value):
        self._login = value

    @property
    def password(self):
        """
        :rtype: parallels.core.dump.entity.password.Password
        """
        return get_password(self._node.find('properties'))

    @password.setter
    def password(self, password):
        """
        :type password:  parallels.core.dump.entity.password.Password
        """
        set_password(self._node.find('properties'), password)

    @property
    def contact(self):
        return self._contact

    @contact.setter
    def contact(self, value):
        self._contact = value

    @property
    def subscriptions(self):
        return self._subscriptions

    @subscriptions.setter
    def subscriptions(self, value):
        self._subscriptions = value

    @property
    def personal_info(self):
        return self._personal_info

    @personal_info.setter
    def personal_info(self, value):
        self._personal_info = value

    @property
    def auxiliary_user_roles(self):
        return self._auxiliary_user_roles

    @auxiliary_user_roles.setter
    def auxiliary_user_roles(self, value):
        self._auxiliary_user_roles = value

    @property
    def auxiliary_users(self):
        return self._auxiliary_users

    @auxiliary_users.setter
    def auxiliary_users(self, value):
        self._auxiliary_users = value

    @property
    def is_enabled(self):
        return self._is_enabled

    @is_enabled.setter
    def is_enabled(self, value):
        self._is_enabled = value

    @property
    def external_id(self):
        return self._external_id

    @property
    def creation_date(self):
        """Date when the customer was initially created on the source server.

        Format is "YYYY-MM-DD", for example "2015-11-16" for 16 of November 2015

        :rtype: str | unicode
        """
        return self._creation_date


class AuxiliaryUser(object):
    _external_email = ''

    def __init__(
            self, node, name, contact, email, roles, personal_info, subscription_name, is_active,
            is_domain_admin, is_built_in, external_email
    ):
        self._node = node
        self._name = name
        self._contact = contact
        self._email = email
        self._roles = roles
        self._personal_info = personal_info
        self._subscription_name = subscription_name
        self._is_active = is_active
        self._is_domain_admin = is_domain_admin
        self._is_built_in = is_built_in
        self._external_email = external_email

    @property
    def name(self):
        return self._name

    @property
    def contact(self):
        return self._contact

    @property
    def email(self):
        return self._email

    @property
    def password(self):
        """
        :rtype: parallels.core.dump.entity.password.Password
        """
        # 'password' node is located inside of 'properties' node for all of aux users except of 'domainuser's
        xml_node = self._node.find('properties')
        if xml_node is None:
            xml_node = self._node
        return get_password(xml_node)

    @password.setter
    def password(self, password):
        """
        :type password:  parallels.core.dump.entity.password.Password
        """
        # 'password' node is located inside of 'properties' node for all of aux users except of 'domainuser's
        properties_node = self._node.find('properties')
        if properties_node is not None:
            set_password(properties_node, password)
        else:
            set_password(self._node, password)

    @property
    def roles(self):
        return self._roles

    @property
    def personal_info(self):
        return self._personal_info

    @property
    def subscription_name(self):
        return self._subscription_name

    @property
    def is_active(self):
        return self._is_active

    @property
    def is_domain_admin(self):
        return self._is_domain_admin

    @property
    def is_built_in(self):
        return self._is_built_in

    @property
    def external_email(self):
        return self._external_email


AuxiliaryUserRole = namedtuple('AuxiliaryUserRole', ('name', 'permissions', 'is_domain_admin'))


class Sysuser(object):
    """
    :type _node: xml.etree.ElementTree.Element
    """
    def __init__(self, node):
        """
        :type node: xml.etree.ElementTree.Element
        """
        self._node = node

    @property
    def name(self):
        return self._node.attrib['name']

    @name.setter
    def name(self, new_value):
        self._node.attrib['name'] = new_value

    @property
    def password(self):
        """
        :rtype: parallels.core.dump.entity.password.Password
        """
        return get_password(self._node)

    @password.setter
    def password(self, password):
        """
        :type password:  parallels.core.dump.entity.password.Password
        """
        set_password(self._node, password)

    @property
    def has_shell_access(self):
        shell = self._node.attrib.get('shell')
        return shell not in (
            None, '',
            'Login Disabled',  # Windows-specific
            '/bin/false'  # Unix-specific
        )

    @property
    def home(self):
        return self._node.attrib.get('home')

    @home.setter
    def home(self, value):
        self._node.attrib['home'] = value


class FTPUser(object):
    def __init__(self, node):
        self.node = node

    @property
    def name(self):
        return self.node.attrib['name']

    @name.setter
    def name(self, new_value):
        self.node.attrib['name'] = new_value

    @property
    def sysuser(self):
        sysuser_node = self.node.find('sysuser')
        if sysuser_node is not None:
            return Sysuser(sysuser_node)
        else:
            return None


class ProtectedDir(object):
    """Protected directory of domain"""

    def __init__(self, node):
        self.node = node

    @property
    def name(self):
        """Protected directory name (which is a path relative to domain's document root)

        :rtype: unicode | str
        """
        return self.node.attrib['name']

    def iter_users(self):
        """Iterate over protected directory users

        :rtype: Iterable[parallels.core.dump.data_model.ProtectedDirUser]
        """
        for node in self.node.findall('pduser'):
            yield ProtectedDirUser(node)


class ProtectedDirUser(object):
    """Protected directory user"""

    def __init__(self, node):
        self.node = node

    @property
    def name(self):
        """Name (login) of protected directory user

        :return: str | unicode
        """
        return self.node.attrib['name']

    @name.setter
    def name(self, new_value):
        """Set name (login) of protected directory user

        :type new_value: str | unicode
        """
        self.node.attrib['name'] = new_value

    @property
    def password(self):
        """
        :rtype: parallels.core.dump.entity.password.Password
        """
        return get_password(self.node)

    @password.setter
    def password(self, password):
        """
        :type password:  parallels.core.dump.entity.password.Password
        """
        set_password(self.node, password)


class Domain(object):
    def __init__(self, node, name):
        self.node = node
        self.name = name

    @property
    def name_idn(self):
        return self.name.encode("idna")

    @property
    def name_canonical(self):
        return self.name.lower().encode('idna')

    @property
    def is_idn(self):
        return self.name != self.name_idn

    @property
    def guid(self):
        """Globally Unique Identifier of domain

        :rtype: str | None
        """
        return self.node.attrib.get('guid')

    @property
    def creation_date(self):
        """Date of subscription creation

        :rtype: str | unicode | None
        """
        return self.node.attrib.get('cr-date')

    @property
    def www_root(self):
        return if_not_none(self.node.find('phosting'), lambda p: p.attrib.get('www-root'))

    def set_www_root(self, www_root):
        phosting_node = self.node.find('phosting')
        if phosting_node:
            phosting_node.attrib['www-root'] = www_root

    @property
    def is_enable_ssl(self):
        return default(if_not_none(self.node.find('phosting'), lambda p: p.attrib.get('https') == 'true'), False)

    @property
    def hosting_type(self):
        # Plesk 10 and up have separate node for hosting
        for t in ['phosting', 'shosting', 'fhosting']:
            if self.node.find(t) is not None:
                return t
        # Plesk 9 for Windows specifies subdomain hosting as its attribute. Values there can be: vrt_hst, sub_hst
        # When such attribute is not found, return 'none'
        # FIXME won't it be better to return None instead?
        return self.node.attrib.get('hosting-type', 'none')

    @property
    def is_virtual_hosting(self):
        return self.hosting_type in ('phosting', 'vrt_hst', 'sub_hst')

    @property
    def is_forwarding(self):
        return self.hosting_type in ('shosting', 'fhosting')

    @property
    def is_frame_forwarding(self):
        return self.hosting_type == 'fhosting'

    def get_forwarding_url(self):
        return self.node.findtext('fhosting/url') or self.node.findtext('shosting/url')

    @property
    def is_maintenance(self):
        hosting_type = self.hosting_type
        if hosting_type != 'none':
            hosting_node = self.node.find(hosting_type)
            if hosting_node is not None:
                mode = hosting_node.attrib.get('maintenance-mode', False)
                if mode:
                    return mode == 'true'
        return False

    def iter_mail_accounts(self):
        for mail_user_node in self.node.findall('mailsystem/mailusers/mailuser'):
            yield MailAccount.parse(mail_user_node, self.name)

    def iter_mailboxes(self):
        for mailuser_node in self.node.findall('mailsystem/mailusers/mailuser'):
            yield Mailbox.parse(mailuser_node, self.name)

    def add_seo_redirect_if_not_set(self):
        """Add seoRedirect option if it not set to override server global value
        """
        properties_node = self.node.find('properties')
        if properties_node is None:
            return
        dom_param_node = properties_node.find('dom-param')
        if dom_param_node is None:
            dom_param_node = elem('dom-param')
            properties_node.append(dom_param_node)
        else:
            for param_node in dom_param_node:
                name_node = param_node.find('name')
                if name_node is None:
                    continue
                if name_node.text == "seoRedirect":
                    return
        param_node = elem('param', [text_elem("name", "seoRedirect"), text_elem("value", base64.b64encode("none"))])
        dom_param_node.append(param_node)

    @property
    def scripting(self):
        """
        :rtype: Scripting
        """
        return _get_scripting(self.node)

    @property
    def mailsystem(self):
        """
        :rtype: parallels.core.dump.data_model.Mailsystem | None
        """
        return None

    @property
    def web_settings(self):
        """
        :rtype: list[parallels.core.dump.data_model.WebSetting] 
        """
        settings = []

        for setting_node in self.node.findall('phosting/web-settings/setting'):
            settings.append(WebSetting(setting_node))

        return settings

    @property
    def tomcat_applications(self):
        """
        :rtype: list[parallels.core.dump.data_model.TomcatApplication] 
        """
        apps = []

        for webapp_node in self.node.findall('tomcat/webapp'):
            apps.append(TomcatApplication(webapp_node))

        return apps

    @property
    def dns_zone(self):
        """
        :rtype: parallels.core.dump.data_model.DnsZone
        """
        return None

    @property
    def iter_domains(self):
        """
        :rtype: list[]
        """
        return []


class MasterDomain(Domain):
    def __init__(self, node, name, backup):
        super(MasterDomain, self).__init__(node, name)
        self.backup = backup

    @property
    def external_id(self):
        return self.node.attrib.get('external-id')

    def get_domain_names(self):
        """Retrieve list of all domain names within subscription

        :rtype: list[str]
        """
        domain_names = [d.name for d in self.iter_domains()]
        domain_names.extend([a.name for a in self.iter_aliases()])
        return domain_names

    def iter_domains(self):
        """
        :rtype: list[parallels.core.dump.data_model.Domain]
        """
        return itertools.chain(
            [self], self.backup.iter_sites(self.name)
        )

    def iter_aliases(self):
        """
        :rtype: list[parallels.core.dump.data_model.DomainAlias]
        """
        return self.backup.iter_aliases(self.name)

    @cached
    def get_database(self, database_name, database_type):
        """Retrieve dump of database with given name and type

        :type database_name: str
        :type database_type: str
        :rtype: parallels.core.dump.data_model.Database | None
        """
        for database in self.iter_all_databases():
            if database.name == database_name and database.dbtype == database_type:
                return database
        return None

    def iter_all_databases(self):
        """Iterate all subscription databases

        :rtype: list[parallels.core.dump.data_model.Database]
        """
        for database in self.iter_databases():
            yield database

        for database in self.iter_aps_databases():
            yield database

        # iterate databases of APS applications installed on add-on domains
        for addon_domain in self.backup.iter_addon_domains(self.name):
            for database in addon_domain.iter_aps_databases():
                yield database

        # iterate databases of APS applications installed on subdomains
        for domain in itertools.chain([self], self.backup.iter_addon_domains(self.name)):
            for subdomain in self.backup.iter_subdomains(self.name, domain.name):
                for database in subdomain.iter_aps_databases():
                    yield database

    def iter_databases(self):
        """Iterate overall databases on subscriptions (except databases of installed APS applications)

        :rtype: list[parallels.core.dump.data_model.Database]
        """
        raise NotImplementedError()

    def iter_aps_databases(self):
        """Iterate databases of APS applications installed on master domain

        :rtype: list[parallels.core.dump.data_model.Database]
        """
        raise NotImplementedError()

    def get_database_user(self, database_user_name, database_user_type):
        """Retrieve dump of database user with given name and type

        :type database_user_name: str
        :type database_user_type: str
        :rtype: parallels.core.dump.data_model.DatabaseUser | None
        """
        for database_user in self.iter_all_database_users():
            if database_user.name == database_user_name and database_user.dbtype == database_user_type:
                return database_user
        return None

    def iter_all_database_users(self):
        """Iterate all database users on subscription

        :rtype: list[parallels.core.dump.data_model.DatabaseUser]
        """
        # overall database users
        for database_user in self.iter_overall_database_users():
            yield database_user

        # per-database users
        for database_user in self.iter_per_database_users():
            yield database_user

    def iter_overall_database_users(self):
        """Iterate overall database users on subscriptions (except database users of specific databases)

        :rtype: list[parallels.core.dump.data_model.DatabaseUser]
        """
        raise NotImplementedError()

    def iter_per_database_users(self):
        """Iterate database users of specific databases on subscription

        :rtype: list[parallels.core.dump.data_model.DatabaseUser]
        """
        raise NotImplementedError()


class Subscription(MasterDomain):
    def __init__(self, node, backup, plan_id, name, ip, ip_type, ipv6, ipv6_type, limits, permissions):
        super(Subscription, self).__init__(node, name, backup)
        self.plan_id = plan_id
        self.ip = ip
        self.ip_type = ip_type
        self.ipv6 = ipv6
        self.ipv6_type = ipv6_type
        self.limits = limits
        self.permissions = permissions

    def remove_database(self, database):
        """
        :type database: parallels.core.dump.data_model.Database
        :rtype: None
        """
        parent_map = {c: p for p in self.node.iter() for c in p}
        parent_map[database.node].remove(database.node)

    def remove_database_user(self, dbuser):
        """
        :type dbuser: parallels.core.dump.data_model.DatabaseUser
        :rtype: None
        """
        parent_map = {c: p for p in self.node.iter() for c in p}
        parent_map[dbuser.node].remove(dbuser.node)

    @property
    def mail_service_ips(self):
        return parse_subscription_ips(self.node.findall('mailsystem/properties/ip'))

    @property
    def dns_zone(self):
        return if_not_none(self.node.find('properties/dns-zone'), lambda dz: DnsZone.parse(self.name, dz))

    @property
    def mailsystem(self):
        return if_not_none(self.node.find('mailsystem'), lambda n: Mailsystem(n))

    @property
    def maillists(self):
        return if_not_none(self.node.find('maillists'), lambda n: Maillists(n))

    @property
    def plan(self):
        return self.backup.get_plan_by_guid(self.plan_id)

    @property
    def addon_plan_ids(self):
        ids = set()
        for plan_node in self.node.findall('preferences/subscription/plan'):
            if plan_node.attrib.get('is-addon', 'false') == 'true':
                ids.add(plan_node.attrib['plan-guid'])
        return ids

    @property
    def is_enabled(self):
        return self.node.find('properties/status/enabled') is not None

    @property
    def is_webspace_enabled(self):
        disabled_by_node = self.node.find('properties/webspace-status/disabled-by')
        if disabled_by_node is None:
            return True
        else:
            return is_empty(disabled_by_node.attrib.get('name'))

    @property
    def locked(self):
        subscription_node = self.node.find('preferences/subscription')
        if subscription_node is not None:
            return subscription_node.attrib.get('locked') == 'true'
        else:
            return False

    @property
    def dedicated_iis_application_pool_enabled(self):
        app_pool_node = self.node.find('phosting/preferences/iis-application-pool')
        if app_pool_node is not None:
            return parse_bool(app_pool_node.attrib['turned-on'])
        else:
            return False

    def remove_capability_info(self):
        capability_info_node = self.node.find('capability-info')
        if capability_info_node is not None:
            self.node.remove(capability_info_node)

    def remove_plans_reference(self):
        # We remove entire 'subscription' node, as if you remove 'plan' nodes only,
        # you will get "Element 'subscription': Missing child element(s). Expected is ( plan )." error.
        # According to plesk.xsd, 'preferences/subscription' node could only contain plans
        # related to a subscription, and sync mode, so it is safe to remove it.
        prefs_node = self.node.find('preferences')
        if prefs_node is not None:
            subscription_node = prefs_node.find('subscription')
            if (
                subscription_node is not None
                # Since Plesk 12.1 we add custom attribute to identify subscription which have no plans.
                # If custom == true subscription does not contain any plans and only external_id will be restored
                and subscription_node.attrib.get('custom') != 'true'
            ):
                prefs_node.remove(subscription_node)

    def remove_limit_and_permissions(self):
        limits_and_permissions = self.node.find('limits-and-permissions')
        if limits_and_permissions is not None:
            self.node.remove(limits_and_permissions)
            return True
        return False

    def remove_limit_by_name(self, limit_name):
        """Remove limit from subscription's limit by limit name

        :type limit_name: str | unicode
        :rtype: None
        """
        limits_and_permissions_node = self.node.find('limits-and-permissions')
        if limits_and_permissions_node is None:
            return

        for limit_node in limits_and_permissions_node.findall('limit'):
            if limit_node.attrib.get('name') == limit_name:
                limits_and_permissions_node.remove(limit_node)

    def remove_maillists(self):
        maillists_root = self.node.find('maillists')
        if maillists_root is not None:
            has_maillists = maillists_root.find('maillist') is not None
            self.node.remove(maillists_root)
            return has_maillists

        return False

    def has_maillists(self):
        maillists_root = self.node.find('maillists')
        if maillists_root is not None:
            return maillists_root.find('maillist') is not None
        else:
            return False

    def remove_mailsystems(self):
        domain_nodes = [self.node] + self.node.findall('phosting/sites/site')
        for domain_node in domain_nodes:
            if domain_node is not None:
                mailsystem_node = domain_node.find('mailsystem')
                if mailsystem_node is not None:
                    domain_node.remove(mailsystem_node)

    def remove_default_db_servers(self):
        preferences_node = self.node.find('preferences')
        if preferences_node is not None:
            default_db_servers_node = preferences_node.find('default-db-servers')
            if default_db_servers_node is not None:
                preferences_node.remove(default_db_servers_node)

    def remove_mime_types(self):
        _remove_all_nodes(self.node, 'mime-types')

    def delete_external_id(self):
        if 'external-id' in self.node.attrib:
            del self.node.attrib['external-id']

    def iter_databases(self):
        """Iterate overall databases on subscriptions (except databases of installed APS applications)

        :rtype list[parallels.core.dump.data_model.Database]
        """
        for db_node in self.node.findall('databases/database'):
            yield Database.parse(db_node)

    def iter_overall_database_users(self):
        """Get database users that have access to all databases"""
        for db_user_node in self.node.findall("databases/dbusers/dbuser"):
            user = DatabaseUser.parse(db_user_node)
            if user is None:
                continue
            yield user

    def iter_per_database_users(self):
        for db_node in itertools.chain(
            self.node.findall("databases/database"),
            self.node.findall('phosting/applications/sapp-installed/database'),
            self.node.findall('phosting/sites/site/phosting/applications/sapp-installed/database')
        ):
            for db_user_node in db_node.findall('dbuser'):
                user = DatabaseUser.parse(db_user_node, db_node)
                if user is None:
                    continue
                yield user

    def iter_all_mail_accounts(self):
        """Iterate all mail accounts on subscription

        :rtype: list[parallels.core.dump.entity.mail_account.MailAccount]
        """
        for domain in self.iter_domains():
            for mail_account in domain.iter_mail_accounts():
                yield mail_account

    @property
    def has_mail_accounts(self):
        """
        @return: bool
        """
        mail_accounts_count = sum(1 for _ in self.iter_all_mail_accounts())
        return mail_accounts_count > 0

    def iter_aps_databases(self):
        for db_node in self.node.findall('phosting/applications/sapp-installed/database'):
            yield Database.parse(db_node)

    def iter_system_users(self):
        for sysuser_node in (
            self.node.findall('phosting/preferences/sysuser')
            + self.node.findall('phosting/webusers/webuser/sysuser')
            + self.node.findall('phosting/ftpusers/ftpuser/sysuser')
        ):
            yield if_not_none(sysuser_node, lambda n: Sysuser(n))

    def iter_additional_system_users(self):
        for sysuser_node in (
            self.node.findall('phosting/webusers/webuser/sysuser')
            + self.node.findall('phosting/ftpusers/ftpuser/sysuser')
        ):
            yield if_not_none(sysuser_node, lambda n: Sysuser(n))

    def iter_ftp_users(self):
        for ftpuser_node in (
            self.node.findall('phosting/ftpusers/ftpuser')
        ):
            yield if_not_none(ftpuser_node, lambda n: FTPUser(n))

    def get_phosting_sysuser(self):
        return if_not_none(self.node.find('phosting/preferences/sysuser'), lambda n: Sysuser(n))

    def get_phosting_sysuser_name(self):
        return if_not_none(self.node.find('phosting/preferences/sysuser'), lambda n: n.attrib['name'])

    def get_phosting_webuser_names(self):
        return [
            wuser_node.attrib['name']
            for wuser_node in self.node.findall('phosting/webusers/webuser/sysuser')
        ]

    def set_phosting_sysuser_name(self, name):
        self.node.find('phosting/preferences/sysuser').attrib['name'] = name

    phosting_sysuser_name = property(get_phosting_sysuser_name, set_phosting_sysuser_name)

    def change_web_ips(self, new_ips):
        """
        :type new_ips: list[(str | unicode, str | unicode)]
        :rtype: None
        """
        # Change IP addresses for these nodes:
        change_ips_nodes = (
            (self.node.findall('properties'), None),
            (self.node.findall('phosting/properties'), None),
            (self.node.findall('tomcat/properties'), 'status'),
            (self.node.findall('shosting/properties'), None),
            (self.node.findall('fhosting/properties'), None),

        )
        # Remove all occurences of IP address nodes from these nodes:
        remove_ips_nodes = (
            self.node.findall('phosting/sites/site/properties'),
            self.node.findall('phosting/sites/site/phosting/properties'),
            self.node.findall('phosting/sites/site/shosting/properties'),
            self.node.findall('phosting/sites/site/fhosting/properties')
        )

        for nodes, insert_after in change_ips_nodes:
            for node in nodes:
                if node is not None:
                    _change_ips(node, new_ips, add_node_if_not_exists=True, insert_after=insert_after)

        for nodes in remove_ips_nodes:
            for node in nodes:
                if node is not None:
                    _change_ips(node, [], add_node_if_not_exists=False)

    @property
    def has_aps(self):
        return len(self._get_sapp_installed_nodes()) > 0

    def _get_sapp_installed_nodes(self):
        return (
            self.node.findall('phosting/applications/sapp-installed')
            # In Plesk backup of versions before 10.4, subdomains applications are found here:
            + self.node.findall('phosting/subdomains/subdomain/applications/sapp-installed')
            # In Plesk backup since version 10.4, subdomains are moved here:
            + self.node.findall('phosting/sites/site/phosting/applications/sapp-installed')
        )

    def replace_applications(self, new_applications):
        phosting_node = self.node.find('phosting')
        if phosting_node is not None:
            if len(new_applications) > 0:
                _replace_node(
                    phosting_node,
                    ['mime-types', 'webusers', 'ftpusers', 'frontpageusers', 'subdomains', 'sites'],
                    elem('applications', new_applications)
                )
            else:
                _remove_node(phosting_node, 'applications')

    def get_aps_applications(self):
        """
        :rtype: list[parallels.core.dump.entity.aps_application.ApsApplication]
        """
        return _get_aps_applications(self._get_sapp_installed_nodes())

    def get_direct_aps_applications(self):
        """Get APS applications of subscription, but not of its sites

        :rtype: list[parallels.core.dump.entity.aps_application.ApsApplication]
        """
        return _get_aps_applications(
            self.node.findall('phosting/applications/sapp-installed'),
        )

    def iter_limits(self):
        for node in self.node.findall('limits-and-permissions/limit'):
            yield Limit.parse(node)

    @property
    def www_alias_enabled(self):
        return self.node.attrib.get('www') == 'true'

    @property
    def www_root(self):
        return default(super(Subscription, self).www_root, 'httpdocs')

    @property
    def sitebuilder_site_id(self):
        return if_not_none(self.node.find('phosting'), lambda p: p.attrib.get('sitebuilder-site-id'))

    @property
    def web_ips(self):
        return Ips(v4=self.ip, v4_type=self.ip_type, v6=self.ipv6, v6_type=self.ipv6_type)

    @property
    def anonftp_enabled(self):
        """Check whether anonymous FTP is enabled or not for subscription"""
        return self.node.find('phosting/preferences/anonftp') is not None

    def get_domain(self, name):
        for domain in self.iter_domains():
            if domain.name == name:
                return domain
        raise Exception(messages.DOMAIN_NAME_S_NOT_FOUND % name)

    def iter_subdomains(self, parent_domain_name=None):
        if parent_domain_name is None:
            parent_domain_name = self.name
        return self.backup.iter_subdomains(self.name, parent_domain_name)

    def iter_all_subdomains(self):
        """Iterate over all add-on subdomains on this subscription

        :rtype: list[parallels.core.dump.data_model.Subdomain]
        """
        return itertools.chain(
            self.iter_subdomains(),
            *[self.iter_subdomains(addon.name) for addon in self.iter_addon_domains()]
        )

    def iter_addon_domains(self):
        """Iterate over all add-on domains on this subscription

        :rtype: list[parallels.core.dump.data_model.AddonDomain]
        """
        return self.backup.iter_addon_domains(self.name)

    def iter_sites(self):
        return self.backup.iter_sites(self.name)

    @property
    def owner_login(self):
        """Search for subscription's owner in backup."""
        client = self.backup.get_subscription_owner_client(self.name)
        return client.login

    @property
    def mime_types(self):
        """MIME types of subscription
        
        Dictionary {extension: mime type}
        """
        return _read_mimetypes(self.node)

    @property
    def scripting(self):
        """
        :rtype: Scripting
        """
        return _get_scripting(self.node)

    def iter_protected_dirs(self):
        """Iterate over protected directories

        :rtype: Iterable[parallels.core.dump.data_model.ProtectedDir]
        """
        for node in self.node.findall('phosting/preferences/pdir'):
            yield ProtectedDir(node)

    def remove_protected_dir(self, protected_dir):
        """Remove protected directory for domain

        :type protected_dir: parallels.core.dump.data_model.ProtectedDir
        """
        preferences_node = self.node.find('phosting/preferences')
        if preferences_node is not None:
            preferences_node.remove(protected_dir.node)

    @property
    def odbc_dsns(self):
        """
        :rtype: list[parallels.core.dump.entity.odbc_dsn.OdbcDsn]
        """
        odbc_dsns = []
        for node in self.node.findall('odbc-dsn'):
            odbc_dsns.append(OdbcDsn(node))
        return odbc_dsns


class Scripting(object):
    OPTION_PHP = 'php'
    OPTION_PHP_HANDLER_TYPE = 'php_handler_type'
    OPTION_PHP_VERSION = 'php_version'
    OPTION_ASP_DOT_NET = 'asp_dot_net'
    OPTION_ASP_DOT_NET_VERSION = 'managed_runtime_version'
    OPTION_ASP = 'asp'

    VALUE_ASP_DOT_NET_VERSION_2_0 = '2.0'
    VALUE_ASP_DOT_NET_VERSION_4_0 = '4.0'

    VALUE_PHP_HANDLER_TYPE_ISAPI = 'isapi'
    VALUE_PHP_HANDLER_TYPE_CGI = 'cgi'
    VALUE_PHP_HANDLER_TYPE_MODULE = 'module'
    VALUE_PHP_HANDLER_TYPE_FASTCGI = 'fastcgi'
    VALUE_PHP_HANDLER_TYPE_FPM = 'fpm'

    VALUE_PHP_VERSION_4_x = '4'
    VALUE_PHP_VERSION_5_BELOW_5_3 = '5'

    def __init__(self, node):
        self._node = node

    def is_option_enabled(self, option_name):
        """Check whether scripting option with specified name is enabled or not.

        Usage example: scripting.is_option_enabled('perl')

        :type option_name: str
        :rtype: bool
        """
        for option in self.options:
            if option.name == option_name and option.value == 'true':
                return True

        return False

    def disable_option(self, option_name):
        """Disable scripting option in backup dump.

        Usage example: scripting.disable_option('python')

        :type option_name: str
        :rtype: bool
        """
        for option in self.options:
            if option.name == option_name:
                option.value = 'false'

    def set_option(self, option_name, option_value):
        """Set value of specified scripting option

        Usage example: scripting.set_option('php_version', '5.3')

        :type option_name: str | unicode
        :type option_value: str | unicode
        :rtype: None
        """
        self._node.attrib[option_name] = option_value

    def enable_option(self, option_name):
        """Enable scripting option in backup dump.

        Usage example: scripting.enable_option('php')

        :type option_name: str | unicode
        :rtype: None
        """
        self._node.attrib[option_name] = 'true'

    @property
    def options(self):
        """List of all scripting options of domain object

        :rtype: list[parallels.core.dump.data_model.ScriptingOption]
        """
        return [
            ScriptingOption(self._node, option_name)
            for option_name in self._node.attrib.keys()
        ]


class ScriptingOption(object):
    def __init__(self, scripting_node, name):
        self._scripting_node = scripting_node
        self._name = name

    @property
    def name(self):
        """
        :rtype: str | unicode
        """
        return self._name

    @property
    def value(self):
        """
        :rtype: str | unicode
        """
        return self._scripting_node.attrib.get(self._name)

    @value.setter
    def value(self, new_value):
        self._scripting_node.attrib[self._name] = new_value


class TomcatApplication(object):
    def __init__(self, node):
        """
        :type node: xml.etree.ElementTree.Element
        """
        self._node = node

    @property
    def name(self):
        """
        :rtype: str | unicode | None 
        """
        return self._node.attrib.get('name')


class WebSetting(object):
    SETTING_APACHE_ADDITIONAL_SETTINGS = 'additionalSettings'
    SETTING_APACHE_SSL_ADDITIONAL_SETTINGS = 'additionalSslSettings'
    SETTING_NGINX_ADDITIONAL_SETTINGS = 'additionalNginxSettings'

    def __init__(self, node):
        self._node = node

    @property
    def name(self):
        """
        :rtype: str | unicode | None 
        """
        return self._node.findtext('name')

    @property
    def value(self):
        """
        :rtype: str | unicode | None 
        """
        value_node = self._node.find('value')

        if value_node is None or value_node.text is None:
            return None

        value = value_node.text
        if value_node.attrib.get('encoding') == 'base64':
            value = base64_decode(value)

        if value_node.attrib.get('compression') == 'gzencode':
            value = gzdecode(value)

        return value


class Subscription8(MasterDomain):
    def __init__(self, node, backup, plan_id, name, ip, ip_type, ipv6, ipv6_type, limits, permissions):
        super(Subscription8, self).__init__(node, name, backup)
        self.plan_id = plan_id
        self.ip = ip
        self.ip_type = ip_type
        self.ipv6 = ipv6
        self.ipv6_type = ipv6_type
        self.limits = limits
        self.permissions = permissions

    @property
    def mail_service_ips(self):
        # Mail IP is the same as domain one.
        return parse_subscription_ips(self.node.findall('ip'))

    @property
    def dns_zone(self):
        return if_not_none(self.node.find('dns-zone'), lambda dz: DnsZone.parse(self.name, dz))

    @property
    def mailsystem(self):
        return if_not_none(self.node.find('mailsystem'), lambda n: Mailsystem8(n))

    @property
    def maillists(self):
        return if_not_none(self.node.find('maillists'), lambda n: Maillists(n))

    @property
    def addon_plan_ids(self):
        return set()

    @property
    def is_in_maintenance_mode(self):
        disabled_by_node = self.node.find('status/disabled-by')
        if disabled_by_node is None:
            return False
        else:
            return disabled_by_node.attrib['name'] == 'client'

    @property
    def is_enabled(self):
        return self.node.find('status/enabled') is not None

    @property
    def locked(self):
        return True

    @property
    def dedicated_iis_application_pool_enabled(self):
        return False

    def remove_capability_info(self):
        capability_info_node = self.node.find('capability-info')
        if capability_info_node is not None:
            self.node.remove(capability_info_node)

    def remove_plans_reference(self):
        pass

    def remove_limit_and_permissions(self):
        limits = self.node.findall('limit')
        for limit in limits:
            self.node.remove(limit)

    def remove_maillists(self):
        maillists_root = self.node.find('maillists')
        if maillists_root is not None:
            has_maillists = maillists_root.find('maillist') is not None
            self.node.remove(maillists_root)
            return has_maillists

        return False

    def has_maillists(self):
        maillists_root = self.node.find('maillists')
        if maillists_root is not None:
            return maillists_root.find('maillist') is not None
        else:
            return False

    def remove_mailsystems(self):
        domain_nodes = [self.node] + self.node.findall('phosting/sites/site')
        for domain_node in domain_nodes:
            if domain_node is not None:
                mailsystem_node = domain_node.find('mailsystem')
                if mailsystem_node is not None:
                    domain_node.remove(mailsystem_node)

    def remove_default_db_servers(self):
        pass

    def remove_mime_types(self):
        _remove_all_nodes(self.node, 'mime-types')

    def delete_external_id(self):
        pass

    def iter_databases(self):
        """Iterate overall databases on subscriptions (except databases of installed APS applications)

        :rtype list[parallels.core.dump.data_model.Database]
        """
        for db_node in self.node.findall('database'):
            yield Database.parse(db_node)

    def iter_overall_database_users(self):
        # database users that have access to multiple databases were introduced in Plesk 11.1,
        # so return empty list for old Plesk versions
        return

    def iter_per_database_users(self):
        for db_node in itertools.chain(
            self.node.findall("database"),
            self.node.findall('phosting/sapp-installed/database'),
            self.node.findall('phosting/subdomain/applications/sapp-installed/database'),
            self.node.findall('phosting/subdomain/sapp-installed/database')
        ):
            for db_user_node in db_node.findall('dbuser'):
                user = DatabaseUser.parse(db_user_node, db_node)
                if user is not None:
                    yield user

    def iter_aps_databases(self):
        for db_node in self.node.findall('phosting/sapp-installed/database'):
            yield Database.parse(db_node)

    def get_phosting_sysuser(self):
        return if_not_none(self.node.find('phosting/sysuser'), lambda n: Sysuser(n))

    def iter_system_users(self):
        for sysuser_node in (
            self.node.findall('phosting/sysuser')
            + self.node.findall('phosting/webuser/sysuser')
        ):
            yield if_not_none(sysuser_node, lambda n: Sysuser(n))

    def iter_additional_system_users(self):
        for sysuser_node in (
            self.node.findall('phosting/webusers/webuser/sysuser')
        ):
            yield if_not_none(sysuser_node, lambda n: Sysuser(n))

    def get_phosting_sysuser_name(self):
        return if_not_none(self.node.find('phosting/sysuser'), lambda n: n.attrib['name'])

    def set_phosting_sysuser_name(self, name):
        self.node.find('phosting/sysuser').attrib['name'] = name

    phosting_sysuser_name = property(get_phosting_sysuser_name, set_phosting_sysuser_name)

    def change_web_ips(self, new_ips):
        _change_ips(
            self.node, new_ips,
            # When domain has no hosting on PfU 8, it may have no IP address node.
            # But in certain cases (don't know exactly when - need to investigate)
            # this makes Plesk restore to fail due to Plesk bug 130416:
            # it picks one of source server's IPs,
            # with which subscription can't be restored.
            add_node_if_not_exists=True
        )

    @property
    def has_aps(self):
        return len(self._get_sapp_installed_nodes()) > 0

    def iter_limits(self):
        for node in self.node.findall('limit'):
            yield Limit.parse(node)

    @property
    def www_alias_enabled(self):
        return self.node.attrib.get('www') == 'true'

    @property
    def sitebuilder_site_id(self):
        return None  # migration of WPB sites from Plesk 8 is not supported

    def get_aps_applications(self):
        """
        :rtype: list[parallels.core.dump.entity.aps_application.ApsApplication]
        """
        return _get_aps_applications(self._get_sapp_installed_nodes())

    def get_direct_aps_applications(self):
        """Get APS applications of subscription, but not of its subdomains

        :rtype: list[parallels.core.dump.entity.aps_application.ApsApplication]
        """
        return _get_aps_applications(
            self.node.findall('phosting/sapp-installed'),
        )

    def _get_sapp_installed_nodes(self):
        return self.node.findall('phosting/sapp-installed') + self.node.findall('phosting/subdomain/sapp-installed')

    def iter_mail_accounts(self):
        for mail_user_node in self.node.findall('mailsystem/mailuser'):
            yield MailAccount.parse(mail_user_node, self.name)

    def iter_all_mail_accounts(self):
        for domain in self.iter_domains():
            for mail_account in domain.iter_mail_accounts():
                yield mail_account

    def iter_mailboxes(self):
        for mailuser_node in self.node.findall('mailsystem/mailuser'):
            yield Mailbox8.parse(mailuser_node, self.name)

    @property
    def web_ips(self):
        return Ips(v4=self.ip, v4_type=self.ip_type, v6=self.ipv6, v6_type=self.ipv6_type)

    def get_domain(self, name):
        for domain in self.iter_domains():
            if domain.name == name:
                return domain
        raise Exception(messages.DOMAIN_NAME_S_NOT_FOUND_1 % name)

    def iter_sites(self):
        return self.backup.iter_sites(self.name)

    @property
    def scripting(self):
        """
        :rtype: Scripting
        """
        return _get_scripting(self.node)

    def iter_protected_dirs(self):
        """Iterate over protected directories

        :rtype: Iterable[parallels.core.dump.data_model.ProtectedDir]
        """
        for node in self.node.findall('phosting/preferences/pdir'):
            yield ProtectedDir(node)

    def remove_protected_dir(self, protected_dir):
        """Remove protected directory for domain

        :type protected_dir: parallels.core.dump.data_model.ProtectedDir
        """
        preferences_node = self.node.find('phosting/preferences')
        if preferences_node is not None:
            preferences_node.remove(protected_dir.node)

    @property
    def www_root(self):
        return default(super(Subscription8, self).www_root, 'httpdocs')

    @property
    def odbc_dsns(self):
        """
        :rtype: list[parallels.core.dump.entity.odbc_dsn.OdbcDsn]
        """
        odbc_dsns = []
        for node in self.node.findall('odbc-dsn'):
            odbc_dsns.append(OdbcDsn(node))
        return odbc_dsns


def _get_aps_applications(sapp_installed_nodes):
    """
    :param sapp_installed_nodes: list[xml.etree.ElementTree.Element]
    :rtype: list[parallels.core.dump.entity.aps_application.ApsApplication]
    """
    return [
        ApsApplication(application_node)
        for application_node in sapp_installed_nodes
    ]


class Subdomain8(Domain):
    def __init__(self, subscription_name, node, parent_domain):
        name = '%s.%s' % (node.attrib['name'], subscription_name)
        super(Subdomain8, self).__init__(node, name)
        self.subscription_name = subscription_name
        self.parent_domain = parent_domain

    @property
    def creation_date(self):
        return self.node.attrib.get('cr-date')

    @property
    def short_name(self):
        return self.node.attrib['name']

    @property
    def dns_zone(self):
        return None

    def remove_dns_zone(self):
        pass  # as there is no DNS zone in Plesk 8 - there is nothing to remove

    @property
    def parent_domain_name(self):
        # Plesk backup stores parent-domain-name in IDNA
        return self.subscription_name

    @property
    def mailsystem(self):
        return None

    @property
    def maillists(self):
        return None

    def iter_aps_databases(self):
        for database_node in itertools.chain(
            self.node.findall('applications/sapp-installed/database'),
            self.node.findall('sapp-installed/database')
        ):
            database = Database.parse(database_node)
            if database is None:
                continue
            yield database

    def replace_applications(self, new_applications):
        if len(new_applications) > 0:
            _replace_node(
                self.node,
                ['preferences', 'mime-types', 'sb-domain'],
                elem('applications', new_applications)
            )
        else:
            _remove_node(self.node, 'applications')

    def get_direct_aps_applications(self):
        """Get APS applications of subdomain

        :rtype: list[parallels.core.dump.entity.aps_application.ApsApplication]
        """
        return _get_aps_applications(
            self.node.findall('applications/sapp-installed') +
            self.node.findall('sapp-installed')
        )

    @property
    def www_alias_enabled(self):
        return False  # in Plesk 8 there were no www aliases for subdomains

    @property
    def sitebuilder_site_id(self):
        return None  # migration of WPB sites from Plesk 8 is not supported

    def iter_mail_accounts(self):
        return []  # there are no mail accounts on subdomains

    def iter_mailboxes(self):
        return []  # there are no mail boxes on subdomains

    @property
    def is_maintenance(self):
        return False

    @property
    def is_enabled(self):
        return True  # subdomains are always enabled in Plesk 8

    @property
    def is_enable_ssl(self):
        return self.parent_domain.is_enable_ssl

    @property
    def web_ips(self):
        return parse_plesk_ips([])  # Subdomain in Plesk 8 does not have a separate web IP

    @property
    def scripting(self):
        """
        :rtype: Scripting
        """
        return _get_scripting(self.node)

    @property
    def www_root(self):
        if 'www-root' in self.node.attrib:
            return self.node.attrib.get('www-root')
        else:
            # If 'www-root' attribute is not defined, fallback to default subdomain location
            return 'subdomains/%s/httpdocs' % self.short_name.encode('idna')

    @staticmethod
    def iter_protected_dirs():
        """Iterate over protected directories

        :rtype: Iterable[parallels.core.dump.data_model.ProtectedDir]
        """
        return []

    def remove_protected_dir(self, protected_dir):
        """Remove protected directory for domain

        :type protected_dir: parallels.core.dump.data_model.ProtectedDir
        """
        pass


class Limit(object):
    def __init__(self, node):
        self.node = node

    @classmethod
    def parse(cls, node):
        return cls(node)

    @property
    def name(self):
        return self.node.attrib.get('name')

    @property
    def value(self):
        return self.node.text

    @value.setter
    def value(self, new_value):
        self.node.text = new_value


class HostingPlan(namedtuple('HostingPlan', (
    'backup', 'node', 'id', 'name', 'is_addon', 'properties', 'log_rotation', 'php_settings'
))):
    @property
    def type(self):
        """
        :rtype: str | unicode
        """
        return 'hosting'

    @property
    def aps_filter(self):
        """
        :rtype: parallels.core.dump.data_model.ApsBundleFilter | None
        """
        return if_not_none(self.node.find('aps-bundle/filter'), ApsBundleFilter)

    @property
    def custom_plan_items(self):
        """
        :rtype: list[parallels.core.dump.data_model.CustomPlanItem]
        """
        all_plan_items = self.backup.get_custom_plan_items()
        all_plan_items_by_name = group_by_id(all_plan_items, lambda i: i.name)
        current_plan_items = []
        for plan_item in self.node.findall('template-plan-item'):
            if plan_item.text == 'true':
                name = plan_item.attrib.get('name')
                if name in all_plan_items_by_name:
                    current_plan_items.append(all_plan_items_by_name[name])
        return current_plan_items

    @property
    def default_db_servers(self):
        """
        :rtype: list[parallels.core.dump.data_model.DefaultDatabaseServer]
        """
        return [
            DefaultDatabaseServer(node)
            for node in self.node.findall('default-db-servers/db-server')
        ]

    def get_default_database_server(self, server_type):
        """
        :rtype: parallels.core.dump.data_model.DefaultDatabaseServer
        """
        for server in self.default_db_servers:
            if server.type == server_type:
                return server
        return None

    @property
    def php_settings_node(self):
        return self.node.find('php-settings')


class ResellerPlan(namedtuple('ResellerPlan', ('node', 'id', 'name', 'properties', 'ip_pool'))):
    @property
    def type(self):
        return 'reseller'

    @property
    def aps_filter(self):
        """
        :rtype: parallels.core.dump.data_model.ApsBundleFilter | None
        """
        return if_not_none(self.node.find('aps-bundle/filter'), ApsBundleFilter)


class DefaultDatabaseServer(object):
    def __init__(self, node):
        self._node = node

    @property
    def type(self):
        """
        :rtype: str | unicode
        """
        return self._node.attrib.get('type')

    @property
    def host(self):
        """
        :rtype: str | unicode
        """
        return self._node.findtext('host')

    @property
    def port(self):
        """
        :rtype: str | unicode
        """
        return self._node.findtext('port')


class CustomPlanItem(object):
    """Custom plan item of hosting plans, also known as "Additional Services"
    """
    def __init__(self, node):
        self._node = node
        self._properties = {
            prop_node.attrib.get('name'): prop_node.text
            for prop_node in self._node.findall('properties/plan-item-property')
        }

    @property
    def name(self):
        """
        :rtype: str | unicode | None
        """
        return self._node.attrib.get('name')

    @property
    def guid(self):
        """
        :rtype: str | unicode | None
        """
        return self._node.attrib.get('guid')

    @property
    def visible(self):
        """
        :rtype: str | unicode | None
        """
        return self._node.attrib.get('visible')

    @property
    def hint(self):
        """
        :rtype: str | unicode | None
        """
        return self._properties.get('hint')

    @property
    def description(self):
        """
        :rtype: str | unicode | None
        """
        return self._properties.get('description')

    @property
    def url(self):
        """
        :rtype: str | unicode | None
        """
        return self._properties.get('url')

    @property
    def options(self):
        """
        :rtype: int | None
        """
        return if_not_none(self._properties.get('options'), int)


class ApsBundleFilter(object):
    def __init__(self, node):
        self._type = node.attrib.get('type', 'white')
        self._items = [
            ApsBundleFilterItem(item_node)
            for item_node in node.findall('item')
        ]

    @property
    def type(self):
        """
        :rtype: str | unicode
        """
        return self._type

    @property
    def items(self):
        """
        :rtype: list[ApsBundleFilterItem]
        """
        return self._items


class ApsBundleFilterItem(object):
    def __init__(self, node):
        self._name = node.findtext('name')
        self._value = node.findtext('value')

    @property
    def name(self):
        """
        :rtype: str | unicode
        """
        return self._name

    @property
    def value(self):
        """
        :rtype: str | unicode
        """
        return self._value


LogRotation = namedtuple('LogRotation', ('compress', 'max_logfiles_number', 'enabled', 'max_size', 'period', 'email'))

Ips = namedtuple('Ips', ('v4', 'v4_type', 'v6', 'v6_type'))
IpAddress = namedtuple('IpAddress', ('address', 'type'))
SystemIpAddress = namedtuple('SystemIpAddress', ('address', 'mask', 'interface', 'type'))


class DatabaseServer(Entity):
    def __init__(self, host, port, login, password, dbtype, is_default):
        self._host = host
        self._port = port
        self._login = login
        self._password = Password(password_text=password, password_type='plain')
        self._dbtype = dbtype
        self._is_default = is_default

    @property
    def host(self):
        return self._host

    @property
    def port(self):
        return self._port

    @property
    def login(self):
        return self._login

    @property
    def password(self):
        """
        :rtype: parallels.core.dump.entity.password.Password
        """
        return self._password

    @property
    def dbtype(self):
        return self._dbtype

    @property
    def is_default(self):
        return self._is_default


class DatabaseServerOverXML(object):
    def __init__(self, node, host, port, login, dbtype, is_default):
        self._node = node
        self._host = host
        self._port = port
        self._login = login
        self._dbtype = dbtype
        self._is_default = is_default

    @property
    def host(self):
        return self._host

    @property
    def port(self):
        return self._port

    @property
    def login(self):
        return self._login

    @property
    def password(self):
        """
        :rtype: parallels.core.dump.entity.password.Password
        """
        return get_password(self._node)

    @password.setter
    def password(self, password):
        """
        :type password:  parallels.core.dump.entity.password.Password
        """
        set_password(self._node, password)

    @property
    def dbtype(self):
        return self._dbtype

    @property
    def is_default(self):
        return self._is_default


class Database(object):
    def __init__(self, node, name, dbtype, host, port, login, password, password_type):
        self.node = node
        self.name = name
        self.dbtype = dbtype
        self.host = host
        self.port = port
        self.login = login
        self.password = password
        self.password_type = password_type

    @staticmethod
    def parse(node):
        name = node.attrib['name']
        dbtype = node.attrib['type']

        # parse database server data
        host = node.findtext('db-server/host')
        # Microsoft SQL database at localhost has port 0, at remote host - has empty port ('')

        port = 0
        port_string = node.findtext('db-server/port')
        if port_string is not None and port_string != '':
            port = int(port_string)

        dbuser_node = node.find('dbuser')
        if dbuser_node is not None:
            login = dbuser_node.attrib['name']
            password = dbuser_node.findtext('password')
            password_type = dbuser_node.find('password').attrib['type']
        else:
            login = password = password_type = None
        return Database(node, name, dbtype, host, port, login, password, password_type)

    def change_location(self, location):
        self.host = location.host
        self.port = location.port

        host_node = self.node.find('db-server/host')
        if host_node is not None:
            host_node.text = location.host

        port_node = self.node.find('db-server/port')
        if port_node is not None:
            port_node.text = str(location.port) if location.port is not None else ''

    def set_host(self, host):
        self.host = host
        self.node.find('db-server/host').text = host

    def set_name(self, name):
        self.name = name
        self.node.attrib['name'] = name


class DatabaseUser(object):
    def __init__(self, node, name, database_name=None, dbtype=None, host=None, port=None):
        self.node = node
        self.name = name
        self.database_name = database_name
        self.dbtype = dbtype
        self.host = host
        self.port = port

    @staticmethod
    def parse(node, database_node=None):
        name = node.attrib['name']

        database_name = if_not_none(database_node, lambda x: x.attrib.get('name'))

        db_server_node = node.find('db-server') if database_node is None else database_node.find('db-server')

        dbtype = if_not_none(db_server_node, lambda d: d.attrib.get('type'))
        host = if_not_none(db_server_node, lambda d: d.findtext('host'))

        port = 0
        port_string = if_not_none(db_server_node, lambda d: d.findtext('port'))
        if port_string is not None and port_string != '':
            port = int(port_string)

        return DatabaseUser(node, name, database_name, dbtype, host, port)

    @property
    def password(self):
        """
        :rtype: parallels.core.dump.entity.password.Password
        """
        return get_password(self.node)

    @password.setter
    def password(self, password):
        """
        :type password: parallels.core.dump.entity.password.Password
        """
        set_password(self.node, password)

    def change_location(self, location):
        host_node = self.node.find('db-server/host')
        if host_node is not None:
            host_node.text = location.host
        port_node = self.node.find('db-server/port')
        if port_node is not None:
            port_node.text = str(location.port) if location.port is not None else ''

    def set_name(self, name):
        self.name = name
        self.node.attrib['name'] = name


class SpamassassinPattern(object):
    def __init__(self, node):
        self._node = node

    @property
    def address(self):
        return self._node.text

    @address.setter
    def address(self, new_address):
        self._node.text = new_address


class Mailbox(object):
    def __init__(self, node, name, domain_name, enabled, quota):
        self._node = node
        self.name = name
        self.domain_name = domain_name
        self.enabled = enabled
        self.quota = quota

    @staticmethod
    def parse(mailuser_node, domain_name):
        name = mailuser_node.attrib['name']
        # if there is no password, disable the mailbox
        enabled = False
        password_node = mailuser_node.find('properties/password')
        password = if_not_none(password_node, lambda node: node.text)
        if password:
            mnode = mailuser_node.find('preferences/mailbox')
            if mnode is not None:
                enabled = parse_bool(mnode.attrib.get('enabled'))

        quota = if_not_none(mailuser_node.attrib.get('mailbox-quota'), lambda q: int(q))
        return Mailbox(mailuser_node, name, domain_name, enabled, quota)

    @property
    def full_name(self):
        return '%s@%s' % (self.name, self.domain_name)

    @property
    def password(self):
        """
        :rtype: parallels.core.dump.entity.password.Password
        """
        return get_password(self._node.find('properties'))

    @password.setter
    def password(self, password):
        if password is not None:
            properties_node = self._node.find('properties')
            if properties_node is None:
                properties_node = elem('properties')
                self._node.insert(0, properties_node)
            set_password(properties_node, password)

    def remove_spamassassin(self):
        self._remove_preferenses_subnode('spamassassin')

    def remove_virusfilter(self):
        self._remove_preferenses_subnode('virusfilter')

    def has_mailbox(self):
        return self._node.find('preferences/mailbox') is not None

    def _remove_preferenses_subnode(self, subnode_name):
        preferences_node = self._node.find('preferences')
        if preferences_node is not None:
            subnode = preferences_node.find(subnode_name)
            if subnode is not None:
                preferences_node.remove(subnode)

    @property
    def cp_access(self):
        return self._node.attrib.get('cp-access-default') == 'true'

    @cp_access.setter
    def cp_access(self, new_value):
        if new_value:
            self._node.attrib['cp-access-default'] = 'true'
        else:
            if 'cp-access-default' in self._node.attrib:
                self._node.attrib['cp-access-default'] = 'false'

    def iter_spamassassin_patterns(self):
        for pattern_node in itertools.chain(
            self._node.findall('preferences/spamassassin/blacklist-member'),
            self._node.findall('preferences/spamassassin/whitelist-member')
        ):
            yield SpamassassinPattern(pattern_node)


class Mailbox8(object):
    def __init__(self, node, name, domain_name, password, password_type, enabled, quota):
        self.node = node
        self.name = name
        self.domain_name = domain_name
        self.password = password
        self.password_type = password_type
        self.enabled = enabled
        self.quota = quota

    @staticmethod
    def parse(node, domain_name):
        name = node.attrib['name']
        password = node.findtext('password')
        password_type = if_not_none(node.find('password'), lambda pnode: pnode.attrib.get('type'))

        mailbox_node = node.find('mailbox')
        if mailbox_node is not None:
            if mailbox_node.attrib.get('enabled') != 'false':
                enabled = True
            else:
                enabled = False
        else:
            enabled = False
        quota = if_not_none(node.attrib.get('mailbox-quota'), lambda q: int(q))
        return Mailbox8(node, name, domain_name, password, password_type, enabled=enabled, quota=quota)

    @property
    def full_name(self):
        return '%s@%s' % (self.name, self.domain_name)

    def set_password(self, password):
        password_node = self.node.find('password')
        if password_node is None:
            password_node = elem('password')
            self.node.insert(0, password_node)

        password_node.attrib['type'] = 'plain'
        password_node.text = password

    def remove_spamassassin(self):
        self._remove_subnode('spamassassin')

    def remove_virusfilter(self):
        self._remove_subnode('virusfilter')

    def _remove_subnode(self, subnode_name):
        subnode = self.node.find(subnode_name)
        if subnode is not None:
            self.node.remove(subnode)

DnsSettings = namedtuple('DnsSettings', ('do_subdomains_own_zones',))


class DnsZoneBase(object):
    """Entity class for DNS zone"""

    def __init__(self, domain_name, zone_type, enabled):
        self.domain_name = domain_name
        self.zone_type = zone_type
        self.enabled = enabled

    def iter_dns_records(self):
        raise NotImplementedError()

    def add_dns_record(self, record):
        raise NotImplementedError()

    def remove_dns_record(self, record):
        raise NotImplementedError()

    def enable(self):
        raise NotImplementedError()


class DnsZone(DnsZoneBase):
    """DNS zone based on backup XML node"""

    def __init__(self, domain_name, node, zone_type, enabled):
        self._node = node
        super(DnsZone, self).__init__(domain_name, zone_type, enabled)

    @staticmethod
    def parse(domain_name, node):
        return DnsZone(
            domain_name,
            node,
            zone_type=node.attrib['type'],
            enabled=node.find('status/enabled') is not None
        )

    def iter_dns_records(self):
        for n in self._node.iterfind('dnsrec'):
            # XXX Workaround for bug #106623: skip "slave zone" marker record which doesn't
            # have 'src' and 'dst' attributes and causes parsing failure.
            if n.attrib['type'] == 'master':
                continue
            yield DnsRecord.parse(n)

    def remove_marker_records(self):
        nodes_to_remove = []
        for n in self._node.iterfind('dnsrec'):
            if n.attrib['type'] == 'master':
                nodes_to_remove.append(n)

        for n in nodes_to_remove:
            self._node.remove(n)

    def add_dns_record(self, record):
        record_xml_node = elem('dnsrec', [], {
            'type': record.rec_type, 'src': record.src, 'dst': record.dst, 'opt': record.opt
        })
        self._node.append(record_xml_node)
        return DnsRecord(node=record_xml_node, src=record.src, dst=record.dst, opt=record.opt, rec_type=record.rec_type)

    def remove_dns_record(self, record):
        self._node.remove(record._node)

    def enable(self):
        for index, node in enumerate(self._node):
            if node.tag == 'status':
                self._node[index] = elem('status', [elem('enabled')])
                return

    @property
    def is_enabled(self):
        return self._node.find('status/enabled') is not None


class DnsRecordBase(object):
    """Entity class for DNS record"""

    def __init__(self, src, dst, opt, rec_type):
        self.src = src
        self.dst = dst
        self.opt = opt
        self.rec_type = rec_type

    def change(self, src, dst):
        self.src = src
        self.dst = dst

    def __hash__(self):
        return hash(self.rec_type) ^ hash(self.src) ^ hash(self.dst) ^ hash(self.opt)

    def __eq__(self, other):
        return (
            self.rec_type == other.rec_type and
            self.src == other.src and self.dst == other.dst and self.opt == other.opt
        )

    def __repr__(self):
        return u"DnsRecord(rec_type={rec_type} src='{src}' dst='{dst}' opt='{opt}')".format(**self.__dict__)


class DnsRecord(DnsRecordBase):
    """DNS record based on backup XML node"""

    def __init__(self, node, src, dst, opt, rec_type):
        self._node = node
        super(DnsRecord, self).__init__(src, dst, opt, rec_type)

    @classmethod
    def parse(cls, node):
        src = node.attrib['src']
        dst = node.attrib.get('dst', '')
        opt = node.attrib.get('opt', '')
        rec_type = node.attrib['type']

        return DnsRecord(node, src, dst, opt, rec_type)

    def change(self, src, dst):
        self._node.attrib['src'] = src
        self._node.attrib['dst'] = dst
        super(DnsRecord, self).change(src, dst)


class AddonDomain(Domain):
    def __init__(self, node):
        super(AddonDomain, self).__init__(node, node.attrib['name'])

    @property
    def is_enabled(self):
        return self.node.find('properties/status/enabled') is not None

    @property
    def dns_zone(self):
        return if_not_none(self.node.find('properties/dns-zone'), lambda dz: DnsZone.parse(self.name, dz))

    def remove_dns_zone(self):
        properties_node = self.node.find('properties')
        if properties_node is not None:
            zone_node = properties_node.find('dns-zone')
            if zone_node is not None:
                properties_node.remove(zone_node)

    @property
    def parent_domain_name(self):
        # Plesk backup stores parent-domain-name in IDNA
        return if_not_none(self.node.attrib.get('parent-domain-name'), safe_idn_decode)

    @property
    def mailsystem(self):
        return if_not_none(self.node.find('mailsystem'), lambda n: Mailsystem(n))

    @property
    def maillists(self):
        return if_not_none(self.node.find('maillists'), lambda n: Maillists(n))

    def iter_aps_databases(self):
        db_nodes = self.node.findall('phosting/applications/sapp-installed/database')
        for db_node in db_nodes:
            yield Database.parse(db_node)

    def replace_applications(self, new_applications):
        phosting_node = self.node.find('phosting')
        if phosting_node is not None:
            if len(new_applications) > 0:
                _replace_node(
                    phosting_node,
                    ['mime-types', 'webusers', 'ftpusers', 'frontpageusers', 'subdomains', 'sites'],
                    elem('applications', new_applications)
                )
            else:
                _remove_node(phosting_node, 'applications')

    @property
    def www_alias_enabled(self):
        return self.node.attrib.get('www') == 'true'

    @property
    def sitebuilder_site_id(self):
        return if_not_none(self.node.find('phosting'), lambda p: p.attrib.get('sitebuilder-site-id'))

    @property
    def web_ips(self):
        """Return site's web IP addresses, if any are specified.
        It is technically possible to specify a separate IP address for a site since Plesk 10.
        Plesk is not using that, but H-Sphere is using.
        """
        return parse_plesk_ips(self.node.findall('properties/ip'))

    def remove_mailsystem(self):
        mailsystem_node = self.node.find('mailsystem')
        if mailsystem_node is not None:
            self.node.remove(mailsystem_node)

    def get_direct_aps_applications(self):
        """Get APS applications of addon domain

        :rtype: list[parallels.core.dump.entity.aps_application.ApsApplication]
        """
        return _get_aps_applications(
            self.node.findall('phosting/applications/sapp-installed'),
        )

    @property
    def mime_types(self):
        """MIME types of domain
        
        Dictionary {extension: mime type}
        """
        return _read_mimetypes(self.node)

    @property
    def scripting(self):
        """
        :rtype: Scripting
        """
        return _get_scripting(self.node)

    def iter_protected_dirs(self):
        """Iterate over protected directories

        :rtype: Iterable[parallels.core.dump.data_model.ProtectedDir]
        """
        for node in self.node.findall('phosting/preferences/pdir'):
            yield ProtectedDir(node)

    def remove_protected_dir(self, protected_dir):
        """Remove protected directory for domain

        :type protected_dir: parallels.core.dump.data_model.ProtectedDir
        """
        preferences_node = self.node.find('phosting/preferences')
        if preferences_node is not None:
            preferences_node.remove(protected_dir.node)

    @property
    def creation_date(self):
        return self.node.attrib.get('cr-date')


class Subdomain(AddonDomain):
    @property
    def short_name(self):
        return self.name[0:-1*len(self.parent_domain_name)-1]

    def iter_mail_accounts(self):
        return []  # there are no mail accounts on subdomains

    def iter_mailboxes(self):
        return []  # there are no mail boxes on subdomains


class DomainAlias(object):
    def __init__(self, parent_domain_name, node):
        self.parent_domain_name = parent_domain_name
        self.node = node

    @property
    def name(self):
        return self.node.attrib['name']

    @property
    def name_idn(self):
        return self.node.attrib['name'].encode('idna')

    @property
    def name_canonical(self):
        return self.node.attrib['name'].lower().encode('idna')

    @property
    def mail(self):
        return self.node.attrib['mail']

    def enable_mail(self):
        self.node.attrib['mail'] = 'true'

    @property
    def dns_zone(self):
        return if_not_none(self.node.find('dns-zone'), lambda dz: DnsZone.parse(self.name, dz))

    @property
    def mailsystem(self):
        return None

    @property
    def maillists(self):
        return None


class MailsystemBase(object):
    def __init__(self, node):
        self.node = node

    def replace_catch_all(self, value):
        """Replace catch all value with a new one. However if catch-all node does not exist, nothing will be done"""
        preferences_node = self.node.find('preferences')
        if preferences_node is not None:
            catch_all_node = preferences_node.find('catch-all')
            if catch_all_node is not None:
                catch_all_node.text = value

    def get_catch_all(self):
        return if_not_none(self.node.find('preferences'), lambda n: n.findtext('catch-all'))


class Mailsystem(MailsystemBase):
    def remove_ips(self):
        properties_node = self.node.find('properties')
        if properties_node is not None:
            _remove_node(properties_node, 'ip')

    @property
    def ips(self):
        return parse_plesk_ips(self.node.findall('properties/ip'))

    def enable(self):
        properties_node = self.node.find('properties')
        for index, node in enumerate(properties_node):
            if node.tag == 'status':
                properties_node[index] = elem('status', [elem('enabled')])
                return

    @property
    def enabled(self):
        return self.node.find('properties/status/enabled') is not None

    def set_webmail(self, webmail):
        webmail_node = self.node.find('preferences/web-mail')
        if webmail_node is not None:
            webmail_node.text = webmail

    def get_webmail(self):
        webmail_node = self.node.find('preferences/web-mail')
        if webmail_node is not None:
            return webmail_node.text
        return None

    def add_domainkeys(self, dkey_public, dkey_private):
        dkeys_node = self.node.find('preferences/domain-keys')
        if dkeys_node is not None:
            dkeys_node.attrib['public-key'] = dkey_public
            dkeys_node.attrib['private-key'] = base64.b64encode(dkey_private)

    def remove_domainkeys(self):
        self._remove_preferenses_subnode('domain-keys')

    def remove_greylisting(self):
        self._remove_preferenses_subnode('grey-listing')

    def _remove_preferenses_subnode(self, subnode_name):
        preferences_node = self.node.find('preferences')
        if preferences_node is not None:
            subnode = preferences_node.find(subnode_name)
            if subnode is not None:
                preferences_node.remove(subnode)


class Mailsystem8(MailsystemBase):
    def remove_ips(self):
        pass  # no changes are required for Plesk 8.x backup, as it does not contain any mail IP addresses

    def set_webmail(self, webmail):
        webmail_node = self.node.find('preferences/web-mail')
        if webmail_node is not None:
            webmail_node.text = webmail

    def get_webmail(self):
        webmail_node = self.node.find('preferences/web-mail')
        if webmail_node is not None:
            return webmail_node.text
        return None

    def enable(self):
        for index, node in enumerate(self.node):
            if node.tag == 'status':
                self.node[index] = elem('status', [elem('enabled')])
                return

    @property
    def enabled(self):
        return self.node.find('status/enabled') is not None

    def add_domainkeys(self, dkey_public, dkey_private):
        dkeys_node = self.node.find('domain-keys')
        if dkeys_node is not None:
            dkeys_node.attrib['public-key'] = dkey_public
            dkeys_node.attrib['private-key'] = base64.b64encode(dkey_private)

    def remove_domainkeys(self):
        self._remove_subnode('domain-keys')

    def remove_greylisting(self):
        self._remove_subnode('grey-listing')

    def _remove_subnode(self, subnode_name):
        subnode = self.node.find(subnode_name)
        if subnode is not None:
            self.node.remove(subnode)


class Maillists(object):
    def __init__(self, node):
        self.node = node

    def remove_ips(self):
        properties_node = self.node.find('properties')
        if properties_node is not None:
            _remove_node(properties_node, 'ip')


def _read_mimetypes(node):
    """Read MIME types from domain/subscription node

    Arguments:
    - node - domain or subscription node
    """
    result = {}
    for mimetype_node in node.findall('phosting/mime-types/mime-type'):
        result[mimetype_node.attrib['ext']] = mimetype_node.attrib['value']
    return result


def _change_ips(root_node, new_ips, add_node_if_not_exists=False, insert_after=None):
    """
    :type new_ips: list[(str | unicode, str | unicode)]
    :type add_node_if_not_exists: bool
    :type insert_after: str | unicode
    :rtype: None
    """
    ip_nodes = root_node.findall('ip')
    if len(ip_nodes) == 0:
        if add_node_if_not_exists:
            insert_at_index = 0
            if insert_after is not None:
                for index, node in enumerate(root_node, start=1):
                    if node.tag == insert_after:
                        insert_at_index = index
                        break

            for ip_address, ip_type in new_ips:
                if ip_address is not None:
                    new_ip_node = elem('ip', [
                        text_elem('ip-type', ip_type),
                        text_elem('ip-address', ip_address),
                    ])
                    root_node.insert(insert_at_index, new_ip_node)
        else:
            # if there were no IP nodes - we consider that backup of that particular
            # version should not contain any IP addresses in root_node
            # for example Plesk 9.x has no IP nodes in mailsystem node => we should not insert
            # these nodes with new mail service IPs as
            # Plesk restore will detect IP addresses itself
            return
    else:
        # detect position of IP nodes in root_node
        first_ip_node_index = None
        for index, node in enumerate(root_node):
            if node.tag == 'ip':
                first_ip_node_index = index
                break
        assert(first_ip_node_index is not None)

        # remove all existing IP nodes from root_node
        for ip_node in ip_nodes:
            root_node.remove(ip_node)

        # add IP nodes with new IPs
        inserted_cnt = 0
        for ip_address, ip_type in new_ips:
            if ip_address is not None:
                new_ip_node = elem('ip', [
                    text_elem('ip-type', ip_type),
                    text_elem('ip-address', ip_address),
                ])
                root_node.insert(first_ip_node_index + inserted_cnt, new_ip_node)
                inserted_cnt += 1


def _replace_node(parent_node, next_nodes_names, new_node):
    """Replace all nodes named as given new_node and located under parent_node with the new_node.
    @param parent_node
    @param next_nodes_names - names of all nodes located in schema under given parent_node and after new_node
    @param new_node
    """
    _remove_node(parent_node, new_node.tag)

    new_idx = 0
    for idx, node in enumerate(parent_node):
        if node.tag in next_nodes_names:
            break
        else:
            new_idx = idx + 1

    parent_node.insert(new_idx, new_node)


def _remove_node(parent_node, node_tag):
    for old_node in parent_node.findall(node_tag):
        parent_node.remove(old_node)


def _remove_all_nodes(parent_node, node_tag):
    """Remove all nodes with specific tag

    :type parent_node: xml.etree.ElementTree.Element
    :param node_tag: str | unicode
    :rtype: None
    """
    nodes_to_remove = []

    # find all nodes which should be removed,
    # and run remove recursively on all nodes which should not be removed
    for subnode in parent_node:
        if subnode.tag == node_tag:
            nodes_to_remove.append(subnode)
        else:
            _remove_all_nodes(subnode, node_tag)

    # actual remove happens here in a separate loop, otherwise iteration over nodes may get broken
    for subnode in nodes_to_remove:
        parent_node.remove(subnode)


def _get_scripting(domain_node):
    """
    :rtype: parallels.core.dump.data_model.Scripting
    """
    # Sites and subscriptions, defined by "<domain>" and "<site>" elements
    node = domain_node.find('phosting/limits-and-permissions/scripting')
    if node is None:
        # Subdomains defined by "<subdomain>" elements
        node = domain_node.find('scripting')
    if node is None:
        return None
    return Scripting(node)
