#!/usr/bin/env /opt/rbta/venvs/aldpro-common/bin/python3
import argparse
import json
import logging
import logging.handlers
import os.path
import sys
import time
from contextlib import suppress
from enum import Enum
from json.decoder import JSONDecodeError
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from random import random
import click
import ldap
from ldap.controls.pagedresults import SimplePagedResultsControl
from ldap.ldapobject import LDAPObject
from tqdm import tqdm

BASE_DIR = "/opt/rbta/ad/mgmtportal/api/core"
if BASE_DIR not in sys.path:
    sys.path.insert(0, BASE_DIR)

os.environ["DJANGO_SETTINGS_MODULE"] = "project.settings"


from django.conf import settings  # noqa: E402
from rest_framework.exceptions import ValidationError  # noqa: E402

from aldpro.ROCO.role.logic import RoleLogic  # noqa: E402
from aldpro.ROCO.role.model import RoleModel  # noqa: E402
from aldpro_core.ldap_om.auth.vanilla_auth import KinitAuth  # noqa: E402
from aldpro_logging import Composer, FileLogger, SyslogLogger  # noqa: E402
from aldpro_om2 import Dn, GenericRepo, ScopesEnum  # noqa: E402

from project.constants import (  # noqa: E402
    DELETE_DENIED,
    ORGUNIT_DELETE_WARNING_TEXT,
    ORGUNIT_FORCE_DELETE_WARNING_TEXT,
)
from project.validators import validate_dn  # noqa: E402

APP_PATH = Path("/opt/rbta/migration/aldpro-move-ou/")
APP_PATH.mkdir(exist_ok=True)
LOG_PATH = APP_PATH.joinpath("action.log")

OP_DELETE_UNFINISHED_PATH = APP_PATH.joinpath("delete-ou.json")
OP_MOVE_UNFINISHED_PATH = APP_PATH.joinpath("move-ou.json")

OP_MODRDN_AVG_TIME_PER_OB = 0.012  # эмпирика - среднее значение времени на каждый объект
OP_RETRY_SEC = 1  # через 1 с ведем опрос о результате
OB_TOTAL_LIMIT = 100_000
OB_BLOCK_TOTAL_PERCENTAGE = 90
OB_COMPLETED_AVG = int(OP_RETRY_SEC / OP_MODRDN_AVG_TIME_PER_OB)

MOVE_OU_UNEXPECTED_BEHAVIOUR_TEXT = (
    "Примечание: ошибка может быть связана с несоответствием параметров 389ds "
    'и масштаба выполняемой операции. Ознакомьтесь с разделом "Troubleshooting" соответствующей инструкции.'
)
DN_NOT_FOUND_ERROR_TEXT = "DN {} не найден."


class ProgressBar:
    """Ручное управление tqdm.tqdm."""

    def __init__(self, tqdm_bar: Optional[tqdm] = None, fixed: bool = True) -> None:
        self.bar = tqdm_bar
        self.fixed = fixed
        self.ob_total = 100

    def post_init(self, ob_total: int, ob_completed: int) -> None:
        """Инициализация pbar с начальными ob_total, ob_completed."""
        if not self.bar:
            return
        self.ob_total = ob_total if ob_total > 0 else 1
        self.update(ob_completed)

    def slowly_increase(self, n: float) -> None:
        """Плавно свдинуть pbar"""
        if not self.bar:
            return
        last_n = self.bar.last_print_n
        diff = n - last_n
        if diff > 25:
            while self.bar.n < (last_n + n):
                self.bar.refresh()
                fluct = random()
                self.bar.n += fluct
                time.sleep(0.005)
                if self.bar.n > self.bar.total:
                    self.bar.n = self.bar.total
        else:
            self.bar.n += n

        self.bar.refresh()

    def update(self, ob_completed: int = 1) -> None:
        """Сдвинуть pbar с учетом обработанных объектов ob_completed."""
        if not self.bar:
            return

        n = 100 * ob_completed / self.ob_total
        if self.fixed and (self.bar.n + n) > OB_BLOCK_TOTAL_PERCENTAGE:
            self.slowly_increase(OB_BLOCK_TOTAL_PERCENTAGE)
        else:
            self.slowly_increase(n)

    def done(self) -> None:
        """Заполнить pbar до конца."""
        if not self.bar:
            return
        while self.bar.n < self.bar.total:
            if self.bar.n < OB_BLOCK_TOTAL_PERCENTAGE:
                self.bar.refresh()
                fluct = random()
                self.bar.n += fluct
                time.sleep(0.005)
            else:
                self.bar.refresh()
                fluct = random()
                self.bar.n += fluct
                time.sleep(0.1)

        self.bar.n = self.bar.total
        self.bar.refresh()


class ProgressBarLoggingHandler(logging.Handler):
    def __init__(self, bar: tqdm, level=logging.NOTSET) -> None:
        self.bar = bar
        super().__init__(level)

    def emit(self, record) -> None:
        try:
            msg = self.format(record)
            self.bar.write(msg)
            self.flush()
        except Exception:
            self.handleError(record)


DEBUG = True if os.getenv("DEBUG") == "True" else False

stdout_log_level = "DEBUG" if DEBUG else "INFO"

logger = Composer("aldpro-move-ou").join(FileLogger, logging.INFO, max_bytes=1000).join(SyslogLogger, logging.WARNING)


SUPERIORS_FOR_DELETE = [
    "cn=hostgroups,cn=accounts",
    "cn=groups,cn=accounts",
    "cn=computers,cn=accounts",
    "cn=users,cn=accounts",
    "cn=roles,cn=accounts",
    "cn=gprules,cn=gp",
    "cn=swprules,cn=swp",
]

RELATION_ATTR_MAPPING = {
    "cn=hostgroups,cn=accounts": "rbtadp",
    "cn=groups,cn=accounts": "rbtadp",
    "cn=computers,cn=accounts": "rbtadp",
    "cn=users,cn=accounts": "rbtadp",
    "cn=roles,cn=accounts": "rbtadp",
    "cn=gprules,cn=gp": "member",
    "cn=swprules,cn=swp": "member",
}


class DeleteOrgunitActionState(str, Enum):
    CREATE = "create"
    REMOVE_RELATION = "remove_relation"
    REMOVE_ORGUNIT = "remove_orgunit"
    DONE = "done"


class RenameOrgunitActionState(str, Enum):
    CREATE = "create"
    DEACTIVATE_ROLES = "deactivate_roles"
    RENAME_OU = "rename_ou"
    ACTIVATE_ROLES = "activate_roles"
    DONE = "done"


class RenameOrgunitValidationError(Exception):
    """Ошибка связанная с переименованием подразделения."""


class DeleteOrgunitValidationError(Exception):
    """Ошибка связанная с удалением подразделения."""


class MoveOuApplicationError(Exception):
    """Ошибка aldpro-move-ou."""


class DnError(RenameOrgunitValidationError):
    def __init__(self, msg: str, arg_value: str) -> None:
        super().__init__(msg)
        self.msg = msg
        self.arg_value = arg_value


class MoveOuApplicationParamError(MoveOuApplicationError):
    def __init__(self, msg: str, arg_name: str) -> None:
        super().__init__(msg)
        self.msg = msg
        self.arg_name = arg_name

    def __str__(self) -> str:
        return f"--{self.arg_name}: {self.msg}"


def search_with_no_controls(
    conn: LDAPObject,
    base: str,
    scope: ScopesEnum,
    filterstr: str,
    attrlist: List[str],
    sizelimit: int = 2000,
) -> List[Tuple[str, Dict[str, List[bytes]]]]:
    """Поиск записей без ldap.controls."""
    result: List[Tuple[str, Dict[str, List[bytes]]]] = []
    msg_id = conn.search_ext(
        base=base,
        scope=scope,
        filterstr=filterstr,
        timeout=-1,
        sizelimit=sizelimit,
        attrlist=attrlist,
    )

    while True:
        try:
            objtype, entries_batch, _, _ = conn.result3(msg_id, all=0)
        except ldap.SIZELIMIT_EXCEEDED:
            break
        if objtype == ldap.RES_SEARCH_RESULT:
            break
        if entries_batch:
            result.extend(entries_batch)

    return result


def search_with_spr(
    conn: LDAPObject,
    base: str,
    scope: ScopesEnum,
    filterstr: str,
    attrlist: List[str],
    page_size: int = 1000,
) -> List[Tuple[str, Dict[str, List[bytes]]]]:
    """Поиск записей с использованием SimplePagedResultsControl."""
    control = SimplePagedResultsControl(True, size=page_size, cookie="")

    result: List[Tuple[str, Dict[str, List[bytes]]]] = []
    pages = 0
    while True:
        msg_id = conn.search_ext(
            base=base,
            scope=scope,
            serverctrls=[control],
            filterstr=filterstr,
            attrlist=attrlist,
        )
        _, entries, _, response_ctrls = conn.result3(msg_id, all=1)

        result.extend(entries)
        pages += 1

        cookie = None
        for ctrl in response_ctrls:
            if ctrl.controlType == SimplePagedResultsControl.controlType:
                cookie = ctrl.cookie
                if not cookie:
                    break
                control.cookie = cookie
                break
        if not cookie:
            break
    return result


def find_dn_s_subtree(conn: LDAPObject, superior: str, filterstr: str) -> List[str]:
    """Поиск полного дерева подразделений вложенных в superior."""
    result = search_with_spr(conn, superior, ScopesEnum.SUBTREE, filterstr, ["1.1"])
    return [entries[0] for entries in result]


def find_entries(
    rbtadps: List[Dn], conn: LDAPObject, attrlist: List[str]
) -> Tuple[Dict[str, List[Tuple[str, Dict[str, List[bytes]]]]], int]:
    """Поиск записей с указанным rbtadps с фильтром eq из контейнеров SUPERIORS_FOR_DELETE."""
    res = {}
    for superior in SUPERIORS_FOR_DELETE:
        relation_attr = RELATION_ATTR_MAPPING[superior]
        res[superior] = []
        data = []
        for rbtadp in rbtadps:
            data = search_with_spr(
                conn,
                f"{superior},{settings.BASE_DN}",
                ScopesEnum.LEVEL,
                f"({relation_attr}={rbtadp})",
                attrlist,
            )
            res[superior].extend(data)
    total_entries = 0
    for key, _ in res.items():
        total_entries += len(res[key])
    logger.debug(f"Всего записей {total_entries}")
    return res, total_entries


class ActionState:
    def __init__(self, file: Path) -> None:
        self.file = file
        self.data: Dict[str, Any] = {}
        self.__create_file()

    def __create_file(self) -> None:
        if not self.file.exists():
            self.file.parent.mkdir(exist_ok=True)
            self.file.touch()

    def load(self) -> None:
        with self.file.open("r") as f:
            with suppress(JSONDecodeError):
                self.data = json.load(f)

    def save(self) -> None:
        with self.file.open("w") as f:
            json.dump(self.data, f, indent=2)

    def get_action(self, key: str) -> Optional[Dict[str, Any]]:
        self.load()

        return self.data.get(key, None)

    def set_action(self, key: str, action_data: Dict[str, Any]) -> None:
        self.load()
        self.data[key] = action_data
        self.save()

    def delete_action(self, action_key: str) -> None:
        self.load()
        self.data.pop(action_key, None)
        self.save()


class RenameOrgunitAction:
    """Переименование подразделения."""

    REQUIRED_SUPERIOR = "cn=orgunits"

    def __init__(
        self,
        dn: Dn,
        newrdn: Dn,
        newsuperior: Optional[Dn],
        repo: GenericRepo,
        role_logic: RoleLogic,
        action_state_manager: ActionState,
        ob_domain_total: int,
        pbar: Optional[ProgressBar] = None,
    ) -> None:
        self.dn = dn
        self.newrdn = newrdn
        self.newsuperior = newsuperior
        self.repo = repo
        self.role_logic = role_logic
        self.action_state_manager = action_state_manager
        self.pbar = pbar or ProgressBar()
        self.ob_domain_total = ob_domain_total

        # Состояние операции
        self._state = None
        self._rbtadps: List[str] = []
        self._role_dn_s: Optional[List[str]] = None
        self.msgid: Optional[int] = None
        self._ob_rename_total: Optional[int] = None
        self._ob_rename_done_total: int = 0
        self._op_new = True
        # Объекты ролей
        self._roles: List[RoleModel] = []

    @property
    def new_dn(self) -> Dn:
        if self.newsuperior:
            return Dn(f"{self.newrdn},{self.newsuperior}")
        return Dn(f"{self.newrdn},{self.dn.superior}")

    def validate_new_dn(self) -> None:
        if not self.new_dn.endswith(settings.LDAP_ORGUNITS_ROOT_OU):
            raise RenameOrgunitValidationError("Нельзя переместить выше корневого.")

        if self.repo.locate(self.new_dn):
            raise RenameOrgunitValidationError(f"DN {self.new_dn} уже существует.")

    def validate_newrdn(self) -> None:
        if self.newrdn.rdn_name != "ou":
            raise RenameOrgunitValidationError("Новый RDN подразделения некорректен")

    def validate_new_superior(self) -> None:
        if self.newsuperior:
            if self.REQUIRED_SUPERIOR not in self.newsuperior:
                raise DnError(DN_NOT_FOUND_ERROR_TEXT.format(self.newsuperior), self.newsuperior)

            if not self.repo.locate(self.newsuperior):
                raise DnError(DN_NOT_FOUND_ERROR_TEXT.format(self.newsuperior), self.newsuperior)

            if self.newsuperior.endswith(self.dn):
                raise RenameOrgunitValidationError("Ошибка выбора родителя.")

    def validate(self) -> None:
        """Валидация входных данных."""
        if self.dn == settings.LDAP_ORGUNITS_ROOT_OU:
            raise RenameOrgunitValidationError("Нельзя отредактировать базовый OU.")

        try:
            _, ou_data = search_with_no_controls(
                self.repo.conn,
                self.dn,
                ScopesEnum.BASE,
                "(objectClass=*)",
                ["aldproObjectProtected"],
            )[0]
        except ldap.NO_SUCH_OBJECT:
            raise DnError(DN_NOT_FOUND_ERROR_TEXT.format(self.dn), self.dn)

        protected = ou_data.get("aldproObjectProtected")

        if protected and protected[0].decode() == "TRUE":
            raise RenameOrgunitValidationError(
                "Невозможно переместить защищённое подразделение. "
                "Для продолжения необходимо снять защиту от случайного удаления."
            )

        self.validate_newrdn()
        self.validate_new_superior()
        self.validate_new_dn()

    def load_state(self) -> None:
        """Загрузка состояния операции."""
        data = self.action_state_manager.get_action(self.dn)

        if data:
            self._state = data.get("state")
            self._role_dn_s = data.get("roles")
            self._rbtadps = data.get("rbtadps")
            self.newrdn = data.get("newrdn")
            self.newsuperior = data.get("newsuperior")
            self.msgid = data.get("msgid")
            self._ob_rename_total = data.get("ob_rename_total")
            self._ob_rename_done_total = data.get("ob_rename_done_total")
            self._op_new = False
            logger.info(f"Продолжение переименования {self.dn}")
        else:
            self._state = RenameOrgunitActionState.CREATE

    def dump_state(self) -> None:
        """Сохранение текущего состояние операции."""
        state_dict = {
            "state": self._state,
            "roles": self._role_dn_s,
            "rbtadps": self._rbtadps,
            "newrdn": self.newrdn,
            "newsuperior": self.newsuperior,
            "msgid": self.msgid,
            "ob_rename_total": self._ob_rename_total,
            "ob_rename_done_total": self._ob_rename_done_total,
        }
        self.action_state_manager.set_action(self.dn, state_dict)

    def get_rbtadps(self) -> List[str]:
        """Получение списка подразделений."""
        if not self._rbtadps:
            self._rbtadps = find_dn_s_subtree(self.repo.conn, self.dn, "(objectClass=rbta-org-unit)")
        return self._rbtadps

    def get_roles(self) -> List[RoleModel]:
        """Получение объектов ролей."""
        if self._roles:
            return self._roles

        if self._role_dn_s:
            for role_dn in self._role_dn_s:
                self._roles.append(self.role_logic.worker.get(full_dn=role_dn))

        return self._roles

    def init_data(self) -> None:
        """
        Получение затрагиваемых объектов для переименования подразделения:
            - список dn ролей;
            - список подразделений;
            - кол-во затрагиваемых объектов.
        """
        self._rbtadps = self.get_rbtadps()
        self._role_dn_s = None
        roles = []
        for _rbtadp in self._rbtadps:
            data = search_with_spr(
                self.repo.conn,
                f"cn=roles,cn=accounts,{settings.BASE_DN}",
                ScopesEnum.LEVEL,
                f"(rbtadp={_rbtadp})",
                ["1.1"],
            )
            roles.extend([entry_data[0] for entry_data in data])

        if roles:
            self._role_dn_s = roles

        if self.ob_domain_total > OB_TOTAL_LIMIT:
            self._ob_rename_total = self.ob_domain_total
        else:
            _, _total_entries = find_entries(self._rbtadps, self.repo.conn, ["1.1"])
            self._ob_rename_total = len(self._rbtadps) + _total_entries + 2 * len(roles)

    def init_pbar(self) -> None:
        """Определение границ прогресс-бара."""
        self.pbar.post_init(self._ob_rename_total, self._ob_rename_done_total)

    def execute(self) -> None:
        """Выполнение деактивации ролей, переименования подразделения, активации ролей."""
        logger.info(f"Изменение {self.dn} на {self.new_dn}")

        self.action_state_manager.load()
        if self.action_state_manager.data:
            logger.warning("Есть незавершенные операции...")

        self.load_state()

        while self._state != RenameOrgunitActionState.DONE:
            if self._state == RenameOrgunitActionState.CREATE:
                self.validate()
                self.init_data()
                self._state = RenameOrgunitActionState.RENAME_OU
                self.dump_state()

            self.init_pbar()

            if self._state == RenameOrgunitActionState.RENAME_OU:
                try:
                    self.repo.conn.modify_s(
                        self.dn, [(ldap.MOD_REPLACE, "displayName", self.new_dn.rdn_value.encode("utf-8"))]
                    )
                    msgid = self.repo.conn.rename(self.dn, self.newrdn, self.newsuperior)
                    self.msgid = msgid
                except ldap.NO_SUCH_OBJECT:
                    logger.debug(f"{self.dn} already renamed")
                self.dump_state()
                self.is_rename_done()

                self._state = RenameOrgunitActionState.DEACTIVATE_ROLES
                self.dump_state()

            self.pbar.fixed = False

            if self._state == RenameOrgunitActionState.DEACTIVATE_ROLES:
                self.deactivate_roles()
                self._state = RenameOrgunitActionState.ACTIVATE_ROLES
                self.dump_state()

            if self._state == RenameOrgunitActionState.ACTIVATE_ROLES:
                self.activate_roles()
                self.action_state_manager.delete_action(self.dn)
                self._state = RenameOrgunitActionState.DONE

        self.pbar.done()

        logger.info("Операция завершена.")

    def is_rename_done(self) -> None:
        """Проверяет статус работы RI Plugin'а."""
        delay = get_referint_update_delay(self.repo.conn)
        if delay == 0:
            self.is_rename_done_sync_mode(self.msgid)
        else:
            self.is_rename_done_async_mode()

    def report_progress(self, n: int) -> None:
        if (self._ob_rename_done_total + n) < int(OB_BLOCK_TOTAL_PERCENTAGE * self._ob_rename_total / 100):
            # Обновляем pbar
            self.pbar.update(n)
            # Обновляем файл состояния
            self._ob_rename_done_total += n
            self.dump_state()
        else:
            self.pbar.update()

    def polling_result(self, msg_id: int) -> Any:
        """Опрос результата операции."""
        done = False
        results = (None, None)
        while not done:
            try:
                # Если timeout=0, то result = (None, None), если результат не готов
                results = self.repo.conn.result(msg_id, all=1, timeout=0)
            except ldap.LDAPError as exc:
                logger.debug(f"got critical error, e={str(exc)}")
                raise exc
            if results == (None, None):
                logger.debug("got empty results, check result again")
                time.sleep(OP_RETRY_SEC)
                self.report_progress(OB_COMPLETED_AVG)
            else:
                done = True
                logger.debug(f"got results: {results}")
                time.sleep(OP_RETRY_SEC)
                self.report_progress(OB_COMPLETED_AVG)
                break
        return results

    def polling_op_rename(self, msg_id: int) -> Any:
        result = self.polling_result(msg_id)
        if result == (ldap.RES_MODRDN, []):
            logger.debug("got rename done, stop polling op_rename")
        else:
            logger.debug("got unexpected result")
            raise MoveOuApplicationError(f"Неизвестный результат операции переименования: {result}")

    def polling_op_search(self, msg_id: int) -> Any:
        result = self.polling_result(msg_id)
        if result == (ldap.RES_SEARCH_RESULT, []):
            logger.debug("got search done, stop polling op_search")
        else:
            logger.debug("got unexpected result")
            raise MoveOuApplicationError(f"Неизвестный результат операции переименования: {result}")

    def is_rename_done_sync_mode(self, msgid: int) -> None:
        logger.debug("use sync mode")
        logger.debug(f"_op_new: {self._op_new}")
        if self._op_new:
            self.polling_op_rename(msgid)
        else:
            try:
                msgid = self.repo.conn.search_ext(
                    base=settings.BASE_DN,
                    scope=ScopesEnum.SUBTREE,
                    filterstr=f"(rbtadp={self.dn})",
                    timeout=1,
                    sizelimit=10,
                    attrlist=["1.1"],
                )
            except ldap.SUCCESS:
                logger.debug("got ldap success")
                return
            else:
                self.polling_op_search(msgid)

    def is_rename_done_async_mode(self) -> None:
        """Определение конца работы RI Plugin'а."""
        rbtadps = self.get_rbtadps().copy()
        base_len_entries = 10
        count_times = 0
        last_entries_len = 0
        logger.debug("use async mode")
        while rbtadps:
            rbtadp = rbtadps[-1]
            try:
                entries = search_with_no_controls(
                    self.repo.conn,
                    settings.BASE_DN,
                    ScopesEnum.SUBTREE,
                    f"(rbtadp={rbtadp})",
                    ["1.1"],
                    sizelimit=base_len_entries,
                )
            except (ldap.TIMELIMIT_EXCEEDED, ldap.TIMEOUT) as exc:
                logger.debug(f"got {exc} when search for {rbtadp}: maybe referint work?")
                self.report_progress(OB_COMPLETED_AVG)
                continue

            if not entries:
                logger.debug(f"got no entires for rbtadp={rbtadps[-1]}")
                last_entries_len = 0
                rbtadps.pop()

            entries_len = len(entries)

            if entries_len == base_len_entries:
                logger.debug(f"got {len(entries)} entires. referint is working... continue polling")
                time.sleep(OP_RETRY_SEC)
                self.report_progress(OB_COMPLETED_AVG)
                continue

            if 0 < entries_len < base_len_entries:
                if last_entries_len != entries_len:
                    last_entries_len = entries_len
                    count_times = 0
                else:
                    if count_times > 4:
                        logger.debug(f"got retry search {count_times} for {rbtadps[-1]}, is referint fine?")
                        break
                    count_times += 1
                time.sleep(OP_RETRY_SEC)

    def change_config(self) -> None:
        """Изменение конфига ролей."""
        for role in self.get_roles():
            try:
                self.role_logic.cn = role.cn
                if role.rbtadp == self.dn:
                    rbtadp = self.new_dn
                else:
                    rbtadp = role.rbtadp.replace(self.dn, self.new_dn)
                self.role_logic.update(attrs={"rbtadp": rbtadp, "ou_nested": role.ou_nested})
            except Exception as exc:
                logger.info(f"Не удалось изменить конфиг у роли {role.cn}: {exc}")

    def deactivate_roles(self) -> None:
        """Деактивация ролей."""
        for role in self.get_roles():
            self.pbar.update()
            if role.render_status in ("done", "error"):
                logger.debug(f"Деактивирую роль {role.cn}")
                try:
                    self.role_logic.cn = role.cn
                    self.role_logic.load()
                    self.role_logic.deactivate()
                    self.role_logic.worker.update(role.cn, render_status="editing")
                except Exception as exc:
                    logger.info(f"Не удалось деактивировать роль {role.cn}: {exc}")

    def activate_roles(self) -> None:
        """Активация ролей."""
        self.change_config()
        for role in self.get_roles():
            self.pbar.update()
            if role.render_status in ("done", "error"):
                logger.debug(f"Активирую роль {role.cn}")
                try:
                    self.role_logic.cn = role.cn
                    self.role_logic.worker.update(role.cn, render_status="pending")
                    self.role_logic.process_pending()
                except Exception as exc:
                    logger.info(f"Не удалось активировать роль {role.cn}: {exc}")


class DeleteOrgunitAction:
    """Удаление подразделения."""

    REQUIRED_SUPERIOR = "cn=orgunits"

    def __init__(
        self,
        dn: Dn,
        repo: GenericRepo,
        action_state_manager: ActionState,
        force: bool = False,
        pbar: Optional[ProgressBar] = None,
    ) -> None:
        self.dn = dn
        self.repo = repo
        self.logger = logger
        self._force = force
        self._state = None
        self.action_state_manager = action_state_manager
        self.pbar = pbar or ProgressBar()

        self._ob_delete_total: Optional[int] = None
        self._ob_delete_done_total: int = 0
        self._op_new = True

        self._rbtadps = []
        self._entries: Dict[str, List[Tuple[str, Dict[str, List[bytes]]]]] = {}

        self._dump_batch_size = 200

    def validate(self) -> None:
        if self.REQUIRED_SUPERIOR not in self.dn:
            raise DeleteOrgunitValidationError("Нельзя удалить не подразделение.")

        if self.dn == settings.LDAP_ORGUNITS_ROOT_OU:
            raise DeleteOrgunitValidationError("Нельзя отредактировать базовый OU.")

        if not self.repo.locate(self.dn):
            raise DnError(DN_NOT_FOUND_ERROR_TEXT.format(self.dn), self.dn)

    def load_state(self) -> None:
        data = self.action_state_manager.get_action(self.dn)
        if data:
            self._state = data.get("state")
            self._op_new = False
            self._ob_delete_total = data.get("ob_delete_total")
            self._ob_delete_done_total = data.get("ob_delete_done_total")
            logger.info(f"Продолжение удаления {self.dn}")
        else:
            self._state = DeleteOrgunitActionState.CREATE

    def get_rbtadps(self) -> List[str]:
        if not self._rbtadps:
            self._rbtadps = find_dn_s_subtree(self.repo.conn, self.dn, "(objectClass=rbta-org-unit)")
        return self._rbtadps

    def get_entries(self) -> Dict[str, List[Tuple[str, Dict[str, List[bytes]]]]]:
        if not self._entries:
            self._entries, self._total_entries = find_entries(
                self.get_rbtadps(), self.repo.conn, ["aldproObjectProtected"]
            )
        return self._entries

    def init_data(self) -> None:
        """
        Получение затрагиваемых объектов для удаления подразделения:
            - список подразделений;
            - кол-во затрагиваемых объектов.
        """
        self.get_entries()
        if self._op_new:
            self._ob_delete_total = len(self._rbtadps) + self._total_entries

    def init_pbar(self) -> None:
        """Определение границ прогресс-бара."""
        self.pbar.post_init(self._ob_delete_total, self._ob_delete_done_total)
        self.pbar.fixed = False

    def __is_protected(self, value: Optional[List[bytes]]) -> bool:
        if value and value[0].decode() == "TRUE":
            return True
        return False

    def validate_protected(self) -> None:
        rbtadps = search_with_spr(
            self.repo.conn, self.dn, ScopesEnum.SUBTREE, "(objectClass=rbta-org-unit)", ["aldproObjectProtected"]
        )
        for rbtadp in rbtadps:
            if self.__is_protected(rbtadp[1].get("aldproObjectProtected")):
                raise DeleteOrgunitValidationError(DELETE_DENIED)

        entries = self.get_entries()
        for sup in SUPERIORS_FOR_DELETE:
            for entry in entries.get(sup, []):
                if self.__is_protected(entry[1].get("aldproObjectProtected")):
                    raise DeleteOrgunitValidationError(DELETE_DENIED)

    def execute(self) -> None:
        self.load_state()

        while self._state != DeleteOrgunitActionState.DONE:
            if self._state == DeleteOrgunitActionState.CREATE:
                self.validate()
                if not self._force:
                    self.validate_protected()
                self.init_data()
                self._state = DeleteOrgunitActionState.REMOVE_RELATION
                self.dump_state()

            self.init_pbar()

            if self._state == DeleteOrgunitActionState.REMOVE_RELATION:
                self.dump_state()
                self.delete_relations()
                self._state = DeleteOrgunitActionState.REMOVE_ORGUNIT

            if self._state == DeleteOrgunitActionState.REMOVE_ORGUNIT:
                self.dump_state()
                self.delete()
                self.check_state()
                self.action_state_manager.delete_action(self.dn)
                self._state = DeleteOrgunitActionState.DONE

        self.pbar.done()
        logger.info("Операция завершена.")

    def report_progress(self, n: int = 1) -> None:
        self._ob_delete_done_total += n
        self.pbar.update(n)

        if self._ob_delete_done_total % self._dump_batch_size == 0:
            self.dump_state()

    def dump_state(self) -> None:
        self.action_state_manager.set_action(
            self.dn,
            {
                "state": self._state,
                "ob_delete_total": self._ob_delete_total,
                "ob_delete_done_total": self._ob_delete_done_total,
            },
        )

    def check_state(self) -> None:
        entries, _ = find_entries(self.get_rbtadps(), self.repo.conn, ["1.1"])

        for _, value in entries.items():
            for val in value:
                logger.info(f"Не смог удалить: {val[0]}")

    def delete(self) -> None:
        logger.debug(f"Удаляю подразделение {self.dn}")
        rbtadps = search_with_spr(
            self.repo.conn, self.dn, ScopesEnum.SUBTREE, "(objectClass=rbta-org-unit)", ["aldproObjectProtected"]
        )
        rbtadps.sort(key=lambda x: len(x[0]), reverse=True)
        for rbtadp in rbtadps:
            self.delete_entry(rbtadp)

    def delete_relations(self) -> None:
        entries = self.get_entries()
        for sup in SUPERIORS_FOR_DELETE:
            for entry in entries.get(sup, []):
                logger.debug(f"Удаляю {entry[0]}")
                self.delete_entry(entry)

    def _delete_aldpro_object_attr(self, entry: Dict[str, List[str]]) -> None:
        """Удаление атрибута aldproObjectProtected для форсированного удаления."""
        self.repo.conn.modify_s(
            f"{entry[0]}",
            [(ldap.MOD_DELETE, "aldproObjectProtected", None)],
        )

    def delete_entry(self, entry: Dict[str, List[str]]) -> None:
        if self._force and self.__is_protected(entry[1].get("aldproObjectProtected")):
            self._delete_aldpro_object_attr(entry)
        try:
            self.repo.delete(dn=entry[0])
        except Exception as exc:
            logger.info(f"Не удалось удалить {entry[0]}: {exc}")
        self.report_progress()


def parse_args() -> Tuple[str, str, str, str, bool]:
    description = (
        "Утилита для управления подразделениями, поддерживает операции: переименование, перенос, удаление. "
        'Перед использованием утилиты ознакомьтесь с инструкцией "Утилита переименования подразделений. '
        "ВНИМАНИЕ: при работе с КД с >100 тыс. объектов необходимо предварительно сконфигурировать 389ds, "
        "ознакомьтесь с инструкцией."
    )
    parser = argparse.ArgumentParser(add_help=False, description=description)

    parser.add_argument(
        "--dn", help=r"DN целевого подразделения, которое необходимо удалить\переместить\переименовать", required=True
    )
    parser.add_argument(
        "--newrdn",
        help=r"Новый RDN (Relative DN) подразделения, вида \"ou=<orgunit_name>\" (переименование)",
        required=False,
    )
    parser.add_argument(
        "--newsuperior", help=r"DN нового родительского подразделения для целевого (перенос)", required=False
    )
    parser.add_argument(
        "--action",
        help=r"Действие (переименовать\удалить) с целевым подразделением",
        choices=["delete", "rename"],
        required=True,
    )
    parser.add_argument(
        "--force",
        action="store_true",
        default=False,
        help="РИСК. Игнорировать защиту от случайного удаления при удалении подразделения",
    )
    parser.add_argument("-h", "--help", action="help", default=argparse.SUPPRESS, help="Показать справку к утилите")

    args = parser.parse_args()

    return args.dn, args.newrdn, args.newsuperior, args.action, args.force


def parse_input_dn(dn: str) -> Dn:
    try:
        validate_dn(dn)
    except ValidationError as exc:
        raise DnError(str(exc.detail[0]), dn)
    else:
        if '"' in dn or "'" in dn:
            raise RenameOrgunitValidationError(
                "Нельзя переименовать/переместить подразделение содержащее спец. символы \" ' "
                "или использовать данные спец. символы "
                "для нового RDN подразделения"
            )
        return Dn(dn)


def prepare_rename_action_input_data(
    dn: str,
    newrdn: Optional[str],
    newsuperior: Optional[str],
) -> Tuple[Dn, Dn, Optional[Dn]]:
    if not newrdn and not newsuperior:
        raise RenameOrgunitValidationError(
            "Инициализация параметра(ов) --newrdn и --newsuperior "
            "(один из них, или оба одновременно, см. --help для подробностей) обязательна для операции --rename."
        )

    dn = parse_input_dn(dn)

    if newrdn:
        newrdn = parse_input_dn(newrdn)
    else:
        newrdn = Dn(dn.rdn)

    if newsuperior:
        newsuperior = parse_input_dn(newsuperior)

    return dn, newrdn, newsuperior


def init_auth() -> Tuple[GenericRepo, RoleLogic]:
    kinit_auth = KinitAuth(server=settings.LDAP_SERVER, base_dn=settings.BASE_DN)
    kinit_auth.bind()
    kinit_auth.client.base_dn = settings.BASE_DN

    repo = GenericRepo(kinit_auth.client)
    role_logic = RoleLogic(auth_class=kinit_auth)

    return repo, role_logic


def get_num_subordinates(conn: LDAPObject, base: str) -> int:
    _, data = search_with_no_controls(conn, base, ScopesEnum.BASE, "(objectClass=*)", ["numSubordinates"])[0]

    num_subordinates = data.get("numSubordinates")
    if not num_subordinates:
        raise MoveOuApplicationError("Отсутствует роль ALDPRO - Main Administrator")

    num_subordinates = int(num_subordinates[0].decode())
    return num_subordinates


def get_ob_total(conn: LDAPObject) -> int:
    users_total = get_num_subordinates(conn, f"cn=users,cn=accounts,{settings.BASE_DN}")
    groups_total = get_num_subordinates(conn, f"cn=groups,cn=accounts,{settings.BASE_DN}")
    computers_total = get_num_subordinates(conn, f"cn=computers,cn=accounts,{settings.BASE_DN}")
    computer_groups_total = get_num_subordinates(conn, f"cn=hostgroups,cn=accounts,{settings.BASE_DN}")
    return users_total + groups_total + computers_total + computer_groups_total


def get_referint_update_delay(conn: LDAPObject) -> int:
    _, ri_plugin = search_with_no_controls(
        conn,
        "cn=referential integrity postoperation,cn=plugins,cn=config",
        ScopesEnum.BASE,
        "(objectClass=*)",
        ["referint-update-delay"],
    )[0]

    delay = ri_plugin.get("referint-update-delay")
    if not delay:
        raise MoveOuApplicationError("Отсутствует роль ALDPRO - Main Administrator")
    return int(delay[0].decode())


def validate_referint_settings(conn: LDAPObject) -> None:
    validate_main_admin(conn)
    ob_total = get_ob_total(conn)
    if ob_total > OB_TOTAL_LIMIT:
        logger.warning(
            f"ПРЕДУПРЕЖДЕНИЕ. Пользователей, групп пользователей, компьютеров, групп компьютеров {OB_TOTAL_LIMIT} "
            "или более. "
            "Убедитесь, что в плагине 389 referint-update-delay > 0"
        )

        delay = get_referint_update_delay(conn)

        if delay == 0:
            raise MoveOuApplicationError(
                "В каталоге более 100 тыс. объектов, параметр referint-update-delay "
                "не должен быть равен 0 (см. Руководство Администратора ч.2 - Справочные "
                "материалы\\Утилита переименования подразделений)."
            )


def validate_main_admin(conn: LDAPObject) -> None:
    me = conn.whoami_s()[4:]
    main_admin_dn = f"cn=ALDPRO - Main Administrator,cn=roles,cn=accounts,{settings.BASE_DN}"
    has_main_admin = conn.compare_s(me, "memberOf", main_admin_dn)

    if not has_main_admin:
        raise RenameOrgunitValidationError("Отсутствует роль ALDPRO - Main Administrator")


def run(dn: str, newrdn: str, newsuperior: str, action: str, force: bool, bar: tqdm) -> None:  # noqa: C901
    if action == "rename":
        dn, newrdn, newsuperior = prepare_rename_action_input_data(dn, newrdn, newsuperior)

        if "cn=orgunits" in dn:
            repo, role_logic = init_auth()

            validate_referint_settings(repo.conn)
            ob_domain_total = get_ob_total(repo.conn)
            RenameOrgunitAction(
                dn,
                newrdn,
                newsuperior,
                repo,
                role_logic,
                ActionState(OP_MOVE_UNFINISHED_PATH),
                ob_domain_total,
                pbar=ProgressBar(bar),
            ).execute()
        else:
            raise MoveOuApplicationParamError(DN_NOT_FOUND_ERROR_TEXT.format(dn), "dn")
    elif action == "delete":
        dn = parse_input_dn(dn)
        confirm_delete = click.confirm(
            text=ORGUNIT_DELETE_WARNING_TEXT,
            default=False,
            show_default=True,
        )

        if not confirm_delete:
            return

        if force:
            confirm_force_delete = click.confirm(
                text=ORGUNIT_FORCE_DELETE_WARNING_TEXT,
                default=False,
                show_default=True,
            )
            if not confirm_force_delete:
                return

        repo, _ = init_auth()
        validate_main_admin(repo.conn)
        DeleteOrgunitAction(dn, repo, ActionState(OP_DELETE_UNFINISHED_PATH), force, pbar=ProgressBar(bar)).execute()


def main():
    dn, newrdn, newsuperior, action, force = parse_args()

    PARAM_MAPPING = {
        dn: "dn",
        newrdn: "newrdn",
        newsuperior: "newsuperior",
    }

    bar = tqdm(disable=False, total=100, unit="%", desc="Processing", bar_format="{l_bar}{bar}{elapsed}")
    bar_handler = ProgressBarLoggingHandler(bar)
    bar_handler.setLevel(stdout_log_level)
    # Добавляю хэндлер и очищаю первую строку tqdm прогресс-бара для исключения "мигания"
    logger._logger.addHandler(bar_handler)
    sys.stdout.write("\r")

    try:
        run(dn, newrdn, newsuperior, action, force, bar)
    except MoveOuApplicationParamError as exc:
        logger.error(str(exc))
        non_zero_exit(bar)
    except DnError as exc:
        error = str(MoveOuApplicationParamError(exc.msg, PARAM_MAPPING[exc.arg_value]))
        logger.error(str(error))
        non_zero_exit(bar)
    except (DeleteOrgunitValidationError, MoveOuApplicationError, RenameOrgunitValidationError) as exc:
        logger.error(str(exc))
        non_zero_exit(bar)
    except (ldap.UNWILLING_TO_PERFORM, ldap.SIZELIMIT_EXCEEDED, ldap.ADMINLIMIT_EXCEEDED) as exc:
        logger.error(str(exc))
        logger.info(MOVE_OU_UNEXPECTED_BEHAVIOUR_TEXT)
        non_zero_exit(bar)
    except Exception as exc:
        logger.error(f"Ошибка при работе утилиты: {exc}")
        non_zero_exit(bar)
    sys.exit(0)


def non_zero_exit(bar: tqdm):
    bar.leave = False
    bar.close()
    sys.exit(1)


if __name__ == "__main__":
    main()
