import functools
import logging
import time
from typing import Optional, Type, Tuple, Set, List

from django.conf import settings
from django.contrib.postgres.fields import ArrayField
from django.core.cache import cache
from django.db import models, transaction
from django.db.models import Q, QuerySet
from django.db.models.signals import post_save, pre_save
from django.dispatch import receiver
from django.utils import timezone
from django.utils.module_loading import import_string
from django.utils.translation import ugettext_lazy as _, ugettext_noop
from ylog.context import log_context

from idm.core import signals
from idm.core.constants.action import ACTION
from idm.core.constants.role import ROLE_STATE
from idm.core.constants.system import *
from idm.core.querysets.system import SystemManager
from idm.core.queues import RoleNodeQueue
from idm.core.utils import get_system_state
from idm.core.workflow.common.subject import subjectify
from idm.core.workflow.exceptions import GroupPolicyError, PassportLoginPolicyError
from idm.framework.fields import StrictForeignKey
from idm.framework.mixins import LocalizedModel
from idm.framework.requester import Requester
from idm.notification.utils import report_problem, send_notification
from idm.permissions.utils import get_permission
from idm.users.models import User
from idm.utils import http, events
from idm.utils.log import log_duration

log = logging.getLogger(__name__)


class System(LocalizedModel, models.Model):
    slug = models.SlugField(max_length=255, default='', unique=True)
    name = models.CharField(max_length=255, default='', verbose_name=_('Название'), db_index=True)
    name_en = models.CharField(max_length=255, default='', verbose_name=_('Название (англ)'), db_index=True)
    added = models.DateTimeField(auto_now_add=True)
    updated = models.DateTimeField(auto_now=True, db_index=True)
    metainfo = models.OneToOneField(
        'SystemMetainfo',
        on_delete=models.PROTECT,
        verbose_name=_('Метаинформация о системе'),
        related_name='system',
        null=True
    )
    actual_workflow = StrictForeignKey(
        'Workflow',
        verbose_name=_('Актуальный workflow'),
        null=True, blank=True, default=None,
        related_name='wf_system',
        on_delete=models.SET_NULL,
    )
    service = StrictForeignKey(
        'services.Service',
        verbose_name=_('Связанный сервис'),
        null=True, blank=True, default=None,
        related_name='systems',
        on_delete=models.SET_NULL,
    )
    is_active = models.BooleanField(default=True, verbose_name=_('Включена'))
    can_be_broken = models.BooleanField(default=True, verbose_name=_('Может быть сломана'))
    is_broken = models.BooleanField(default=False, verbose_name=_('Сломана'))
    stop_depriving = models.BooleanField(default=False, verbose_name=_('Отзыв ролей остановлен'))
    creator = StrictForeignKey(
        'users.User',
        null=True,
        blank=True,
        verbose_name=_('Создатель'),
        related_name='created_systems',
        on_delete=models.CASCADE,
    )
    has_review = models.BooleanField(default=True, verbose_name=_('Имеет регулярный пересмотр'))
    passport_policy = models.CharField(
        choices=SYSTEM_PASSPORT_POLICY.CHOICES, default=SYSTEM_PASSPORT_POLICY.UNCONSTRAINED, max_length=50,
        verbose_name=_('Политика системы в отношении паспортных логинов'),
    )
    group_policy = models.CharField(
        choices=SYSTEM_GROUP_POLICY.CHOICES, default=SYSTEM_GROUP_POLICY.UNAVAILABLE, max_length=50,
        verbose_name=_('Политика системы в отношении групп'),
        db_index=True,
    )
    request_policy = models.CharField(
        choices=SYSTEM_REQUEST_POLICY.CHOICES, default=SYSTEM_REQUEST_POLICY.SUBORDINATES, max_length=50,
        verbose_name=_('Политика системы в отношении прав запроса ролей'),
        db_index=True,
    )
    role_grant_policy = models.CharField(
        choices=SYSTEM_ROLE_GRANT_POLICY.CHOICES, default=SYSTEM_ROLE_GRANT_POLICY.IDM, max_length=50,
        verbose_name=_('Политика системы в отношении подтверждения ролей'),
    )
    roletree_policy = models.CharField(
        choices=SYSTEM_ROLETREE_POLICY.CHOICES, default=SYSTEM_ROLETREE_POLICY.EDITABLE, max_length=50,
        verbose_name=_('Политика системы в отношении редактирования дерева ролей через API'),
    )
    review_on_relocate_policy = models.CharField(
        choices=SYSTEM_REVIEW_ON_RELOCATE_POLICY.CHOICES,
        default=SYSTEM_REVIEW_ON_RELOCATE_POLICY.REVIEW,
        max_length=50,
        verbose_name=_('Политика системы в отношении перезапроса ролей при смене сотрудником подразделения'),
    )
    inconsistency_policy = models.CharField(
        choices=SYSTEM_INCONSISTENCY_POLICY.CHOICES, default=SYSTEM_INCONSISTENCY_POLICY.STRICT, max_length=50,
        verbose_name=_('Политика работы с расхождениями')
    )
    workflow_approve_policy = models.CharField(
        choices=SYSTEM_WORKFLOW_APPROVE_POLICY.CHOICES, default=SYSTEM_WORKFLOW_APPROVE_POLICY.ANY, max_length=50,
        verbose_name=_('Политика подтверждения своих правок в workflow')
    )
    audit_method = models.CharField(
        choices=SYSTEM_AUDIT_METHOD.CHOICES, default=SYSTEM_AUDIT_METHOD.GET_ALL_ROLES, max_length=50,
        verbose_name=_('Ручка для сверки ролей')
    )
    audit_backend = models.CharField(
        choices=SYSTEM_AUDIT_BACKEND.CHOICES, default=SYSTEM_AUDIT_BACKEND.MEMORY, max_length=50,
        verbose_name=_('Бекенд сверки ролей')
    )
    emails = models.CharField(max_length=255, blank=True, null=True, verbose_name=_('Уведомлять по адресам'))
    auth_factor = models.CharField(
        choices=SYSTEM_AUTH_FACTOR.CHOICES, default=SYSTEM_AUTH_FACTOR.TVM, max_length=10,
        verbose_name=_('Метод аутентификации')
    )
    tvm_id = models.CharField(max_length=255, blank=True, default='', verbose_name=_('ID TVM-приложения'))
    check_certificate = models.BooleanField(default=True, verbose_name=_('Проверять сертификат'))
    use_requests = models.BooleanField(default=False, verbose_name=_('Использовать requests'))
    is_sox = models.BooleanField(default=False, verbose_name=_('SOX'))
    use_webauth = models.BooleanField(default=False, verbose_name=_('Использовать webauth'))
    use_mini_form = models.BooleanField(default=False, verbose_name=_('Использовать мини-форму'))
    description = models.TextField(blank=True, default='', verbose_name=_('Описание с wiki'))
    description_en = models.TextField(blank=True, default='', verbose_name=_('Описание с wiki (на английском)'))
    description_text = models.TextField(blank=True, default='', verbose_name=_('Описание без wiki'))
    description_text_en = models.TextField(
        blank=True, default='', verbose_name=_('Описание без wiki (на английском)')
    )
    base_url = models.CharField(max_length=255, null=True, blank=True)
    plugin_type = models.CharField(
        max_length=64, choices=SYSTEM_PLUGIN_TYPE.CHOICES, default=SYSTEM_PLUGIN_TYPE.GENERIC
    )
    node_plugin_type = models.CharField(
        max_length=64, choices=SYSTEM_NODE_PLUGIN_TYPE.CHOICES, null=True, blank=True,
    )
    root_role_node = StrictForeignKey('RoleNode', null=True, related_name='+', blank=True,
                                      verbose_name=_('Корневой узел дерева ролей'), on_delete=models.SET_NULL)
    sync_interval = models.DurationField(default=timezone.timedelta(seconds=60 * 60),
                                         verbose_name=_('Интервал синхронизации'))
    endpoint_timeout = models.IntegerField(null=False, blank=False, default=60,
                                           verbose_name=_('Таймаут ожидаемо быстрых ручек системы (в секундах)'))
    endpoint_long_timeout = models.IntegerField(null=False, blank=False, default=60,
                                                verbose_name=_('Таймаут ожидаемо медленных ручек системы (в секундах)'))
    active_nodes_count = models.IntegerField(verbose_name=_('Количество активных узлов в системе'),
                                             default=-1, editable=False, db_index=True)
    active_roles_count = models.IntegerField(verbose_name=_('Количество активных ролей в системе'),
                                             default=-1, editable=False)
    roles_review_days = models.IntegerField(verbose_name=_('Срок пересмотра ролей в днях'),
                                            default=settings.IDM_DEFAULT_REVIEW_ROLES_DAYS)
    max_approvers = models.IntegerField(verbose_name=_('Максимальное количество подтверждающих'), default=200)
    roles_tree_url = models.CharField(max_length=255, null=True, blank=True,
                                      verbose_name=_('Ссылка на дерево ролей в формате YAML'))

    use_batch_requests = models.BooleanField(verbose_name=_('Использовать batch-запросы'), default=False)

    push_batch_size = models.IntegerField(verbose_name=_('Размер батча при пуше членств'), default=1)

    systems_allowed_in_workflow = models.ManyToManyField(
        'self', symmetrical=False, related_name='systems_that_can_use_this_in_workflow', blank=True,
    )
    workflow_execution_method = models.CharField(
        max_length=20,
        choices=SYSTEM_WORKFLOW_EXECUTION_METHOD.CHOICES,
        default=SYSTEM_WORKFLOW_EXECUTION_METHOD.SANDBOXED,
        verbose_name=_('Метод исполнения workflow'),
    )
    workflow_python_version = models.CharField(
        max_length=20,
        choices=SYSTEM_WORKFLOW_INTERPRETER.CHOICES,
        default=SYSTEM_WORKFLOW_INTERPRETER.PYTHON_2_7,
        verbose_name=_('Версия интерпретатора в песочнице'),
    )
    use_tvm_role = models.BooleanField(default=False, verbose_name=_('Можно запрашивать роли на tvm-приложения'))
    with_inheritance = models.BooleanField(default=True, verbose_name=_('Будет ли роль выдана вложенным департаментам'))
    with_external = models.BooleanField(default=True, verbose_name=_('Будет ли роль выдана внешним'))
    with_robots = models.BooleanField(default=True, verbose_name=_('Будет ли роль выдана роботам'))
    retry_failed_roles = models.BooleanField(
        default=False,
        verbose_name=_('Нужно ли бесконечно пытаться добавлять роль')
    )

    inconsistencies_for_break = models.IntegerField(
        null=True,
        blank=True,
        verbose_name=_('Количество расхождений для поломки системы'),
    )
    use_workflow_for_deprive = models.BooleanField(default=False, verbose_name=_('Использовать workflow на отзыв'))
    push_uid = models.BooleanField(default=True, verbose_name=_('отдавать uid в system.plugin.add/remove_role()'))
    export_to_tirole = models.BooleanField(default=False, verbose_name=_('Выгружать роли в Tirole'))
    tvm_tirole_ids: Optional[List[int]] = ArrayField(
        models.IntegerField(),
        blank=True,
        null=True,
        verbose_name=_('TVM ids for tirole'),
    )
    review_required_default = models.BooleanField(_('Политика ревью для узлов по умолчанию'), null=True)

    objects = SystemManager()

    class Meta:
        ordering = ('name',)
        verbose_name = _('Система')
        verbose_name_plural = _('Системы')
        db_table = 'upravlyator_system'

    def __str__(self):
        return self.name

    def get_self(self):
        """Хак для слабых ссылок"""
        return self

    def get_state(self):
        return get_system_state(self)

    def get_human_state(self):
        return dict(SYSTEM_STATE.CHOICES)[self.get_state()]

    def get_absolute_url(self):
        return '/system/%s/' % self.slug

    def get_responsibles(self):
        from idm.core.models import InternalRoleUserObjectPermission

        internal_role_user_object_permissions = InternalRoleUserObjectPermission.objects.filter(
            content_object__node__value_path='/',
            content_object__role='responsible',
            content_object__node__system=self,
        )
        users = User.objects.filter(id__in=internal_role_user_object_permissions.values_list('user', flat=True))
        return users

    @property
    def suggest_cache_key(self):
        return 'suggest:%s' % self.slug

    def save(self, reason=None, user=None, force_insert=False, *args, **kwargs):
        self.base_url = self.base_url.strip() if self.base_url else ''

        # В тестах хочется создавать системы с заданным id, поэтому тут есть force_insert
        if self.pk and not force_insert:
            db_obj = System.objects.get(pk=self.pk)

            if self.is_broken and not db_obj.is_broken:  # система сломалась
                reason = reason or ugettext_noop('Система вручную переключена в состояние поломки')
                self.actions.create(action='system_marked_broken', data={'comment': reason}, requester=user)
            elif not self.is_broken and db_obj.is_broken:  # система исцелилась
                reason = reason or ugettext_noop('Система восстановлена')
                self.actions.create(action='system_marked_recovered', data={'comment': reason}, requester=user)

        result = super(System, self).save(*args, force_insert=force_insert, **kwargs)
        return result

    def get_name(self, lang: str = None):
        return self.get_localized_field('name', lang=lang)

    def get_name_localized_dict(self):
        return {
            'en': self.name_en,
            'ru': self.name,
        }

    def get_description(self, lang=None):
        return self.get_localized_field('description', lang=lang)

    def get_description_localized_dict(self):
        return {
            'en': self.description_en,
            'ru': self.description,
        }

    def get_description_text(self, lang=None):
        return self.get_localized_field('description_text', lang=lang)

    def get_tvm_id(self, url: Optional[str] = None) -> Optional[str]:
        tree_url = url or self.roles_tree_url
        if (
                tree_url
                and tree_url.startswith(settings.ARCANUM_REPO_PATH)
        ):
            return settings.ARCANUM_TVM_ID

        return self.tvm_id

    def get_emails(self, fallback_to_reponsibles=False):
        """Возвращает email-рассылки системы
        (или список email-ов ответственных лиц в случае отсутствия рассылок и fallback_to_reponsibles=True)"""
        from idm.core.models import InternalRole

        emails = []
        if self.emails:
            for email_gr in self.emails.split(','):
                for email in email_gr.split(';'):
                    _email = email.strip()
                    if _email:
                        emails.append(_email)
        if not emails and fallback_to_reponsibles:
            internal_responsible_role = InternalRole.objects.filter(role='responsible', node_id=self.root_role_node_id)
            if internal_responsible_role.exists():
                internal_role = internal_responsible_role.get()
                user_ids = set(internal_role.user_object_permissions.values_list('user', flat=True))
                emails = list(
                    User.objects.users().filter(pk__in=user_ids)
                        .order_by('username')
                        .values_list('email', flat=True)
                )
        return emails

    def get_users_with_permissions(self, permissions):
        if isinstance(permissions, str):
            permissions = [permissions]

        permission_pks = [get_permission(permission).pk for permission in permissions]

        users = User.objects.filter(
            Q(internalroleuserobjectpermission__permission_id__in=permission_pks) &
            (
                    Q(internalroleuserobjectpermission__content_object__node__system=self,
                      internalroleuserobjectpermission__content_object__node__level=0) |
                    Q(internalroleuserobjectpermission__content_object__node=None)
            )
        ).distinct()
        return users

    def get_workflow_changed_date(self):
        """Возвращает время последнего изменения workflow для системы"""
        return self.actual_workflow and self.actual_workflow.approved or self.added

    def get_user_workflow_code(self):
        """Возвращает текущий код workflow"""
        return self.actual_workflow.workflow if self.actual_workflow else ''

    def get_group_workflow_code(self):
        """Возвращает текущий код группового workflow"""
        return self.actual_workflow.group_workflow if self.actual_workflow else ''

    def is_operational(self):
        return self.is_active and not self.is_broken

    def is_group_aware(self):
        return self.group_policy in SYSTEM_GROUP_POLICY.AWARE_OF_GROUPS

    def estimate_size(self, qs, cached_fieldname, threshold, use_cache):
        if getattr(self, cached_fieldname) == -1:
            return SYSTEM_CONSTANTS.SIZE_BIG
        if use_cache:
            count = getattr(self, cached_fieldname)
        else:
            count = qs.count()
        if count <= threshold:
            size = SYSTEM_CONSTANTS.SIZE_SMALL
        else:
            size = SYSTEM_CONSTANTS.SIZE_BIG
        return size

    def get_estimated_tree_size(self, use_cache=True):
        return self.estimate_size(
            self.nodes.active(),
            'active_nodes_count',
            SYSTEM_CONSTANTS.SMALL_NODES_THRESHOLD,
            use_cache,
        )

    def get_estimated_size(self, use_cache=True):
        return self.estimate_size(
            self.roles.active(),
            'active_roles_count',
            SYSTEM_CONSTANTS.SMALL_ROLES_THRESHOLD,
            use_cache,
        )

    def is_ready_for_tree_sync(self):
        # max(last_sync_at, last_sync_start_at) + max(sync_interval, min_sync_internal) <= now()

        if self.metainfo.last_sync_nodes_start is None:
            return True

        sync_is_running = self.metainfo.last_sync_nodes_finish is None or self.metainfo.last_sync_nodes_finish <= self.metainfo.last_sync_nodes_start
        if sync_is_running:
            # если синхронизация, судя по данным, идёт, то не считаем систему готовой к новому обновлению до тех пор,
            # пока разница между началом синхронизации и текущим временем не достигнет серьёзной разницы
            interval = timezone.timedelta(seconds=settings.IDM_NODES_SYNC_SEEMS_FAILED_INTERVAL)
            should_sync = timezone.now() - self.metainfo.last_sync_nodes_start >= interval
        else:
            # синхронизация прошла, не назначаем следующую, пока не пройдёт MAX(sync_internal, min_sync_interval) секунд
            interval = max(self.sync_interval, timezone.timedelta(seconds=settings.IDM_MIN_NODES_SYNC_INTERVAL))
            should_sync = timezone.now() - self.metainfo.last_sync_nodes_finish >= interval

        return should_sync

    def is_unchecked(self, since=None):
        return System.objects.unchecked(since=since).filter(pk=self.pk).exists()

    def report_unchecked(self):
        report_problem(
            _('Проблемы со сверкой ролей в системе %s.') % self.get_name(lang='ru'),
            ['emails/service/inconsistency_check_failed.txt'],
            {
                'threshold': settings.IDM_CHECK_AND_RESOLVE_ALERT_THERSHOLD_DAYS,
                'system': self,
            },
            system=self
        )

    def set_broken(self, reason='', context=None):
        """Проставляет системе признак поломки и рассылает письма о данной проблеме"""
        log.info('System "%s" marked as broken: %s', self.slug, reason)
        self.is_broken = True
        self.save(reason=reason)

        # отправляем письмо ответственным лицам о блокировке системы
        if not isinstance(context, dict):
            context = {}
        context['system'] = self

        send_notification(
            _('Поломка в системе %s.') % self.get_name(lang='ru'),
            ['emails/service/system_broken.txt'],
            self.get_emails() + list(settings.EMAILS_FOR_REPORTS),
            context,
        )

    def recover(self, reason='', user=None):
        """Восстанавливает систему"""
        log.info('System "%s" marked as worked: %s', self.slug, reason)
        self.is_broken = False
        self.save(reason=reason, user=user)

    def _fetch_plugin_class(self, plugin_type: str, template_class_str: str):
        dotted_path = plugin_type  # полный путь до класса с именем класса
        if '.' not in plugin_type:  # generic/dumb - имя модуля в idm/plugins
            # dotted_path = 'idm.core.plugins.%s.Plugin' % self.plugin_type
            dotted_path = template_class_str % plugin_type
        plugin_class = import_string(dotted_path)
        return plugin_class(self)

    @property
    def plugin(self):
        """
        Возвращает объект плагина, сопоставленного с системой
        """
        if getattr(self, '_plugin', None) is None:
            self._plugin = self._fetch_plugin_class(
                plugin_type=self.plugin_type,
                template_class_str='idm.core.plugins.%s.Plugin',
            )

        return self._plugin

    @property
    def node_plugin(self):
        """
        Возвращает объект плагина для получения дерева ролей, сопоставленного с системой
        """
        if getattr(self, '_node_plugin', None) is None:
            if self.node_plugin_type is not None:
                self._node_plugin = self._fetch_plugin_class(
                    plugin_type=self.node_plugin_type,
                    template_class_str='idm.core.plugins.nodes.%s.NodesPlugin',
                )
            else:
                self._node_plugin = self.plugin

        return self._node_plugin

    @property
    def status(self):
        """
        Возвращает текущий статус системы в IDM.
        АХТУНГ - работает в риалтайме, надо перевести в celery-task
        """
        if self.is_broken:
            return _('Сломана')
        elif not self.is_active:
            return _('Отключена')
        elif not self.plugin:
            return _('Нет плагина')
        else:
            url = self.base_url
            prefix = 'availability:'
            cached = cache.get(prefix + self.slug)
            if cached is not None:
                return cached

            is_available = True
            try:
                resp = http.head(url, timeout=1)
            except Exception as exc:
                log.warning('System %s is not available: %s', self.name, str(exc))
                is_available = False
            else:
                if resp.status_code not in (200, 403):  # 403 отвечают системы, когда ломимся без сертификата
                    log.warning('System %s is not available: %s', self.name, str(resp))
                    is_available = False

            cache.set(prefix + self.slug, is_available, timeout=(60 * 5))

            return _('Доступна') if is_available else _('Недоступна')

    def is_permitted_for(self, user, permission):
        """Проверяем имеет ли user нужный permission в данной системе
        """
        from guardian.shortcuts import get_objects_for_user
        from idm.core.models import InternalRole

        if isinstance(user, Requester):
            if not user.is_allowed_for(self):
                return False
            user = user.impersonated

        if user.has_perm(permission):
            return True

        # Для систем, только что созданных через API, узлов в дереве self-системы ещё нет, но доступ выдавать требуется.
        # creator - временное поле, обнуляемое через сутки после создания системы,
        # когда все внутренние роли уже проросли
        if self.creator_id and self.creator_id == user.id and permission != 'core.edit_system_extended':
            return True

        return (get_objects_for_user(user, permission, klass=InternalRole, use_groups=False, with_superuser=False).
                filter(node__system=self).exists())

    def get_user_passport_logins(self, user: User, internal_only: bool = False) -> QuerySet[str]:
        """
        Args:
            user (User): пользователь
            internal_only (bool): только логины, созданные IDM
        """

        qs = user.passport_logins
        if internal_only:
            qs = qs.filter(login__startswith='yndx-')
        passport_logins = qs.values_list('login', flat=True).order_by('login')
        if self.passport_policy == 'unique_for_user':
            passport_logins = passport_logins[:1]
        return passport_logins

    def get_available_user_passport_logins(self, user: User, role_node: "RoleNode"):  # noqa
        """
        Вернуть варианты новых паспортных логинов для пользователя с учетом системы
        """
        from idm.core.models import UserPassportLogin

        current_logins = set(self.get_user_passport_logins(user))
        base_login = 'yndx-{username}'.format(username=user.username)

        if self.root_role_node:
            if role_node and role_node.is_requestable():
                logins = UserPassportLogin.objects.generate_logins_by_role_node(base_login, role_node, current_logins)
                if not logins:
                    logins = UserPassportLogin.objects.generate_login_by_system(base_login, self, current_logins)
            else:
                logins = UserPassportLogin.objects.generate_logins_by_roles_tree(base_login, self, current_logins)
            if not logins:
                logins = UserPassportLogin.objects.generate_random(base_login, current_logins)

        else:
            if base_login not in current_logins:
                logins = [base_login]
            else:
                logins = []

        logins = sorted(logins, key=lambda item: (len(item), item))
        if self.passport_policy == 'unique_for_user' and current_logins:
            logins = []
        return logins

    def check_passport_policy(self, subject, node, fields_data=None, direct_passport_login=None):
        assert not direct_passport_login or not fields_data  # одновременно не должны быть выставлены
        subject = subjectify(subject)
        if self.passport_policy != 'unique_for_user':
            return
        if not fields_data:
            fields_data = {}
        if direct_passport_login:
            requested_login = direct_passport_login
        else:
            requested_login = fields_data.get('passport-login')
        if not requested_login:
            # случай необязательного логина
            return
        if not subject.is_user:
            # проверка паспортной политики имеет смысл (пока что?) только для пользовательских ролей
            return
        user = subject.user
        passport_logins = user.passport_logins.filter(roles__system=self)
        if passport_logins.count() > 0 and not passport_logins.filter(login=requested_login).exists():
            # у нас уже есть паспортный логин в этой системе, если хочется новый, то надо упасть с ошибкой
            message = _('В связи с политикой системы "%(system)s" в отношении паспортных логинов у пользователя'
                        ' %(user)s не может быть второго паспортного логина')
            message = message % {
                'system': self.name,
                'user': user.username
            }
            raise PassportLoginPolicyError(message)

    def check_group_policy(self, subject):
        subject = subjectify(subject)
        if subject.is_group and self.group_policy not in SYSTEM_GROUP_POLICY.SUPPORT_GROUP_ROLES:
            raise GroupPolicyError(_('Система "%s" не поддерживает групповые роли') % self.name)

    def deprive_expired_roles(self, deadline=None):
        """Отзывает роли в системе, просроченные на момент deadline.
        Если deadline не указан, то используется текущее время."""
        if not self.is_operational():
            return
        if deadline is None:
            deadline = timezone.now()
        expired_roles = self.roles.filter(expire_at__lte=deadline).filter(
            Q(parent=None, state__in=ROLE_STATE.RETURNABLE_STATES) |
            Q(parent__isnull=False, state='onhold')
        ).select_related('parent')
        for role in expired_roles.iterator():
            with transaction.atomic():
                try:
                    if role.state in ('requested', 'sent'):
                        role.set_state('expired')
                    else:
                        role.set_state(ROLE_STATE.DEPRIVING, transition='expire')
                except Exception:
                    # почему-то не удалось отозвать роль, но
                    # надо попробовать отозвать остальные
                    log.warning('Cannot deprive role %s of %s', role.pk, role.get_subject().get_ident(), exc_info=1)

    def has_auto_updated_nodes(self):
        from idm.core.models import RoleNode

        qs = self.nodes.filter(is_auto_updated=True, state__in=RoleNode.ACTIVE_STATES)
        return qs.exists()

    def natural_key(self):
        return self.slug

    def clone_workflow(self, user):
        """Создаёт и возвращает объект workflow, идентичный текущему workflow системы, но в состоянии "редактируется"
        и для пользователя user"""

        self.fetch_actual_workflow()
        if not self.actual_workflow:
            clone = self.workflows.create(
                user=user,
                state='edit',
                workflow='',
                group_workflow='',
            )
        else:
            clone = self.actual_workflow
            clone.pk = None
            clone.user = user
            clone.approver = None
            clone.comment = ''
            clone.resolution = ''
            clone.approved = None
            clone.state = 'edit'
            clone.save()
            clone.parent = self.actual_workflow
        return clone

    def add_role_async(self, action, distinct_exclude=None, requester=None):
        from idm.core.tasks.roles import RoleAdded
        task_args = {
            'kwargs': {
                'system_id': self.id,
                'action_id': action.id,
                'distinct_exclude': distinct_exclude,
            },
            'countdown': settings.IDM_PLUGIN_TASK_COUNTDOWN,
        }

        if requester and requester.impersonated and requester.impersonated.id == settings.CRM_ROBOT:
            task_args['queue'] = settings.PINCODE_QUEUE
            task_args['countdown'] = settings.PINCODE_COUNTDOWN

        RoleAdded.apply_async(**task_args)
        log.info('RoleAdded task created. Action_id {}'.format(action.id))

    def remove_role_async(self, action, with_push=True):
        from idm.core.tasks.roles import RoleRemoved

        RoleRemoved.apply_async(
            kwargs={
                'slug': self.slug,
                'action_id': action.id,
                'with_push': with_push,
            },
            countdown=settings.IDM_PLUGIN_TASK_COUNTDOWN
        )

    def remove_not_existing_role_async(self, inconsistency, requester, action_data):
        from idm.core.tasks import DepriveInconsistentRole

        DepriveInconsistentRole.apply_async(
            kwargs={
                'inconsistency_id': inconsistency.id,
                'action_data': action_data,
                'requester': requester,
            },
            countdown=settings.IDM_PLUGIN_TASK_COUNTDOWN
        )

    def remove_not_existing_role_and_request_new_async(self, inconsistency, action_data):
        from idm.core.tasks import DepriveInconsistentRoleAndRequestNew

        DepriveInconsistentRoleAndRequestNew.apply_async(
            kwargs={'inconsistency_id': inconsistency.id,
                    'action_data': action_data},
            countdown=settings.IDM_PLUGIN_TASK_COUNTDOWN
        )

    def request_refs_async(self, role, grant_action):
        from idm.core.tasks import RequestRoleRefs

        if role.is_referrable():
            RequestRoleRefs.apply_async(
                kwargs={'action_id': grant_action.id},
                countdown=settings.IDM_PLUGIN_TASK_COUNTDOWN
            )

    def deprive_refs_async(self, role, deprive_action, comment=None):
        from idm.core.tasks import DepriveRoleRefs

        if role.is_referrable():
            DepriveRoleRefs.apply_async(
                kwargs={'action_id': deprive_action.id,
                        'comment': comment},
                countdown=settings.IDM_PLUGIN_TASK_COUNTDOWN
            )

    def synchronize(self, force_update: bool = False, user: User = None, dry_run: bool = False) \
            -> Tuple[bool, RoleNodeQueue, 'Action']:
        from idm.utils.actions import start_stop_actions
        from idm.reports.utils import get_sync_report

        log.info('Started %s synchronization for system %s', 'faked' if dry_run else 'real', self.slug)
        self.fetch_root_role_node()  # noqa
        self.metainfo.last_sync_nodes_start = timezone.now()
        self.metainfo.save(update_fields=('last_sync_nodes_start',))
        result = False
        queue = None
        timestamp = time.mktime(timezone.now().timetuple())
        with log_context(system=self.slug, timestamp=timestamp, action='system synchronization'), \
                start_stop_actions(
                    ACTION.ROLE_TREE_STARTED_SYNC,
                    ACTION.ROLE_TREE_SYNCED,
                    fail_action=ACTION.ROLE_TREE_SYNC_FAILED,
                    extra={'requester': user, 'system': self},
                ) as manager:

            sync_key = manager.start_action
            manager.on_failure(
                lambda _: log.warning('Cannot synchronize role tree of system %s', self.slug, exc_info=True)
            )
            manager.on_failure({'parent': sync_key, 'data': {'status': 1}})
            with log_duration(log, 'Synchronizing node tree for system %s', self.slug):
                with log_duration(log, 'Preparing data for synchronization with system %s', self.slug):
                    self.root_role_node.fetcher.prepare()

                with log_duration(log, 'Fetching data for system %s', self.slug):
                    data = self.root_role_node.fetcher.fetch(self)

                try:
                    if data is not NotImplemented:
                        with log_duration(log, 'Hashing system %s', self.slug):
                            self.root_role_node.rehash()
                            self.root_role_node.refresh_from_db()
                        with log_duration(log, 'Building queue for system %s', self.slug):
                            queue = self.root_role_node.get_queue(data, force_update=force_update, system=self)

                    if queue and not dry_run:
                        with log_duration(log, 'Applying queue for system %s', self.slug):
                            queue.apply(force_update=force_update, user=user, sync_key=sync_key)
                        result = True
                finally:
                    cache.delete(self.suggest_cache_key)

            success_data = get_sync_report(self, sync_key)
            manager.on_success(
                lambda _: log.info('Node tree for system %s synchronized successfully', self.slug)
            )
            manager.on_success({'parent': sync_key, 'data': success_data})

        self.metainfo.last_sync_nodes_finish = timezone.now()
        self.metainfo.save(update_fields=('last_sync_nodes_finish',))
        signals.system_synchronized.send(sender=self, system=self)
        return result, queue, sync_key

    def get_group_ids_with_active_roles(self):
        from idm.core.models import Role

        groups_ids = (
            Role.objects
                .filter(Q(is_active=True) | Q(state=ROLE_STATE.AWAITING), system=self, group__isnull=False)
                .values_list('group__id', flat=True)
        )

        return groups_ids

    def push_activating_group_memberships_async(self, batch_size=None, group=None):
        from idm.core.tasks import ActivateGroupMembershipSystemRelations
        ActivateGroupMembershipSystemRelations.apply_async(
            kwargs={'system_id': self.pk,
                    'batch_size': batch_size,
                    'group_id': group.id if group else None},
            countdown=settings.IDM_PLUGIN_TASK_COUNTDOWN
        )

    def push_depriving_group_memberships_async(self, batch_size=None, force=False):
        from idm.core.tasks import DepriveGroupMembershipSystemRelations
        DepriveGroupMembershipSystemRelations.apply_async(
            kwargs={'system_id': self.pk,
                    'batch_size': batch_size,
                    'force': force},
            countdown=settings.IDM_PLUGIN_TASK_COUNTDOWN
        )

    def push_need_update_group_memberships_async(self, batch_size=None):
        from idm.core.tasks import UpdateGroupMembershipSystemRelations
        UpdateGroupMembershipSystemRelations.apply_async(
            kwargs={'system_id': self.pk,
                    'batch_size': batch_size},
            countdown=settings.IDM_PLUGIN_TASK_COUNTDOWN
        )

    def shutdown(self, requester, action=ACTION.SYSTEM_SHUTDOWN):
        """
        Механизм выключения системы:
        1) Переводим систему в dump-плагин
        2) Отзываем все роли без оповещения системы и владельцев роли
        """
        assert self.is_active is False, 'shutdown() work only for inactive systems'
        self.plugin_type = SYSTEM_PLUGIN_TYPE.DUMB
        self._plugin = None
        self.save(update_fields=['plugin_type'])
        shutdown_action = self.actions.create(requester=requester, action=action)
        roles_to_deprive_or_decline = self.roles.exclude(state__in=ROLE_STATE.ALREADY_INACTIVE_STATES)

        # Отзыв ролей по причине выключения системы не отправляет письма
        roles_to_deprive_or_decline.update(no_email=True)
        # Отзываем от имени робота idm
        robot = User.objects.get_idm_robot()
        roles_to_deprive_or_decline.deprive_or_decline(
            depriver=robot,
            comment=_('Отзыв в связи с удалением системы'),
            bypass_checks=True,
            parent_action=shutdown_action,
            deprive_all=True,
            # Отзывать роли сразу, всё равно пуш в систему не отправим
            force_deprive=True,
        )


@receiver(post_save, sender=System)
def setup_system_when_created(instance, created, *args, **kwargs):
    """Для новых систем добавляем корневой узел дерева ролей"""
    from idm.core.models import RoleNode
    from idm.users.models import User

    if not created:
        return
    save = False
    if instance.root_role_node_id is None:
        root_node = RoleNode.objects.create(
            is_public=True,
            is_auto_updated=False,
            is_key=False,
            slug='',
            system=instance,
            data={},
            level=0,
        )
        instance.root_role_node = root_node
        save = True
    if instance.actual_workflow_id is None:
        robot = User.objects.get_idm_robot()
        initial_workflow = instance.workflows.create(
            user=robot,
            approver=robot,
            comment='',
            resolution='',
            approved=timezone.now(),
            state='approved',
        )
        instance.actual_workflow = initial_workflow
        save = True
    if save:
        instance.save()


@receiver(post_save, sender=System)
def export_system_roles_to_tirole(*, instance: System, created: bool, update_fields: Tuple[str], **_):
    if instance.export_to_tirole and (created or 'export_to_tirole' in (update_fields or ())):
        transaction.on_commit(functools.partial(
            events.add_event,
            event_type=events.EventType.YT_EXPORT_REQUIRED,
            system_id=instance.id,
        ))
