import itertools
import os

from parallels.core import MigrationError
from parallels.core.logging import get_logger
from parallels.core.logging import log_context
from parallels.core import version_tuple
from parallels.core.migrator import Migrator as CommonMigrator
from parallels.core.reports.model.issue import Issue
from parallels.core.reports.plain_report import PlainReport
from parallels.core.utils import yaml_utils
from parallels.core.utils.common import group_by_id, cached, safe_format
from parallels.core.utils.common import obj
from parallels.core.utils.migration_progress import SubscriptionMigrationStatus
from parallels.core.utils.subscription_operations import SubscriptionOperation
from parallels.plesk.source.plesk import messages
from parallels.plesk.source.plesk.connections.source_connections import PleskMigratorConnections
from parallels.plesk.source.plesk.content.mail import PleskCopyMailContent
from parallels.plesk.source.plesk.global_context import PleskGlobalMigrationContext
from parallels.plesk.source.plesk.migrated_subscription import PleskMigratedSubscription
from parallels.plesk.source.plesk.session_files import PleskSessionFiles
from parallels.plesk.source.plesk.web_files import PleskWebFiles
from parallels.plesk.utils.xml_rpc.plesk import operator as plesk_ops
from parallels.plesk.utils.xml_rpc.plesk.core import PleskError
from parallels.plesk.utils.xml_rpc.plesk.operator.dns9 import DnsOperator9
from parallels.plesk.utils.xml_rpc.plesk.operator.domain_alias import DomainAliasOperator
from . import dns_timing

logger = get_logger(__name__)


class Migrator(CommonMigrator):
    def _load_connections_configuration(self, global_context, target_panel_type):
        return PleskMigratorConnections(global_context, self._get_target_panel_by_name(target_panel_type))

    def _create_global_context(self):
        """Create global context object

        :rtype: parallels.plesk.source.plesk.global_context.PleskGlobalMigrationContext
        """
        return PleskGlobalMigrationContext()

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

    @cached
    def _create_migrated_subscription(self, name):
        return PleskMigratedSubscription(self, name)

    @property
    def web_files(self):
        """Object to list files to be transferred from source to target

        :rtype: parallels.core.utils.paths.web_files.BaseWebFiles
        """
        return PleskWebFiles()

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

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

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

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

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

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

                domains_masters[domain_info.domain_name] = target_dns_ip

            err_zones.update(
                self._switch_domains_dns_to_slave(domains_info, domains_masters, server_id)
            )

            if len(err_zones) > 0:
                self._add_forwarding_issues(
                    root_report, server_id, err_zones, domains_info,
                    messages.FAILED_DNS_FORWARDING_SET_UP_MANUAL_SOLUTION
                )

        plain_report = PlainReport(self.global_context.dns_forwarding_report, self.global_context.migration_list_data)
        for subscription in self.global_context.iter_all_subscriptions():
            subscription_report = plain_report.get_subscription_report(subscription.name)
            if self.global_context.subscriptions_status.is_stop(subscription.name):
                subscription_status = SubscriptionMigrationStatus.CANCELLED
            elif not subscription_report.has_errors_or_warnings():
                subscription_status = SubscriptionMigrationStatus.FINISHED_OK
            elif not subscription_report.has_errors():
                subscription_status = SubscriptionMigrationStatus.FINISHED_WARNINGS
            else:
                subscription_status = SubscriptionMigrationStatus.FINISHED_ERRORS

            self.global_context.subscriptions_status.set_operation_status(
                subscription.name, SubscriptionOperation.OPERATION_DNS_SWITCHED, subscription_status
            )

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

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

                domains_masters[domain_info.domain_name] = target_dns_ip

            err_zones.update(
                self._switch_domains_dns_to_master(domains_info, domains_masters, server_id)
            )

            if len(err_zones) > 0:
                backup_filename = self.get_raw_dump_filename(server_settings.id)
                self._add_forwarding_issues(
                    root_report, server_id, err_zones, domains_info,
                    messages.FAILED_DNS_FORWARDING_UNDO_MANUAL_SOLUTION % backup_filename
                )

        plain_report = PlainReport(self.global_context.dns_forwarding_report, self.global_context.migration_list_data)
        for subscription in self.global_context.iter_all_subscriptions():
            subscription_report = plain_report.get_subscription_report(subscription.name)
            if self.global_context.subscriptions_status.is_stop(subscription.name):
                subscription_status = SubscriptionMigrationStatus.CANCELLED
            elif not subscription_report.has_errors_or_warnings():
                subscription_status = SubscriptionMigrationStatus.REVERTED
            elif not subscription_report.has_errors():
                subscription_status = SubscriptionMigrationStatus.FINISHED_WARNINGS
            else:
                subscription_status = SubscriptionMigrationStatus.FINISHED_ERRORS

            self.global_context.subscriptions_status.set_operation_status(
                subscription.name, SubscriptionOperation.OPERATION_DNS_SWITCHED, subscription_status
            )

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

        backup = self.load_raw_dump(self.global_context.source_servers[plesk_id])
        for subscription in backup.iter_all_subscriptions():
            # subscription, domains and subdomains
            for domain in itertools.chain([subscription], backup.iter_sites(subscription.name)):
                self._add_domain_info(
                    domains_info, plesk_id, domain,
                    'subdomain' if getattr(domain, 'parent_domain_name', None) is not None else 'domain',
                    subscription.name
                )
            # domain aliases
            for domain_alias in backup.iter_aliases(subscription.name):
                self._add_domain_info(domains_info, plesk_id, domain_alias, 'domain_alias', subscription.name)

        return domains_info

    @staticmethod
    def _add_domain_info(domains_info, plesk_id, domain, domain_type, subscription_name):
        if domain.dns_zone is not None and domain.dns_zone.zone_type == 'slave':
            # changing master servers for a zone in slave mode is generally incorrect -
            # customer may be hosting sites, or some additional services, outside of source Plesk
            # and they would become broken if we forward DNS queries for such zone to target panel's DNS
            logger.debug(messages.LOG_SKIP_SETTING_UP_DNS_FORWARDING_FOR_SLAVE, domain.name)
            return

        if domain.dns_zone is None or not domain.dns_zone.enabled:
            return

        domains_info.update([
            obj(
                domain_name=domain.name,
                domain_type=domain_type,
                dns_zone=domain.dns_zone,
                subscription_name=subscription_name,
                plesk_id=plesk_id
            )
        ])

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

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

    def _add_forwarding_issues(self, root_report, plesk_id, err_zones, domains_info, solution):
        plain_report = PlainReport(root_report, self.global_context.migration_list_data)
        domains_by_name = group_by_id(domains_info, lambda di: di.domain_name)
        for zone, errmsg in err_zones.items():
            subscription_name = domains_by_name[zone].subscription_name
            plain_report.get_subscription_report(subscription_name).add_issue(
                'dns_forwarding_issue', Issue.SEVERITY_ERROR, errmsg,
                solution
            )

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

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

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

            try:
                err_zones.update(
                    self._change_zone_api(zone, job, domain_id, plesk_api_client)
                )
            except Exception as e:
                logger.exception()
                errmsg = safe_format(
                    messages.FAILED_TO_CONFIGURE_DNS_FORWARDING_FOR_ZONE,
                    zone=zone, error=e
                )
                err_zones[zone] = errmsg

        return err_zones

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

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

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

            domain_ids = {
                result.data[1].name: result.data[0] for result in plesk_api_client.send(
                    operator.Get(filter=api_filter)
                )
            }

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

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

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

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

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

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

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

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

        return err_zones

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

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

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

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

    def _set_low_dns_timings_step(self):
        new_ttl = self.global_context.config.getint('GLOBAL', 'zones-ttl')

        logger.info(messages.LOG_GET_OLD_DNS_TIMING_VALUES)
        old_timings = self._get_dns_timings()

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

        logger.info(messages.LOG_SET_LOW_DNS_TIMING_VALUES)
        self._set_dns_timings(new_ttl)

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

        return old_timings

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

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

    def copy_mail_content_single_subscription(self, subscription, issues):
        return PleskCopyMailContent(get_rsync=self._get_rsync).copy_mail(self.global_context, subscription, issues)

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

    def shallow_dump_supported(self, source_id):
        return True

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