from patch.include import *

from collections import defaultdict


# Патч ограничен группами, связанными с CmfAuthLdapPlugin: локальные группы не трогаются.
# Чистятся только прямые строки M2M и RelationCache, без глобального пересчёта связей.
def _value(obj):
    """Вернуть примитивное значение CMF-объекта или Field для сравнения и JSON-отчёта."""
    if hasattr(obj, 'id'):
        obj = obj.id
    if hasattr(obj, 'value'):
        obj = obj.value
    return obj


def _normalize_auth_plugin_ids(auth_plugin_ids):
    """Нормализовать область CLI/API: None означает все LDAP-плагины, строки — список id."""
    if auth_plugin_ids is None:
        return []
    if isinstance(auth_plugin_ids, str):
        auth_plugin_ids = [auth_plugin_ids]

    normalized_ids = []
    for auth_plugin_id in auth_plugin_ids:
        auth_plugin_id = _value(auth_plugin_id)
        for value in str(auth_plugin_id).split(','):
            value = value.strip()
            if value and value not in normalized_ids:
                normalized_ids.append(value)
    return normalized_ids


def _selected_auth_plugins(auth_plugin_ids):
    """Получить выбранные LDAP-плагины и вернуть их параметры для отчёта."""
    filters = []
    if auth_plugin_ids:
        filters.append(['id', 'IN', auth_plugin_ids])

    # Пустой список id — явный режим "все CmfAuthLdapPlugin", а не все группы системы.
    auth_plugins = models.CmfAuthLdapPlugin.list(
        filter=filters,
        fields=['name', 'domain', 'base_dn', 'org_name'],
        order_by=['id'],
        TECHCOM_nocache=True,
    )
    if not auth_plugins:
        raise LookupError('Не найдены CmfAuthLdapPlugin для указанной области очистки')

    return [
        {
            'id': _value(auth_plugin.id),
            'name': _value(auth_plugin.name),
            'domain': _value(auth_plugin.domain),
            'base_dn': _value(auth_plugin.base_dn),
            'org_name': _value(auth_plugin.org_name),
        }
        for auth_plugin in auth_plugins
    ]


def _ldap_group_ids(auth_plugin_ids):
    """Вернуть только LDAP-группы, принадлежащие выбранным CmfAuthLdapPlugin."""
    groups = models.CmfPersonGroup.list(
        filter=['auth_plugin', 'IN', auth_plugin_ids],
        fields=['name', 'auth_plugin'],
        order_by=['id'],
        TECHCOM_nocache=True,
    )
    if not groups:
        raise LookupError('Не найдены LDAP-группы для выбранных CmfAuthLdapPlugin')

    return [
        {
            'id': _value(group.id),
            'name': _value(group.name),
            'auth_plugin_id': _value(group.auth_plugin),
        }
        for group in groups
    ]


def _sort_key(value):
    """Стабильный строковый ключ для детерминированного отчёта и выбора первой строки."""
    return '' if value is None else str(value)


def _collect_duplicate_rows(rows, key_func):
    """Найти дубли по ключу; первую строку в стабильном порядке оставить канонической."""
    grouped_rows = defaultdict(list)
    for row in rows:
        key = tuple(_value(value) for value in key_func(row))
        grouped_rows[key].append(row)

    duplicate_rows = []
    details = []
    for key, key_rows in sorted(
        grouped_rows.items(),
        key=lambda item: tuple(_sort_key(value) for value in item[0]),
    ):
        key_rows = sorted(key_rows, key=lambda row: _sort_key(_value(row.id)))
        # Первую строку оставляем, чтобы патч удалял только очевидные повторные записи.
        if len(key_rows) <= 1:
            continue
        deleted_ids = [_sort_key(_value(row.id)) for row in key_rows[1:]]
        duplicate_rows.extend(key_rows[1:])
        details.append({
            'key': [_sort_key(value) for value in key],
            'kept_id': _sort_key(_value(key_rows[0].id)),
            'deleted_ids': deleted_ids,
        })
    return duplicate_rows, details


def _delete_rows(rows):
    """Удалить уже отобранные дубли через CMF ORM и вернуть количество удалений."""
    deleted = 0
    for row in rows:
        row.delete()
        deleted += 1
    return deleted


def _cleanup_m2m_rows(group_ids, dry_run):
    """Найти дубли прямых M2M-строк LDAP-группа -> пользователь."""
    m2m_model = models.CmfPersonGroup.rg_members.m2m_model_cls()
    # Scope guardrail: только прямое членство CmfPerson в выбранных LDAP-группах.
    filters = [
        ['left_id', 'IN', group_ids],
        ['right_id', 'LIKE', 'CmfPerson:%'],
        ['root_id', '==', None],
    ]
    if hasattr(m2m_model, 'parent_id'):
        filters.append(['parent_id', '==', None])

    rows = m2m_model.list(
        filter=filters,
        fields=['*'],
        order_by=['left_id', 'right_id', 'id'],
        for_update=not dry_run,
        TECHCOM_nocache=True,
    )

    duplicate_rows, details = _collect_duplicate_rows(
        rows,
        lambda row: (
            row.left_id,
            row.right_id,
            row.root_id,
            getattr(row, 'parent_id', None),
        ),
    )

    deleted = 0
    if duplicate_rows and not dry_run:
        deleted = _delete_rows(duplicate_rows)

    return {
        'found': len(duplicate_rows),
        'deleted': deleted,
        'details': details,
    }


def _cleanup_relation_cache_rows(group_ids, dry_run):
    """Найти дубли прямых строк RelationCache для членства в LDAP-группах."""
    # depth=0 ограничивает патч прямыми связями; наследуемые строки вне области исправления.
    rows = models.RelationCache.list(
        filter=[
            ['parent_id', 'IN', group_ids],
            ['parent_model', '==', 'CmfPersonGroup'],
            ['parent_field', '==', 'rg_members'],
            ['child_model', '==', 'CmfPerson'],
            ['child_field', '==', 'rg_member_of'],
            ['depth', '==', 0],
        ],
        fields=['*'],
        order_by=['parent_id', 'child_id', 'id'],
        for_update=not dry_run,
        TECHCOM_nocache=True,
    )

    duplicate_rows, details = _collect_duplicate_rows(
        rows,
        lambda row: (
            row.parent_id,
            row.parent_model,
            row.parent_field,
            row.child_id,
            row.child_model,
            row.child_field,
            row.depth,
        ),
    )

    deleted = 0
    if duplicate_rows and not dry_run:
        deleted = _delete_rows(duplicate_rows)

    return {
        'found': len(duplicate_rows),
        'deleted': deleted,
        'details': details,
    }


def _flush_cache():
    """Сбросить кеш только после фактических удалений, без пересчёта связей."""
    if hasattr(CMF_CACHE, 'flushdb'):
        CMF_CACHE.flushdb()
    else:
        CMF_CACHE.flushall()


def cleanup(auth_plugin_ids=None, dry_run=False):
    """Очистить дубли прямого членства пользователей в LDAP-группах.

    Без auth_plugin_ids область — все группы, принадлежащие CmfAuthLdapPlugin.
    dry_run строит тот же отчёт, но не удаляет строки и не сбрасывает кеш.
    """
    auth_plugin_ids = _normalize_auth_plugin_ids(auth_plugin_ids)

    auth_plugins = _selected_auth_plugins(auth_plugin_ids)
    selected_auth_plugin_ids = [auth_plugin['id'] for auth_plugin in auth_plugins]
    groups = _ldap_group_ids(selected_auth_plugin_ids)
    group_ids = [group['id'] for group in groups]

    # Проверяем оба прямых слоя хранения членства, но не запускаем общий пересчёт связей.
    m2m_report = _cleanup_m2m_rows(group_ids, dry_run)
    relation_cache_report = _cleanup_relation_cache_rows(group_ids, dry_run)

    rows_deleted = m2m_report['deleted'] + relation_cache_report['deleted']
    if rows_deleted:
        _flush_cache()

    return {
        'mode': 'explicit_auth_plugin_ids' if auth_plugin_ids else 'all_auth_plugins',
        'dry_run': dry_run,
        'selected_auth_plugin_ids': selected_auth_plugin_ids,
        'auth_plugins': auth_plugins,
        'groups_scanned': len(groups),
        'groups': groups,
        'm2m_duplicate_rows_found': m2m_report['found'],
        'm2m_duplicate_rows_deleted': m2m_report['deleted'],
        'm2m_duplicates': m2m_report['details'],
        'relation_cache_duplicate_rows_found': relation_cache_report['found'],
        'relation_cache_duplicate_rows_deleted': relation_cache_report['deleted'],
        'relation_cache_duplicates': relation_cache_report['details'],
    }


def _parse_args(argv=None):
    """Разобрать CLI-аргументы ручного патча и нормализовать список LDAP-плагинов."""
    parser = argparse.ArgumentParser(
        description='Очистка дублей прямых связей участников LDAP-групп для TEM-1625051061',
    )
    parser.add_argument(
        '--auth-plugin-id',
        action='append',
        dest='auth_plugin_ids',
        help=(
            'ID CmfAuthLdapPlugin для проверки; можно повторять или передавать через запятую. '
            'Без параметра проверяются все CmfAuthLdapPlugin.'
        ),
    )
    parser.add_argument(
        '--dry-run',
        action='store_true',
        default=False,
        help='Только вывести найденные дубли без удаления',
    )
    args = parser.parse_args(argv)
    args.auth_plugin_ids = _normalize_auth_plugin_ids(args.auth_plugin_ids)
    return args


def _print_report(report):
    """Вывести отчёт патча в JSON для ручной проверки и pytest CLI-сценариев."""
    print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))


@app_context(commit=True)
def patch():
    """
    Для запуска патча: ( cd /opt/eva-app; python3 -m patch.tem_1625051061_ldap_group_member_cleanup )
    options:
    --auth-plugin-id AUTH_PLUGIN_ID   Ограничить очистку конкретным CmfAuthLdapPlugin; можно указать несколько раз
    --dry-run                         Только вывести найденные дубли без удаления
    """
    args = _parse_args()
    report = cleanup(
        auth_plugin_ids=args.auth_plugin_ids,
        dry_run=args.dry_run,
    )
    _print_report(report)


if __name__ == '__main__':
    patch()
