"""Handle failures for migration objects, track them, filter out objects that can not be migrated"""

from collections import defaultdict
from contextlib import contextmanager

from parallels.core import MigrationError, MigrationNoRepeatError, MigrationStopError
from parallels.core.logging import get_logger
from parallels.core.logging import log_context, subscription_context
from parallels.core import messages
from parallels.core.reports.model.issue import Issue
from parallels.core.utils.common import all_equal, default, safe_string_repr
from parallels.core.utils.entity import Entity
from parallels.core.utils.steps_profiler import sleep

logger = get_logger(__name__)


class TargetModelStructure(Entity):
    def __init__(
        self, subscriptions=None, clients=None, resellers=None, plans=None,
        auxiliary_users=None, auxiliary_user_roles=None, general=None
    ):
        self._subscriptions = default(subscriptions, defaultdict(list))
        self._clients = default(clients, defaultdict(list))
        self._resellers = default(resellers, defaultdict(list))
        self._plans = default(plans, defaultdict(list))
        self._auxiliary_users = default(auxiliary_users, defaultdict(list))
        self._auxiliary_user_roles = default(auxiliary_user_roles, defaultdict(list))
        self._general = default(general, [])

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

    @property
    def clients(self):
        return self._clients

    @property
    def resellers(self):
        return self._resellers

    @property
    def plans(self):
        return self._plans

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

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

    @property
    def general(self):
        """General errors, not related to any objects in target model"""
        return self._general


class FailedObjectInfo(Entity):
    """Information about single failure for migrated object"""
    def __init__(self, error_message='', solution='', exception=None, is_critical=True, severity=Issue.SEVERITY_ERROR):
        """Class constructor

        :type error_message: str | unicode
        :type solution: str | unicode | None
        :type exception: Exception | None
        :type is_critical: bool
        :type severity: str | unicode | None
        """
        self._error_message = error_message
        self._solution = solution
        self._exception = exception
        self._is_critical = is_critical
        self._severity = severity

    @property
    def error_message(self):
        """Error message as text

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

    @property
    def solution(self):
        """Proposed solution to fix the issue

        :rtype: str | unicode | None
        """
        return self._solution

    @property
    def exception(self):
        """Exception that caused the failure

        :rtype: Exception | None
        """
        return self._exception

    @property
    def is_critical(self):
        """Whether operation is critical or not: should migrator completely stop migration of this object.

        For example, if subscription creation failed, there is no sense to copy web, mail files, restore
        hosting settings, etc - so subscription creation is critical. While if copying web files failed,
        it still makes sense to copy mail files, so this kind of failure is not critical.

        :rtype: bool
        """
        return self._is_critical

    @property
    def severity(self):
        """Severity of failure - info, warning or error

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


class Safe(object):
    """Handle failures for migration objects, track them, filter out objects that can not be migrated

    :type _plain_report: parallels.core.reports.plain_report.PlainReport | None
    """
    def __init__(self, model):
        self.model = model
        self.failed_objects = TargetModelStructure()
        self.issues = TargetModelStructure()
        self._plain_report = None

    def set_plain_report(self, plain_report):
        """Set report to which all issues will be reported to

        :type plain_report: parallels.core.reports.plain_report.PlainReport
        :rtype: None
        """
        self._plain_report = plain_report

    @contextmanager
    def try_subscription(self, name, error_message, solution=None, is_critical=True, use_log_context=True):
        """Context manager to wrap action performed on subscription

        This context manager catches all exceptions occurred inside, registers errors in failed objects list
        and continues migrator execution (so you can move forward to the next subscription).

        Usage example:
        with try_subscription('test.tld', 'Failed to copy web files'):
            copy_web_files('test.tld')

        :type name: str | unicode
        :type error_message: str | unicode
        :type solution: str | unicode
        :type is_critical: bool
        :type use_log_context: bool
        """
        try:
            if use_log_context:
                with subscription_context(name):
                    yield
            else:
                yield
        except Exception as e:
            logger.exception()
            info = FailedObjectInfo(
                error_message, solution, e, is_critical, Issue.SEVERITY_ERROR
            )
            if self._plain_report is not None:
                add_report_item(self._plain_report.get_subscription_report(name), info)
            self.failed_objects.subscriptions[name].append(info)
            logger.error(messages.FAILED_TO_PERFORM_ACTION_ON_SUBSCRIPTION, name, error_message, e)

    def try_subscription_with_rerun(
        self, func, name, error_message, solution=None,
        is_critical=True, repeat_error=None, repeat_count=3, sleep_time=10,
        use_log_context=True
    ):
        """
        :rtype: bool
        """
        exceptions = []

        with self.try_subscription(
            name, error_message, solution, is_critical,
            use_log_context=use_log_context
        ):
            for i in range(1, repeat_count + 1):
                try:
                    func()
                    return True
                except Exception as e:
                    logger.exception()
                    exceptions.append(e)
                    if i == repeat_count or isinstance(e, MigrationNoRepeatError):
                        raise MultipleAttemptsMigrationError(exceptions)
                    if repeat_error is not None:
                        logger.error(repeat_error)
                    sleep(sleep_time, messages.SLEEP_RETRY_EXECUTING_COMMAND)
        return False

    @contextmanager
    def try_subscriptions(self, subscriptions, is_critical=True):
        """Context manager to wrap action performed on group of subscriptions

        This context manager catches all exceptions occurred inside, registers errors in failed objects list
        for each listed subscription and continues migrator execution
        (so you can move forward to the next subscription).

        subscriptions parameter is a dict with keys - subscription names and values - error messages

        :type subscriptions: dict[str | unicode, str | unicode]
        :type is_critical: bool
        """
        try:
            yield
        except Exception as e:
            logger.exception()
            for name, error_message in subscriptions.items():
                info = FailedObjectInfo(
                    error_message, None, e, is_critical, Issue.SEVERITY_ERROR
                )
                if self._plain_report is not None:
                    add_report_item(self._plain_report.get_subscription_report(name), info)
                self.failed_objects.subscriptions[name].append(info)
                logger.error(messages.FAILED_TO_PERFORM_ACTION_ON_SUBSCRIPTION, name, error_message, e)

    @contextmanager
    def try_plan(self, reseller_name, plan_name, error_message):
        try:
            with log_context(plan_name):
                yield
        except Exception as e:
            logger.exception()
            info = FailedObjectInfo(
                error_message, None, e, is_critical=True,
                severity=Issue.SEVERITY_ERROR
            )
            if self._plain_report is not None:
                add_report_item(self._plain_report.get_plan_report(reseller_name, plan_name), info)
            self.failed_objects.plans[(reseller_name, plan_name)].append(info)
            owner_str = "'%s'" % (reseller_name,) if reseller_name is not None else 'admin'
            logger.error(messages.FAILED_TO_PERFORM_ACTION_ON_PLAN, plan_name, owner_str, error_message, e)

            def fail_plan_subscriptions(clients):
                for client in clients:
                    for subscription in client.subscriptions:
                        if subscription.plan_name == plan_name:
                            self._fail_subscription_plain(subscription.name, messages.FAILED_MIGRATE_SERVICE_PLAN_SUBSCRIPTION)

            if reseller_name is None:  # admin
                fail_plan_subscriptions(self.model.clients.values())
                for reseller in self.model.resellers.values():
                    if reseller.plan_name == plan_name:
                        self._fail_reseller_plain(reseller.login, messages.FAILED_MIGRATE_SERVICE_PLAN_RESELLER_IS)
                        for client in reseller.clients:
                            self._fail_client_plain(client.login, messages.FAILED_MIGRATE_SERVICE_PLAN_THAT_RESELLER)
                            for subscription in client.subscriptions:
                                self._fail_subscription_plain(subscription.name, messages.FAILED_MIGRATE_SERVICE_PLAN_THAT_RESELLER_1)
                        for plan in reseller.plans.values():
                            self._fail_plan_plain(reseller_name, plan.name, messages.FAILED_MIGRATE_SERVICE_PLAN_THAT_RESELLER_2)
            else:
                fail_plan_subscriptions(self.model.resellers[reseller_name].clients)

    @contextmanager
    def try_reseller(self, reseller_name, error_message):
        try:
            with log_context(reseller_name):
                yield
        except Exception as e:
            logger.exception()
            info = FailedObjectInfo(error_message, None, e, is_critical=True, severity=Issue.SEVERITY_ERROR)
            if self._plain_report is not None:
                add_report_item(self._plain_report.get_reseller_report(reseller_name), info)
            self.failed_objects.resellers[reseller_name].append(info)
            logger.error(messages.FAILED_TO_PERFORM_ACTION_ON_RESELLER, reseller_name, error_message, e)
            if self.model is not None:
                self._fail_reseller_with_subobj(reseller_name, error_message, None, e)

    @contextmanager
    def try_client(self, reseller_name, client_name, error_message):
        try:
            with log_context(client_name):
                yield
        except Exception as e:
            logger.exception()
            logger.error(messages.FAILED_TO_PERFORM_ACTION_ON_CLIENT, client_name, error_message, e)
            self._fail_client_with_subobj(reseller_name, client_name, error_message, None, e)

    @contextmanager
    def try_auxiliary_user(self, client_name, auxiliary_user_name, error_message):
        try:
            with log_context(auxiliary_user_name):
                yield
        except Exception as e:
            logger.exception()
            logger.error(
                messages.FAILED_TO_PERFORM_ACTION_ON_AUX_USER, auxiliary_user_name, client_name, error_message, e
            )
            info = FailedObjectInfo(
                error_message, None, e, is_critical=True,
                severity=Issue.SEVERITY_ERROR
            )
            if self._plain_report is not None:
                add_report_item(
                    self._plain_report.get_auxiliary_user_report(client_name, auxiliary_user_name), info
                )
            self.failed_objects.auxiliary_users[(client_name, auxiliary_user_name)].append(info)

    @contextmanager
    def try_auxiliary_user_role(self, client_name, auxiliary_user_role_name, error_message):
        try:
            with log_context(auxiliary_user_role_name):
                yield
        except Exception as e:
            logger.exception()
            logger.error(
                messages.FAILED_TO_PERFORM_ACTION_ON_AUX_USER_ROLE, auxiliary_user_role_name, client_name,
                error_message, e
            )
            info = FailedObjectInfo(
                error_message, None, e, is_critical=True,
                severity=Issue.SEVERITY_ERROR
            )
            if self._plain_report is not None:
                add_report_item(
                    self._plain_report.get_auxiliary_user_role_report(client_name, auxiliary_user_role_name), info
                )
            self.failed_objects.auxiliary_user_roles[
                (client_name, auxiliary_user_role_name)].append(info)

    @contextmanager
    def try_general(self, error_message, solution):
        try:
            yield
        except MigrationStopError:
            raise
        except Exception as e:
            logger.exception()
            logger.error(error_message)
            info = FailedObjectInfo(
                error_message, solution, e, is_critical=False,
                severity=Issue.SEVERITY_ERROR
            )
            if self._plain_report is not None:
                add_report_item(self._plain_report.get_root_report(), info)
            self.failed_objects.general.append(info)

    def fail_general(self, error_message, solution, severity=Issue.SEVERITY_ERROR):
        """Report error that will be displayed at top (not subscription/client) level in the final migration report

        :type error_message: unicode
        :type solution: unicode
        :type severity: str
        :rtype: None
        """
        info = FailedObjectInfo(
            error_message, solution, None, is_critical=False,
            severity=severity
        )
        if self._plain_report is not None:
            add_report_item(self._plain_report.get_root_report(), info)
        self.failed_objects.general.append(info)
        
    def fail_subscription(
        self, name, error_message, solution=None, is_critical=True, omit_logging=False, severity=Issue.SEVERITY_ERROR
    ):
        if not omit_logging:
            logger.error(messages.FAILED_TO_PERFORM_ACTION_ON_SUBSCRIPTION_ERROR_STRING, name, error_message)
        self._fail_subscription_plain(name, error_message, solution, is_critical, severity)

    def fail_client(
        self, reseller_name, name, error_message, solution=None, is_critical=True, omit_logging=False,
        severity=Issue.SEVERITY_ERROR
    ):
        if not omit_logging:
            logger.error(messages.FAILED_TO_PERFORM_ACTION_ON_CLIENT_ERROR_STRING, name, error_message)
        if is_critical:
            self._fail_client_with_subobj(reseller_name, name, error_message, solution, None, severity)
        else:
            self._fail_client_plain(name, error_message, solution, False, severity)

    def fail_reseller(
        self, name, error_message, solution=None, is_critical=True, omit_logging=False, severity=Issue.SEVERITY_ERROR
    ):
        """Consider specified reseller as failed to migrate

        :type name: str | unicode
        :type error_message: str | unicode | None
        :type solution: str | None
        :type is_critical: bool
        :type omit_logging: bool
        :type severity: str | unicode
        :rtype: None
        """
        if not omit_logging:
            message = messages.FAILED_TO_PERFORM_ACTION_ON_RESELLER_ERROR_STRING.format(
                reseller_username=name,
                error_message=error_message
            )
            if severity == Issue.SEVERITY_ERROR:
                logger.error(message)
            else:
                logger.warning(message)
        if is_critical:
            self._fail_reseller_with_subobj(name, error_message, solution, None)
        else:
            self._fail_reseller_plain(name, error_message, solution, False, severity)

    def fail_plan(
        self, reseller_name, plan_name, error_message, solution=None, is_critical=True, severity=Issue.SEVERITY_ERROR
    ):
        info = FailedObjectInfo(
            error_message, solution, None, is_critical=is_critical,
            severity=severity
        )
        if self._plain_report is not None:
            add_report_item(self._plain_report.get_plan_report(reseller_name, plan_name), info)
        self.failed_objects.plans[(reseller_name, plan_name)].append(info)

    def add_issue_subscription(self, subscription_name, issue):
        self.issues.subscriptions[subscription_name].append(issue)
        if self._plain_report is not None:
            self._plain_report.get_subscription_report(subscription_name).add_issue_obj(issue)

    def is_failed_subscription(self, subscription_name):
        """Check if subscription is completely failed or not.

        For subscriptions marked as completely failed we do not continue migration.

        :param subscription_name: str | unicode
        :rtype: bool
        """
        return not no_critical_errors(
            self.failed_objects.subscriptions, subscription_name
        )

    def get_filtering_model(self):
        return FilteringModel(self.model, self.failed_objects)

    def _fail_subscription_plain(self, name, error_message, solution=None, is_critical=True, severity=Issue.SEVERITY_ERROR):
        info = FailedObjectInfo(
            error_message, solution, None, is_critical, severity
        )
        if self._plain_report is not None:
            add_report_item(self._plain_report.get_subscription_report(name), info)
        self.failed_objects.subscriptions[name].append(info)

    def _fail_client_with_subobj(
        self, reseller_name, name, error_message, solution=None, exception=None, severity=Issue.SEVERITY_ERROR
    ):
        info = FailedObjectInfo(error_message, solution, exception, is_critical=True, severity=severity)
        if self._plain_report is not None:
            add_report_item(self._plain_report.get_customer_report(name), info)
        self.failed_objects.clients[name].append(info)

        if reseller_name is None:  # admin
            client = self.model.clients[name]
        else:
            client = [
                reseller_client for reseller_client in self.model.resellers[reseller_name].clients
                if reseller_client.login == name
            ][0]

        for subscription in client.subscriptions:
            self._fail_subscription_plain(subscription.name, messages.FAILED_MIGRATE_CLIENT_SUBSCRIPTION)

    def _fail_reseller_with_subobj(self, name, error_message, solution, exception):
        info = FailedObjectInfo(
            error_message, solution, exception, is_critical=True, severity=Issue.SEVERITY_ERROR
        )
        if self._plain_report is not None:
            add_report_item(self._plain_report.get_reseller_report(name), info)
        self.failed_objects.resellers[name].append(info)

        reseller = self.model.resellers[name]

        for client in reseller.clients:
            self._fail_client_plain(client.login, messages.FAILED_MIGRATE_RESELLER_THAT_OWNS_CLIENT)
            for subscription in client.subscriptions:
                self._fail_subscription_plain(subscription.name, messages.FAILED_MIGRATE_RESELLER_THAT_OWNS_CLIENT_1)

        for plan in reseller.plans.values():
            self._fail_plan_plain(name, plan.name, messages.FAILED_MIGRATE_RESELLER_PLAN)

    def _fail_client_plain(self, name, error_message, solution=None, is_critical=True, severity=Issue.SEVERITY_ERROR):
        info = FailedObjectInfo(
            error_message, solution, None, is_critical=is_critical, severity=severity
        )
        if self._plain_report is not None:
            add_report_item(self._plain_report.get_customer_report(name), info)
        self.failed_objects.clients[name].append(info)

    def _fail_plan_plain(self, reseller_name, plan_name, error_message):
        info = FailedObjectInfo(
            error_message, None, None, is_critical=True,
            severity=Issue.SEVERITY_ERROR
        )
        if self._plain_report is not None:
            add_report_item(self._plain_report.get_plan_report(reseller_name, plan_name), info)
        self.failed_objects.plans[(reseller_name, plan_name)].append(info)

    def _fail_reseller_plain(
        self, name, error_message, solution=None, is_critical=True, severity=Issue.SEVERITY_ERROR
    ):
        """Consider specified reseller as failed to migrate (do not handle subobjects)

        :type name: str | unicode
        :type error_message: str | unicode | None
        :type solution: str | None
        :type is_critical: bool
        :type severity: str | unicode
        :rtype: None
        """
        info = FailedObjectInfo(error_message, solution, None, is_critical=is_critical, severity=severity)
        if self._plain_report is not None:
            add_report_item(self._plain_report.get_reseller_report(name), info)
        self.failed_objects.resellers[name].append(info)


class FilteringModel(object):
    """Adapter over target model that filters out failed objects"""

    def __init__(self, model, failed_objects):
        self.model = model
        self.failed_objects = failed_objects 

    def __getattr__(self, name):
        return getattr(self.model, name)

    def iter_all_subscriptions(self):
        return [
            subscription for subscription in self.model.iter_all_subscriptions() 
            if no_critical_errors(self.failed_objects.subscriptions, subscription.name)
        ]

    def iter_all_owners(self):
        return [
            FilteringClient(owner, self.failed_objects) for owner in self.model.iter_all_owners()
            if no_critical_errors(self.failed_objects.clients, owner.login)
        ]

    @property
    def clients(self):
        return dict([
            (login, FilteringClient(client, self.failed_objects)) for login, client in self.model.clients.items()
            if no_critical_errors(self.failed_objects.clients, client.login)
        ])

    @property
    def resellers(self):
        return dict([
            (login, FilteringReseller(reseller, self.failed_objects))
            for login, reseller in self.model.resellers.items()
            if no_critical_errors(self.failed_objects.resellers, reseller.login)
        ])


class FilteringReseller(object):
    """Adapter over reseller target model object that filters out failed clients of reseller"""

    def __init__(self, reseller, failed_objects):
        self.reseller = reseller
        self.failed_objects = failed_objects 

    def __getattr__(self, name):
        return getattr(self.reseller, name)

    @property
    def clients(self):
        return [
            FilteringClient(client, self.failed_objects) for client in self.reseller.clients
            if no_critical_errors(self.failed_objects.clients, client.login)
        ]


class FilteringClient(object):
    """Adapter over client target model object that filters out failed subscriptions of client"""

    def __init__(self, client, failed_objects):
        self.client = client
        self.failed_objects = failed_objects

    def __getattr__(self, name):
        return getattr(self.client, name)

    def __setattr__(self, name, value):
        if name in ('client', 'failed_objects'):
            # skip internal properties of adapter
            super(FilteringClient, self).__setattr__(name, value)
        else:
            # pass set attribute request to client target model object
            return setattr(self.client, name, value)

    @property
    def subscriptions(self):
        return [
            subscription for subscription in self.client.subscriptions
            if no_critical_errors(self.failed_objects.subscriptions, subscription.name)
        ]


def no_critical_errors(failed_objects_dict, key):
    """Whether specified object has critical objects or not

    Object is specified by failed objects dictionary and key. For example,
    failed objects dictionary contains customers - key is customer login, value is list of failed objects,
    and key parameter is a specific key from dictionary, for instance 'john'.

    :type failed_objects_dict: dict[str | unicode, parallels.core.safe.FailedObjectInfo]
    :type key: str | unicode
    :rtype: bool
    """
    return key not in failed_objects_dict or all(
        not failed_object_info.is_critical for failed_object_info in failed_objects_dict[key]
    )


class MultipleAttemptsMigrationError(MigrationError):
    """Exception occurred when trying to perform operation several times, and all the times it failed

    This exception accepts list of exceptions of each single attempt to perform operation.
    As a result, it tries to compose single error message:
    if messages of all attempts are equal - it returns single message,
    otherwise it composes a list with messages from all attempts.
    """
    def __init__(self, exceptions):
        """Class constructor

        :type exceptions: list[Exception]
        """
        if not all_equal([safe_string_repr(e) for e in exceptions]):
            message = ("\n%s\n" % ('-' * 76,)).join(
                [messages.MIGRATION_TOOLS_TRIED_PERFORM_OPERATION_IN % len(exceptions)] +
                [
                    messages.ATTEMPT_FAILED_WITH_ERROR % (i, safe_string_repr(e))
                    for i, e in enumerate(exceptions, start=1)
                ]
            )
        else:
            if len(exceptions) > 0:
                message = messages.MIGRATOR_TRIED_TO_PERFORM_ACTION_IN_ATTEMPTS % (
                    len(exceptions), safe_string_repr(exceptions[0])
                )
            else:
                assert False, messages.ASSERT_NO_ATTEMPTS

        super(MultipleAttemptsMigrationError, self).__init__(message)


def add_report_issues(report, object_info_list):
    for info in object_info_list:
        add_report_item(report, info)


def add_report_item(report, info):
    report.add_issue(None, info.severity, _format_object_info(info), info.solution)


def _format_object_info(info):
    if info.exception is not None:
        if not isinstance(info.exception, MigrationError):
            return u"%s\nException: %s" % (info.error_message, info.exception)
        else:
            return u"%s\n%s" % (info.error_message, info.exception)
    else:
        return info.error_message
