#!/usr/bin/env python3
# cf-edit v1.28 — Edit 1C configuration root (Configuration.xml)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills

import argparse
import functools
import json
import os
import re
import subprocess
import sys
import uuid as _uuid
from html import escape as html_escape
from lxml import etree

# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.

def parse_json_input(text, source, expected=None, inline=False):
    """Разбор пользовательского JSON: одна строка в stderr вместо traceback (issue #80).

    expected заполняем только для полиморфного входа: у файла подсказка
    была бы наполнителем — имя файла и текст парсера самодостаточны. inline печатает ещё и то,
    что доехало: у файла такого вопроса нет, он лежит на диске и его видно целиком.

    Импорты внутри тела: копия функции живёт в навыках с разными именами модулей
    (skd-decompile импортирует json локально как _json), а тело обязано быть одинаковым.
    """
    import json as _pj
    import sys as _psys
    try:
        if not str(text).strip():
            raise ValueError("input is empty")
        return _pj.loads(text)
    except ValueError as exc:
        what = "%s expects %s" % (source, expected) if expected else "Invalid JSON in %s" % source
        if inline:
            got = " ".join(str(text).split())
            label = "got"
            if not got:
                got = "(empty)"
            elif len(got) > 60:
                label = "got (first 60 chars)"
                got = got[:60]
            what = "%s, %s: %s" % (what, label, got)
        print("[ERROR] %s (%s)" % (what, exc), file=_psys.stderr)
        _psys.exit(1)


def read_json_file(path):
    """Чтение входного JSON-файла с кодировкой из BOM (issue #80).

    BOM — объявление самого файла, поэтому ему верим; без BOM ждём строгий UTF-8. Кодовую
    страницу не подбираем: угаданное имя уехало бы в метаданные молча.
    """
    import os as _pos
    import sys as _psys
    if not _pos.path.exists(path):
        print("[ERROR] File not found: %s" % path, file=_psys.stderr)
        _psys.exit(1)
    if _pos.path.isdir(path):
        print("[ERROR] Expected a JSON file, got a directory: %s" % path, file=_psys.stderr)
        _psys.exit(1)
    with open(path, "rb") as _fh:
        data = _fh.read()
    if data[:3] == b"\xef\xbb\xbf":
        return data[3:].decode("utf-8")
    if data[:2] == b"\xff\xfe":
        return data[2:].decode("utf-16-le")
    if data[:2] == b"\xfe\xff":
        return data[2:].decode("utf-16-be")
    try:
        return data.decode("utf-8")
    except UnicodeDecodeError as exc:
        print("[ERROR] %s is not valid UTF-8: %s - save the file as UTF-8, or add a BOM if it is UTF-16"
              % (path, exc), file=_psys.stderr)
        _psys.exit(1)


class CIDict(dict):
    # Ключи храним КАК ЕСТЬ: часть из них — имена объектов (табличные части, стандартные
    # реквизиты), они попадают в XML. Регистронезависим только поиск. Порядок вставки
    # сохраняется — от него зависит порядок эмиссии.
    def _actual(self, key):
        if not isinstance(key, str) or dict.__contains__(self, key):
            return key
        ci = self.__dict__.get('_ci')
        if ci is None or len(ci) != len(self):
            ci = {k.lower(): k for k in self if isinstance(k, str)}
            self.__dict__['_ci'] = ci
        return ci.get(key.lower(), key)

    def __getitem__(self, key):
        return dict.__getitem__(self, self._actual(key))

    def __contains__(self, key):
        return dict.__contains__(self, self._actual(key))

    def get(self, key, default=None):
        return dict.get(self, self._actual(key), default)

    def pop(self, key, *default):
        return dict.pop(self, self._actual(key), *default)

    def __setitem__(self, key, value):
        # запись по ключу, отличающемуся регистром, обновляет существующий, а не плодит дубль
        dict.__setitem__(self, self._actual(key), value)

def ci_json(obj):
    """Рекурсивно оборачивает разобранный JSON: словари → CIDict, списки обходятся."""
    if isinstance(obj, dict):
        return CIDict((k, ci_json(v)) for k, v in obj.items())
    if isinstance(obj, list):
        return [ci_json(v) for v in obj]
    return obj

def ci_parse_args(parser, argv=None):
    """parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
    argv = list(sys.argv[1:] if argv is None else argv)
    names = {s.lower(): s for a in parser._actions for s in a.option_strings}
    for i, tok in enumerate(argv):
        if tok.startswith('-') and tok.lower() in names:
            argv[i] = names[tok.lower()]
    # choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
    choice_map = {}
    for a in parser._actions:
        if a.choices:
            for s in a.option_strings:
                choice_map[s] = {str(c).lower(): c for c in a.choices}
    for i in range(len(argv) - 1):
        m = choice_map.get(argv[i])
        if m and argv[i + 1].lower() in m:
            argv[i + 1] = m[argv[i + 1].lower()]
    return parser.parse_args(argv)



# ============================================================
# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md
# Blocks edits of vendor objects "на замке" / read-only configs. Trigger = bin
# present; reaction from .v8-project.json editingAllowedCheck (deny|warn|off,
# default deny). Never throws (except sys.exit on deny) — errors degrade to allow.
# ============================================================

def _sg_root_uuid(xml_path):
    if not os.path.isfile(xml_path):
        return None
    try:
        mx = etree.parse(xml_path).getroot()
        for child in mx:
            if isinstance(child.tag, str) and child.get("uuid"):
                return child.get("uuid")
    except Exception:
        return None
    return None


def _sg_is_external_root(xml_path):
    if not os.path.isfile(xml_path):
        return False
    try:
        mx = etree.parse(xml_path).getroot()
        for child in mx:
            if isinstance(child.tag, str):
                return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport")
    except Exception:
        return False
    return False

def _sg_find_v8project(start_dir):
    d = start_dir
    for _ in range(20):
        if not d:
            break
        pj = os.path.join(d, ".v8-project.json")
        if os.path.isfile(pj):
            return pj
        parent = os.path.dirname(d)
        if parent == d:
            break
        d = parent
    return None


def _sg_get_edit_mode(cfg_dir):
    try:
        pj = _sg_find_v8project(os.getcwd()) or _sg_find_v8project(cfg_dir)
        if not pj:
            return "deny"
        proj = json.loads(open(pj, encoding="utf-8-sig").read())
        cfg_full = os.path.normcase(os.path.abspath(cfg_dir)).rstrip("\\/")
        for db in proj.get("databases", []):
            src = db.get("configSrc")
            if src:
                src_full = os.path.normcase(os.path.abspath(src)).rstrip("\\/")
                if cfg_full == src_full or cfg_full.startswith(src_full + os.sep):
                    if db.get("editingAllowedCheck"):
                        return db["editingAllowedCheck"]
        if proj.get("editingAllowedCheck"):
            return proj["editingAllowedCheck"]
        return "deny"
    except Exception:
        return "deny"


def assert_edit_allowed(target_path, require):
    try:
        rp = os.path.abspath(target_path)
        # Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
        if _sg_is_external_root(rp):
            return
        elem_uuid = _sg_root_uuid(rp)
        cfg_dir = None
        bin_path = None
        d = rp if os.path.isdir(rp) else os.path.dirname(rp)
        for _ in range(12):
            if not d:
                break
            if _sg_is_external_root(d + ".xml"):
                return
            if not elem_uuid:
                elem_uuid = _sg_root_uuid(d + ".xml")
            if not cfg_dir:
                cand = os.path.join(d, "Ext", "ParentConfigurations.bin")
                if os.path.exists(cand) or os.path.exists(os.path.join(d, "Configuration.xml")):
                    cfg_dir = d
                    bin_path = cand
            if elem_uuid and cfg_dir:
                break
            parent = os.path.dirname(d)
            if parent == d:
                break
            d = parent
        if not elem_uuid and cfg_dir:
            elem_uuid = _sg_root_uuid(os.path.join(cfg_dir, "Configuration.xml"))
        if not bin_path or not os.path.exists(bin_path):
            return
        data = open(bin_path, "rb").read()
        if len(data) <= 32:
            return
        if data[:3] == b"\xef\xbb\xbf":
            data = data[3:]
        text = data.decode("utf-8", "replace")
        h = re.match(r"\{6,(\d+),(\d+),", text)
        if not h:
            return
        g = int(h.group(1))
        k = int(h.group(2))
        if k == 0:
            return
        best = None
        if elem_uuid:
            for m in re.finditer(r"([0-2]),0," + re.escape(elem_uuid.lower()), text):
                f1 = int(m.group(1))
                if best is None or f1 < best:
                    best = f1
        blocked = False
        code = ""
        reason = ""
        if g == 1:
            blocked = True
            code = "capability-off"
            reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)"
        elif require == "removed":
            if best is not None and best != 2:
                blocked = True
                code = "not-removed"
                reason = "объект не снят с поддержки — удаление сломает обновления"
        else:
            if best is not None and best == 0:
                blocked = True
                code = "locked"
                reason = "объект на замке — редактирование сломает обновления"
        if not blocked:
            return
        mode = _sg_get_edit_mode(cfg_dir)
        if mode == "off":
            return
        if mode == "warn":
            sys.stderr.write(f"[support-guard] ПРЕДУПРЕЖДЕНИЕ: {reason}. Цель: {rp}\n")
            return
        head = "[support-guard] Редактирование отклонено: это объект типовой конфигурации на поддержке поставщика, прямое редактирование молча сломает будущие обновления."
        cfe = "Рекомендуемый путь: внести доработку в расширение (навыки cfe-borrow / cfe-patch-method) — состояние поддержки менять не нужно, обновления вендора сохраняются."
        off_note = "Снять проверку для этой базы: editingAllowedCheck = warn|off в .v8-project.json."
        if code == "capability-off":
            state = f"Состояние: у всей конфигурации выключена возможность изменения (режим read-only «из коробки») — поэтому объект «{rp}» редактировать нельзя."
            fix = (
                "Либо снять защиту явно (навык support-edit, два шага):\n"
                f'  1. support-edit -Path "{cfg_dir}" -Capability on — включить возможность изменения (объекты пока остаются на замке);\n'
                f'  2. support-edit -Path "{rp}" -Set editable — открыть этот объект для редактирования.\n'
                "  Изменение применяется в базу полной загрузкой выгрузки и обходит механизм обновлений вендора."
            )
        elif code == "not-removed":
            state = f"Состояние: объект «{rp}» на поддержке (не снят с поддержки) — его удаление разорвёт обновления вендора."
            fix = (
                "Либо сначала снять объект с поддержки, затем удалять:\n"
                f'  support-edit -Path "{rp}" -Set off-support — объект уходит из-под обновлений, после этого удаление безопасно.'
            )
        else:
            state = f"Состояние: объект «{rp}» на замке (возможность изменения конфигурации включена, но сам объект не редактируется)."
            fix = (
                "Либо разрешить редактирование этого объекта (навык support-edit, выбрать одно):\n"
                f'  support-edit -Path "{rp}" -Set editable — редактировать и дальше получать обновления вендора (возможны конфликты слияния);\n'
                f'  support-edit -Path "{rp}" -Set off-support — снять с поддержки: обновления по объекту больше не приходят.'
            )
        sys.stderr.write(head + "\n" + state + "\n" + cfe + "\n" + fix + "\n" + off_note + "\n")
        sys.exit(1)
    except SystemExit:
        raise
    except Exception:
        return


MD_NS = "http://v8.1c.ru/8.3/MDClasses"
XR_NS = "http://v8.1c.ru/8.3/xcf/readable"
XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
V8_NS = "http://v8.1c.ru/8.1/data/core"
XS_NS = "http://www.w3.org/2001/XMLSchema"

# Canonical type order for ChildObjects (46 types)
TYPE_ORDER = [
    "Language", "Subsystem", "StyleItem", "Style",
    "CommonPicture", "SessionParameter", "Role", "CommonTemplate",
    "FilterCriterion", "CommonModule", "CommonAttribute", "ExchangePlan",
    "XDTOPackage", "WebService", "HTTPService", "WSReference",
    "EventSubscription", "ScheduledJob", "SettingsStorage", "FunctionalOption",
    "FunctionalOptionsParameter", "DefinedType", "Bot", "PaletteColor", "CommonCommand", "CommandGroup",
    "Constant", "CommonForm", "Catalog", "Document",
    "DocumentNumerator", "Sequence", "DocumentJournal", "Enum",
    "Report", "DataProcessor", "InformationRegister", "AccumulationRegister",
    "ChartOfCharacteristicTypes", "ChartOfAccounts", "AccountingRegister",
    "ChartOfCalculationTypes", "CalculationRegister",
    "BusinessProcess", "Task", "IntegrationService",
]

# Type → on-disk directory name (plural)
TYPE_TO_DIR = {
    "Language": "Languages", "Subsystem": "Subsystems", "StyleItem": "StyleItems", "Style": "Styles",
    "CommonPicture": "CommonPictures", "SessionParameter": "SessionParameters", "Role": "Roles", "CommonTemplate": "CommonTemplates",
    "FilterCriterion": "FilterCriteria", "CommonModule": "CommonModules", "Bot": "Bots", "PaletteColor": "PaletteColors", "PaletteColor": "PaletteColors", "CommonAttribute": "CommonAttributes", "ExchangePlan": "ExchangePlans",
    "XDTOPackage": "XDTOPackages", "WebService": "WebServices", "HTTPService": "HTTPServices", "WSReference": "WSReferences",
    "EventSubscription": "EventSubscriptions", "ScheduledJob": "ScheduledJobs", "SettingsStorage": "SettingsStorages", "FunctionalOption": "FunctionalOptions",
    "FunctionalOptionsParameter": "FunctionalOptionsParameters", "DefinedType": "DefinedTypes", "CommonCommand": "CommonCommands", "CommandGroup": "CommandGroups",
    "Constant": "Constants", "CommonForm": "CommonForms", "Catalog": "Catalogs", "Document": "Documents",
    "DocumentNumerator": "DocumentNumerators", "Sequence": "Sequences", "DocumentJournal": "DocumentJournals", "Enum": "Enums",
    "Report": "Reports", "DataProcessor": "DataProcessors", "InformationRegister": "InformationRegisters", "AccumulationRegister": "AccumulationRegisters",
    "ChartOfCharacteristicTypes": "ChartsOfCharacteristicTypes", "ChartOfAccounts": "ChartsOfAccounts", "AccountingRegister": "AccountingRegisters",
    "ChartOfCalculationTypes": "ChartsOfCalculationTypes", "CalculationRegister": "CalculationRegisters",
    "BusinessProcess": "BusinessProcesses", "Task": "Tasks", "IntegrationService": "IntegrationServices",
}

ML_PROPS = ["Synonym", "BriefInformation", "DetailedInformation", "Copyright", "VendorInformationAddress", "ConfigurationInformationAddress"]
SCALAR_PROPS = ["Name", "Version", "Vendor", "Comment", "NamePrefix", "UpdateCatalogAddress"]
REF_PROPS = ["DefaultLanguage"]


def get_new_object_position(cfg_dir):
    """Куда навык ставит новую запись в <ChildObjects> — настройка newObjectPosition.

    databases[].newObjectPosition базы, чей configSrc охватывает каталог родительского XML,
    иначе корневое поле, иначе end. Значения: end — после последнего объекта того же вида
    (так дописывает Конфигуратор); byName — по имени среди объектов того же вида.
    Файл ищем от рабочего каталога вверх, каталог конфигурации — запасной путь: так же
    его ищут support-guard и группа db-*, а скрипт навыка зовут по абсолютному пути, и cwd
    остаётся рабочим каталогом проекта.
    configSrc считается от каталога .v8-project.json, как задокументировано в
    docs/v8-project-guide.md. Реестр семьи: tests/skills/check-inline-drift.mjs.
    """
    try:
        pj = _sg_find_v8project(os.getcwd()) or _sg_find_v8project(os.path.abspath(cfg_dir or "."))
        if not pj:
            return "end"
        proj = json.loads(open(pj, encoding="utf-8-sig").read())
        proj_dir = os.path.dirname(pj)
        cfg_full = os.path.normcase(os.path.abspath(cfg_dir or ".")).rstrip("\\/")
        for db in proj.get("databases", []):
            src = db.get("configSrc")
            if src and db.get("newObjectPosition"):
                src_full = os.path.normcase(os.path.abspath(os.path.join(proj_dir, src))).rstrip("\\/")
                if cfg_full == src_full or cfg_full.startswith(src_full + os.sep):
                    return "byName" if str(db["newObjectPosition"]).lower() == "byname" else "end"
        if str(proj.get("newObjectPosition") or "").lower() == "byname":
            return "byName"
        return "end"
    except Exception:
        return "end"


def is_order_sensitive_type(type_name):
    """Виды, у которых порядок в дереве несёт смысл: автоматически их не упорядочиваем.

    CommonAttribute — исключение самого стандарта (#std467): у общих реквизитов-разделителей
    порядок в дереве задаёт порядок установки параметров сеанса. Subsystem и CommandGroup:
    пока они не перечислены в <SubsystemsOrder> / <GroupsOrder> файла Ext/CommandInterface.xml,
    порядок дерева задаёт порядок в интерфейсе, а платформа эти списки сама не заводит
    (в выгрузке ACC вне GroupsOrder 15 живых групп из 39). Language исключён из осторожности,
    без замера: языков обычно один-два, и в типовых их порядок не алфавитный.
    Явно названный вид сортируется в любом случае.
    Реестр семьи: tests/skills/check-inline-drift.mjs.
    """
    return type_name in ("CommonAttribute", "Subsystem", "CommandGroup", "Language")


def compare_metadata_names(a, b):
    """Порядок имён объектов метаданных, как в дереве Конфигуратора.

    Ключ — пары «ранг+символ»: регистр не учитывается, подчёркивание раньше цифр, цифры раньше
    букв, буквы по кодам (латиница раньше кириллицы), ё на месте е. Культурные таблицы не
    используются — они разные на разных ОС и в разных рантаймах, а так оба порта сравнивают
    одинаково везде. Равные ключи разводит ordinal-сравнение исходных строк.
    Возвращает -1 | 0 | 1. Реестр семьи: tests/skills/check-inline-drift.mjs.
    """
    keys = []
    for name in (a, b):
        parts = []
        for ch in name.lower():
            if ch == "ё":
                ch = "е"
            if ch.isdigit():
                parts.append("1" + ch)
            elif ch.isalpha():
                parts.append("2" + ch)
            else:
                parts.append("0" + ch)
        keys.append("".join(parts))
    if keys[0] != keys[1]:
        return -1 if keys[0] < keys[1] else 1
    if a != b:
        return -1 if a < b else 1
    return 0


RU_TYPE_MAP = {
    "справочник": "Catalog", "документ": "Document", "перечисление": "Enum",
    "отчёт": "Report", "отчет": "Report", "обработка": "DataProcessor",
    "общаяформа": "CommonForm", "журналдокументов": "DocumentJournal",
    "планвидовхарактеристик": "ChartOfCharacteristicTypes",
    "плансчетов": "ChartOfAccounts",
    "планвидоврасчета": "ChartOfCalculationTypes",
    "планвидоврасчёта": "ChartOfCalculationTypes",
    "регистрсведений": "InformationRegister",
    "регистрнакопления": "AccumulationRegister",
    "регистрбухгалтерии": "AccountingRegister",
    "регистррасчета": "CalculationRegister",
    "регистррасчёта": "CalculationRegister",
    "бизнеспроцесс": "BusinessProcess",
    "бот": "Bot",
    "задача": "Task", "планобмена": "ExchangePlan",
    "хранилищенастроек": "SettingsStorage",
    # Множественное число: в дереве конфигурации виды подписаны именно так.
    "справочники": "Catalog", "документы": "Document", "перечисления": "Enum",
    "отчёты": "Report", "отчеты": "Report", "обработки": "DataProcessor",
    "общиеформы": "CommonForm", "журналыдокументов": "DocumentJournal",
    "планывидовхарактеристик": "ChartOfCharacteristicTypes",
    "планысчетов": "ChartOfAccounts",
    "планывидоврасчета": "ChartOfCalculationTypes",
    "планывидоврасчёта": "ChartOfCalculationTypes",
    "регистрысведений": "InformationRegister",
    "регистрынакопления": "AccumulationRegister",
    "регистрыбухгалтерии": "AccountingRegister",
    "регистррасчета": "CalculationRegister", "регистрырасчета": "CalculationRegister",
    "регистрырасчёта": "CalculationRegister",
    "бизнеспроцессы": "BusinessProcess",
    "боты": "Bot",
    "задачи": "Task", "планыобмена": "ExchangePlan",
    "хранилищанастроек": "SettingsStorage",
}


def resolve_type_name(token):
    """Имя вида из пользовательского ввода → каноническое имя или None.

    Ввод прощающий: регистр не важен, принимается имя каталога выгрузки
    (Catalogs → Catalog) и русское имя вида в единственном и множественном числе.
    """
    key = (token or "").strip().lower()
    if not key:
        return None
    for canon in TYPE_ORDER:
        if canon.lower() == key:
            return canon
    for canon, dir_name in TYPE_TO_DIR.items():
        if dir_name.lower() == key:
            return canon
    return RU_TYPE_MAP.get(key)


def localname(el):
    return etree.QName(el.tag).localname


def info(msg):
    print(f"[INFO] {msg}")


def warn(msg):
    print(f"[WARN] {msg}")


def get_child_indent(container):
    if container.text and "\n" in container.text:
        after_nl = container.text.rsplit("\n", 1)[-1]
        if after_nl and not after_nl.strip():
            return after_nl
    for child in container:
        if child.tail and "\n" in child.tail:
            after_nl = child.tail.rsplit("\n", 1)[-1]
            if after_nl and not after_nl.strip():
                return after_nl
    depth = 0
    current = container
    while current is not None:
        depth += 1
        current = current.getparent()
    return "\t" * depth


def insert_before_closing(container, new_el, child_indent):
    children = list(container)
    if len(children) == 0:
        parent_indent = child_indent[:-1] if len(child_indent) > 0 else ""
        container.text = "\r\n" + child_indent
        new_el.tail = "\r\n" + parent_indent
        container.append(new_el)
    else:
        last = children[-1]
        new_el.tail = last.tail
        last.tail = "\r\n" + child_indent
        container.append(new_el)


def insert_before_ref(container, new_el, ref_el, child_indent):
    """Insert new_el before ref_el inside container."""
    idx = list(container).index(ref_el)
    prev = ref_el.getprevious()
    if prev is not None:
        new_el.tail = prev.tail
        prev.tail = "\r\n" + child_indent
    else:
        new_el.tail = container.text
        container.text = "\r\n" + child_indent
    container.insert(idx, new_el)


def remove_with_indent(el):
    parent = el.getparent()
    prev = el.getprevious()
    if prev is not None:
        if el.tail:
            prev.tail = el.tail
    else:
        if el.tail:
            parent.text = el.tail
    parent.remove(el)


def expand_self_closing(container, parent_indent):
    if len(container) == 0 and not (container.text and container.text.strip()):
        container.text = "\r\n" + parent_indent


def import_fragment(xml_string):
    wrapper = (
        f'<_W xmlns="{MD_NS}" xmlns:xsi="{XSI_NS}" xmlns:v8="{V8_NS}" '
        f'xmlns:xr="{XR_NS}" xmlns:xs="{XS_NS}">{xml_string}</_W>'
    )
    frag = etree.fromstring(wrapper.encode("utf-8"))
    return list(frag)


def parse_batch_value(val):
    items = []
    for part in val.split(";;"):
        trimmed = part.strip()
        if trimmed:
            items.append(trimmed)
    return items


def _detect_xml_style(path):
    """Стиль существующего файла для round-trip-сохранения: BOM / EOL / регистр encoding /
    финальный перенос. None → файл новый (сохранить текущее поведение)."""
    try:
        raw = open(path, "rb").read()
    except OSError:
        return None
    bom = raw.startswith(b"\xef\xbb\xbf")
    body = raw[3:] if bom else raw
    crlf = b"\r\n" in body
    m = re.search(rb'encoding="([^"]+)"', body[:200])
    enc = m.group(1).decode("ascii") if m else "utf-8"
    final_nl = body.endswith(b"\n")
    return {"bom": bom, "crlf": crlf, "enc": enc, "final_nl": final_nl}


def _finalize_xml_bytes(xml_bytes, style):
    """Привести байты к стилю оригинала; для НОВОГО файла (style is None) — к канону
    выгрузки Конфигуратора: encoding="UTF-8", CRLF в разделителях, без перевода в конце."""
    enc_decl = style["enc"] if style else "UTF-8"
    xml_bytes = xml_bytes.replace(
        b"<?xml version='1.0' encoding='UTF-8'?>",
        b'<?xml version="1.0" encoding="' + enc_decl.encode("ascii") + b'"?>')
    # Канонизировать переносы к LF (убирает &#13; от \r в tail'ах)
    xml_bytes = (xml_bytes.replace(b"&#13;\n", b"\n").replace(b"&#13;", b"")
                 .replace(b"\r\n", b"\n").replace(b"\r", b"\n"))
    # Финальный перенос — как в оригинале (новый файл → нет, канон #57)
    want_final_nl = style["final_nl"] if style else False
    xml_bytes = xml_bytes.rstrip(b"\n")
    if want_final_nl:
        xml_bytes += b"\n"
    # EOL — как в оригинале (новый файл → CRLF, канон #57)
    if (style["crlf"] if style else True):
        xml_bytes = xml_bytes.replace(b"\n", b"\r\n")
    return xml_bytes


def save_xml_bom(tree, path):
    style = _detect_xml_style(path)
    xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
    xml_bytes = _finalize_xml_bytes(xml_bytes, style)
    with open(path, "wb") as f:
        if style is None or style["bom"]:
            f.write(b"\xef\xbb\xbf")
        f.write(xml_bytes)


def main():
    sys.stdout.reconfigure(encoding="utf-8")
    sys.stderr.reconfigure(encoding="utf-8")
    parser = argparse.ArgumentParser(description="Edit 1C configuration root (Configuration.xml)", allow_abbrev=False)
    parser.add_argument("-ConfigPath", "-Path", required=True)
    parser.add_argument("-DefinitionFile", default=None)
    parser.add_argument("-Operation", default=None, choices=["modify-property", "add-childObject", "remove-childObject", "add-defaultRole", "remove-defaultRole", "set-defaultRoles", "set-panels", "set-home-page", "sort-childObjects"])
    parser.add_argument("-Value", default=None)
    parser.add_argument("-NoValidate", action="store_true")
    args = ci_parse_args(parser)

    if args.DefinitionFile and args.Operation:
        print("Cannot use both -DefinitionFile and -Operation", file=sys.stderr)
        sys.exit(1)
    if not args.DefinitionFile and not args.Operation:
        print("Either -DefinitionFile or -Operation is required", file=sys.stderr)
        sys.exit(1)

    config_path = args.ConfigPath
    if not os.path.isabs(config_path):
        config_path = os.path.join(os.getcwd(), config_path)
    if os.path.isdir(config_path):
        candidate = os.path.join(config_path, "Configuration.xml")
        if os.path.isfile(candidate):
            config_path = candidate
        else:
            print("No Configuration.xml in directory", file=sys.stderr)
            sys.exit(1)
    if not os.path.isfile(config_path):
        print(f"File not found: {config_path}", file=sys.stderr)
        sys.exit(1)
    resolved_path = os.path.abspath(config_path)
    config_dir = os.path.dirname(resolved_path)

    assert_edit_allowed(resolved_path, "editable")

    xml_parser = etree.XMLParser(remove_blank_text=False)
    tree = etree.parse(resolved_path, xml_parser)
    xml_root = tree.getroot()

    # Версия формата редактируемой конфигурации — создаваемые рядом файлы (Ext/HomePageWorkArea.xml)
    # должны нести ту же версию, иначе в проекте окажутся файлы разных версий формата.
    format_version = xml_root.get('version') or '2.17'

    add_count = 0
    remove_count = 0
    modify_count = 0

    cfg_el = None
    for child in xml_root:
        if isinstance(child.tag, str) and localname(child) == "Configuration":
            cfg_el = child
            break
    if cfg_el is None:
        print("No <Configuration> element found", file=sys.stderr)
        sys.exit(1)

    props_el = None
    child_objs_el = None
    for child in cfg_el:
        if not isinstance(child.tag, str):
            continue
        if localname(child) == "Properties":
            props_el = child
        if localname(child) == "ChildObjects":
            child_objs_el = child

    obj_name = ""
    if props_el is not None:
        for child in props_el:
            if isinstance(child.tag, str) and localname(child) == "Name":
                obj_name = (child.text or "").strip()
                break
    info(f"Configuration: {obj_name}")

    # --- Operations ---
    def do_modify_property(batch_val):
        nonlocal modify_count
        items = parse_batch_value(batch_val)
        for item in items:
            eq_idx = item.find("=")
            if eq_idx < 1:
                print(f"Invalid property format '{item}', expected 'Key=Value'", file=sys.stderr)
                sys.exit(1)
            prop_name = item[:eq_idx].strip()
            prop_value = item[eq_idx + 1:].strip()

            prop_el = None
            for child in props_el:
                if isinstance(child.tag, str) and localname(child) == prop_name:
                    prop_el = child
                    break
            if prop_el is None:
                print(f"Property '{prop_name}' not found in Properties", file=sys.stderr)
                sys.exit(1)

            if prop_name in ML_PROPS:
                for ch in list(prop_el):
                    prop_el.remove(ch)
                if not prop_value:
                    prop_el.text = None
                else:
                    indent = get_child_indent(props_el)
                    item_el = etree.SubElement(prop_el, f"{{{V8_NS}}}item")
                    lang_el = etree.SubElement(item_el, f"{{{V8_NS}}}lang")
                    lang_el.text = "ru"
                    content_el = etree.SubElement(item_el, f"{{{V8_NS}}}content")
                    content_el.text = prop_value
                    prop_el.text = "\r\n" + indent + "\t"
                    item_el.text = "\r\n" + indent + "\t\t"
                    lang_el.tail = "\r\n" + indent + "\t\t"
                    content_el.tail = "\r\n" + indent + "\t"
                    item_el.tail = "\r\n" + indent
            elif prop_name in SCALAR_PROPS or prop_name in REF_PROPS:
                for ch in list(prop_el):
                    prop_el.remove(ch)
                if not prop_value:
                    prop_el.text = None
                else:
                    prop_el.text = prop_value
            else:
                for ch in list(prop_el):
                    prop_el.remove(ch)
                prop_el.text = prop_value

            modify_count += 1
            info(f'Set {prop_name} = "{prop_value}"')

    def do_add_child_object(batch_val):
        nonlocal add_count
        if child_objs_el is None:
            print("No <ChildObjects> element found", file=sys.stderr)
            sys.exit(1)

        items = parse_batch_value(batch_val)
        cfg_indent = get_child_indent(cfg_el)
        if len(child_objs_el) == 0 and not (child_objs_el.text and child_objs_el.text.strip()):
            expand_self_closing(child_objs_el, cfg_indent)
        child_indent = get_child_indent(child_objs_el)

        for item in items:
            dot_idx = item.find(".")
            if dot_idx < 1:
                print(f"Invalid format '{item}', expected 'Type.Name'", file=sys.stderr)
                sys.exit(1)
            type_name = resolve_type_name(item[:dot_idx]) or item[:dot_idx]
            obj_name_val = item[dot_idx + 1:]

            if type_name not in TYPE_ORDER:
                print(f"Unknown type '{type_name}'", file=sys.stderr)
                sys.exit(1)
            type_idx = TYPE_ORDER.index(type_name)

            # Check that the referenced object actually exists on disk.
            # cf-edit add-childObject is a low-level operation for rare scenarios
            # (e.g. restoring a rolled-back Configuration.xml when object files are intact).
            # For creating NEW objects, meta-compile/role-compile/subsystem-compile already
            # auto-register in Configuration.xml — calling cf-edit add-childObject there is
            # unnecessary and error-prone.
            type_dir = TYPE_TO_DIR.get(type_name)
            obj_file = os.path.join(config_dir, type_dir, f"{obj_name_val}.xml")
            if not os.path.exists(obj_file):
                hint_skill = {"Subsystem": "subsystem-compile", "Role": "role-compile"}.get(type_name, "meta-compile")
                print(
                    f"Object file not found: {type_dir}/{obj_name_val}.xml\n"
                    f"cf-edit add-childObject only references objects that already exist on disk.\n"
                    f"To create a new {type_name}, use {hint_skill} (auto-registers in Configuration.xml):\n"
                    f'  /{hint_skill} with {{"type":"{type_name}","name":"{obj_name_val}"}}',
                    file=sys.stderr
                )
                sys.exit(1)

            # Dedup
            exists = False
            for child in child_objs_el:
                if isinstance(child.tag, str) and localname(child) == type_name and (child.text or "") == obj_name_val:
                    exists = True
                    break
            if exists:
                warn(f"Already exists: {type_name}.{obj_name_val}")
                continue

            # Место вставки. Вид — по TYPE_ORDER; внутри вида — по newObjectPosition:
            # end (по умолчанию) кладёт после последнего объекта того же вида, byName — по имени.
            # Subsystem по имени не упорядочиваем никогда: порядок подсистем в дереве задаёт
            # порядок разделов в панели, пока их не перечислили в <SubsystemsOrder>.
            by_name = (not is_order_sensitive_type(type_name)
                       and get_new_object_position(config_dir) == "byName")
            insert_before = None
            last_same = None
            first_later = None
            for child in child_objs_el:
                if not isinstance(child.tag, str):
                    continue
                child_type_name = localname(child)
                if child_type_name not in TYPE_ORDER:
                    continue
                child_type_idx = TYPE_ORDER.index(child_type_name)

                if child_type_name == type_name:
                    last_same = child
                    if (by_name and insert_before is None
                            and compare_metadata_names(child.text or "", obj_name_val) > 0):
                        insert_before = child
                elif child_type_idx > type_idx and first_later is None:
                    first_later = child

            if insert_before is None:
                # Место не выбрано именем — ставим сразу за последним объектом того же вида,
                # то есть перед его следующим соседом. Через first_later этого не сделать:
                # если видов старше в файле нет, запись уехала бы в самый конец блока,
                # за пределы своей группы.
                if last_same is not None:
                    siblings = [c for c in child_objs_el if isinstance(c.tag, str)]
                    pos = siblings.index(last_same)
                    insert_before = siblings[pos + 1] if pos + 1 < len(siblings) else None
                else:
                    insert_before = first_later

            new_el = etree.Element(f"{{{MD_NS}}}{type_name}")
            new_el.text = obj_name_val

            if insert_before is not None:
                insert_before_ref(child_objs_el, new_el, insert_before, child_indent)
            else:
                insert_before_closing(child_objs_el, new_el, child_indent)

            add_count += 1
            info(f"Added: {type_name}.{obj_name_val}")

    def do_sort_child_objects(batch_val):
        """Упорядочить <ChildObjects>: имена внутри вида, а без аргумента — и группы видов.

        Виды из is_order_sensitive_type по имени не сортируются, пока не названы явно.
        Вызов без значения дополнительно ставит группы видов в канонический порядок: платформа
        починила бы его только при загрузке-выгрузке, то есть неканоничный файл даёт диф на
        ровном месте. Переставляем ЗНАЧЕНИЯ узлов, а не сами узлы — отступы и структура файла
        остаются как были, в дифе только перестановка строк.
        """
        nonlocal modify_count
        if child_objs_el is None:
            print("No <ChildObjects> element found", file=sys.stderr)
            sys.exit(1)

        requested = []
        for token in (parse_batch_value(batch_val) if str(batch_val or "").strip() else []):
            canon = resolve_type_name(token)
            if canon is None:
                print(f"Unknown type '{token}'. Valid: {', '.join(TYPE_ORDER)}", file=sys.stderr)
                sys.exit(1)
            requested.append(canon)

        groups = {}
        for child in child_objs_el:
            if not isinstance(child.tag, str):
                continue
            groups.setdefault(localname(child), []).append(child)

        targets = requested or [t for t in groups if not is_order_sensitive_type(t)]
        for type_name in targets:
            els = groups.get(type_name, [])
            if len(els) < 2:
                continue
            names = [e.text or "" for e in els]
            ordered = sorted(names, key=functools.cmp_to_key(compare_metadata_names))
            if names == ordered:
                continue
            for el, name in zip(els, ordered):
                el.text = name
            modify_count += 1
            info(f"Sorted: {type_name} ({len(els)})")

        if requested:
            # Вид назван явно — точечная операция: взаимный порядок групп не трогаем.
            return

        # Без аргумента приводим в порядок и сами группы видов: собранная навыками
        # конфигурация может держать их не в каноне, и первая же выгрузка платформы даст
        # диф. Переставляем содержимое существующих узлов, а не узлы, поэтому отступы и
        # структура файла не меняются — в дифе только перестановка строк.
        elems = [c for c in child_objs_el if isinstance(c.tag, str)]
        pairs = [(localname(c), c.text or "") for c in elems]
        ranked = sorted(range(len(pairs)),
                        key=lambda i: (TYPE_ORDER.index(pairs[i][0]) if pairs[i][0] in TYPE_ORDER else len(TYPE_ORDER), i))
        wanted = [pairs[i] for i in ranked]
        if wanted == pairs:
            return
        for el, (tag, text) in zip(elems, wanted):
            el.tag = f'{{{MD_NS}}}{tag}'
            el.text = text
        modify_count += 1
        info(f"Reordered type groups: {len(elems)} entries")

    def do_remove_child_object(batch_val):
        nonlocal remove_count
        if child_objs_el is None:
            print("No <ChildObjects> element found", file=sys.stderr)
            sys.exit(1)

        items = parse_batch_value(batch_val)
        for item in items:
            dot_idx = item.find(".")
            if dot_idx < 1:
                print(f"Invalid format '{item}', expected 'Type.Name'", file=sys.stderr)
                sys.exit(1)
            type_name = resolve_type_name(item[:dot_idx]) or item[:dot_idx]
            obj_name_val = item[dot_idx + 1:]

            found = False
            for child in list(child_objs_el):
                if isinstance(child.tag, str) and localname(child) == type_name and (child.text or "") == obj_name_val:
                    remove_with_indent(child)
                    remove_count += 1
                    info(f"Removed: {type_name}.{obj_name_val}")
                    found = True
                    break
            if not found:
                warn(f"Not found: {type_name}.{obj_name_val}")

    def do_add_default_role(batch_val):
        nonlocal add_count
        items = parse_batch_value(batch_val)

        roles_el = None
        for child in props_el:
            if isinstance(child.tag, str) and localname(child) == "DefaultRoles":
                roles_el = child
                break
        if roles_el is None:
            print("No <DefaultRoles> element found in Properties", file=sys.stderr)
            sys.exit(1)

        props_indent = get_child_indent(props_el)
        if len(roles_el) == 0 and not (roles_el.text and roles_el.text.strip()):
            expand_self_closing(roles_el, props_indent)
        role_indent = get_child_indent(roles_el)

        for item in items:
            role_name = item
            if not role_name.startswith("Role."):
                role_name = f"Role.{role_name}"

            exists = False
            for child in roles_el:
                if isinstance(child.tag, str) and (child.text or "").strip() == role_name:
                    exists = True
                    break
            if exists:
                warn(f"DefaultRole already exists: {role_name}")
                continue

            frag_xml = f'<xr:Item xsi:type="xr:MDObjectRef">{role_name}</xr:Item>'
            nodes = import_fragment(frag_xml)
            if nodes:
                insert_before_closing(roles_el, nodes[0], role_indent)
                add_count += 1
                info(f"Added DefaultRole: {role_name}")

    def do_remove_default_role(batch_val):
        nonlocal remove_count
        items = parse_batch_value(batch_val)

        roles_el = None
        for child in props_el:
            if isinstance(child.tag, str) and localname(child) == "DefaultRoles":
                roles_el = child
                break
        if roles_el is None:
            print("No <DefaultRoles> element found", file=sys.stderr)
            sys.exit(1)

        for item in items:
            role_name = item
            if not role_name.startswith("Role."):
                role_name = f"Role.{role_name}"

            found = False
            for child in list(roles_el):
                if isinstance(child.tag, str) and (child.text or "").strip() == role_name:
                    remove_with_indent(child)
                    remove_count += 1
                    info(f"Removed DefaultRole: {role_name}")
                    found = True
                    break
            if not found:
                warn(f"DefaultRole not found: {role_name}")

    def do_set_default_roles(batch_val):
        nonlocal modify_count
        items = parse_batch_value(batch_val)

        roles_el = None
        for child in props_el:
            if isinstance(child.tag, str) and localname(child) == "DefaultRoles":
                roles_el = child
                break
        if roles_el is None:
            print("No <DefaultRoles> element found", file=sys.stderr)
            sys.exit(1)

        # Clear all existing children
        for ch in list(roles_el):
            roles_el.remove(ch)
        roles_el.text = None

        if not items:
            modify_count += 1
            info("Cleared DefaultRoles")
            return

        props_indent = get_child_indent(props_el)
        role_indent = props_indent + "\t"

        roles_el.text = "\r\n" + props_indent

        for item in items:
            role_name = item
            if not role_name.startswith("Role."):
                role_name = f"Role.{role_name}"

            frag_xml = f'<xr:Item xsi:type="xr:MDObjectRef">{role_name}</xr:Item>'
            nodes = import_fragment(frag_xml)
            if nodes:
                insert_before_closing(roles_el, nodes[0], role_indent)

        modify_count += 1
        info(f"Set DefaultRoles: {len(items)} roles")

    # --- set-panels (writes Ext/ClientApplicationInterface.xml from scratch) ---
    # Canonical English aliases — preferred form, used in docs and error messages.
    PANEL_UUIDS = {
        "sections":  "b553047f-c9aa-4157-978d-448ecad24248",
        "open":      "cbab57f2-a0f3-4f0a-89ea-4cb19570ab75",
        "favorites": "13322b22-3960-4d68-93a6-fe2dd7f28ca3",
        "history":   "c933ac92-92cd-459d-81cc-e0c8a83ced99",
        "functions": "b2735bd3-d822-4430-ba59-c9e869693b24",
    }
    # Russian synonyms — silently accepted (cf-info displays Russian names;
    # users may copy them straight into cf-edit value).
    PANEL_SYNONYMS = {
        "разделов": "sections",   "разделы":   "sections",
        "открытых": "open",       "открытые":  "open",
        "избранного": "favorites","избранное": "favorites",
        "истории":  "history",    "история":   "history",
        "функций":  "functions",  "функции":   "functions",
    }

    def build_panel_entry_xml(entry, indent):
        if isinstance(entry, str):
            key = entry.lower()
            key = PANEL_SYNONYMS.get(key, key)
            if key not in PANEL_UUIDS:
                allowed = ", ".join(sorted(PANEL_UUIDS.keys()))
                print(f"Unknown panel alias '{entry}'. Allowed: {allowed}", file=sys.stderr)
                sys.exit(1)
            inst = str(_uuid.uuid4())
            return f'{indent}<panel id="{inst}">\r\n{indent}\t<uuid>{PANEL_UUIDS[key]}</uuid>\r\n{indent}</panel>'
        if isinstance(entry, dict) and "group" in entry:
            children = entry["group"]
            if not children:
                print("group must contain at least one entry", file=sys.stderr)
                sys.exit(1)
            gid = str(_uuid.uuid4())
            inner = ""
            for child in children:
                child_xml = build_panel_entry_xml(child, indent + "\t\t")
                inner += f"{indent}\t<group>\r\n{child_xml}\r\n{indent}\t</group>\r\n"
            return f'{indent}<group id="{gid}">\r\n{inner}{indent}</group>'
        print(f"Panel entry must be string alias or {{group:[...]}}, got: {entry!r}", file=sys.stderr)
        sys.exit(1)

    def do_set_panels(value):
        nonlocal modify_count
        layout = value
        if isinstance(layout, str):
            layout = ci_json(parse_json_input(
                layout, "-Value for operation 'set-panels'", "a JSON object with panel layout", inline=True))
        if not isinstance(layout, dict) or not layout:
            print("set-panels value must be non-empty object", file=sys.stderr)
            sys.exit(1)

        sides = ("top", "left", "right", "bottom")
        # Reject unknown side keys
        for k in layout.keys():
            if k not in sides:
                print(f"Unknown side '{k}'. Allowed: {', '.join(sides)}", file=sys.stderr)
                sys.exit(1)

        body_parts = []
        for side in sides:
            entries = layout.get(side)
            if entries is None:
                continue
            if not isinstance(entries, list):
                entries = [entries]
            for entry in entries:
                entry_xml = build_panel_entry_xml(entry, "\t\t")
                body_parts.append(f"\t<{side}>\r\n{entry_xml}\r\n\t</{side}>")
        body = "\r\n".join(body_parts)
        body_block = body + "\r\n" if body else ""
        declarations = (
            '\t<panelDef id="b553047f-c9aa-4157-978d-448ecad24248"/>\r\n'
            '\t<panelDef id="13322b22-3960-4d68-93a6-fe2dd7f28ca3"/>\r\n'
            '\t<panelDef id="c933ac92-92cd-459d-81cc-e0c8a83ced99"/>\r\n'
            '\t<panelDef id="cbab57f2-a0f3-4f0a-89ea-4cb19570ab75"/>\r\n'
            '\t<panelDef id="b2735bd3-d822-4430-ba59-c9e869693b24"/>'
        )
        cai_xml = (
            '<?xml version="1.0" encoding="UTF-8"?>\r\n'
            '<ClientApplicationInterface xmlns="http://v8.1c.ru/8.2/managed-application/core" '
            'xmlns:xs="http://www.w3.org/2001/XMLSchema" '
            'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" '
            'xsi:type="InterfaceLayouter">\r\n'
            f'{body_block}{declarations}\r\n'
            '</ClientApplicationInterface>'
        )
        ext_dir = os.path.join(config_dir, "Ext")
        os.makedirs(ext_dir, exist_ok=True)
        cai_path = os.path.join(ext_dir, "ClientApplicationInterface.xml")
        with open(cai_path, "w", encoding="utf-8-sig", newline="") as fh:
            fh.write(cai_xml)
        modify_count += 1
        info(f"Wrote panel layout: {cai_path}")

    # --- set-home-page (writes Ext/HomePageWorkArea.xml from scratch) ---
    DIR_TO_TYPE = {v.lower(): k for k, v in TYPE_TO_DIR.items()}
    UUID_RE = __import__("re").compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")

    def normalize_form_ref(s):
        s = (s or "").strip()
        if not s:
            return s
        if UUID_RE.match(s):
            return s
        if "/" in s or "\\" in s:
            parts = [p for p in s.replace("\\", "/").split("/") if p and p.lower() != "ext"]
            if parts and parts[-1].lower() == "form.xml":
                parts = parts[:-1]
            if len(parts) >= 2:
                type_dir = parts[0]
                type_singular = DIR_TO_TYPE.get(type_dir.lower())
                if type_singular:
                    if type_singular == "CommonForm" and len(parts) >= 2:
                        return f"CommonForm.{parts[1]}"
                    if len(parts) >= 4 and parts[2].lower() == "forms":
                        return f"{type_singular}.{parts[1]}.Form.{parts[3]}"
            return s
        segs = s.split(".")
        if segs:
            head = segs[0].lower()
            if head in RU_TYPE_MAP:
                segs[0] = RU_TYPE_MAP[head]
            for i in range(1, len(segs)):
                if segs[i] == "Форма":
                    segs[i] = "Form"
            if len(segs) == 3 and segs[0] in TYPE_ORDER and segs[0] != "CommonForm":
                segs = [segs[0], segs[1], "Form", segs[2]]
        return ".".join(segs)

    def get_field(obj, keys):
        for k in keys:
            if isinstance(obj, dict) and k in obj:
                return obj[k]
        return None

    def build_home_page_item_xml(entry, indent):
        if isinstance(entry, str):
            form_ref = normalize_form_ref(entry)
            height = 10
            common = True
            roles = None
        elif isinstance(entry, dict):
            form_raw = get_field(entry, ["form", "Form"])
            if not form_raw:
                print(f"Home page item: 'form' is required, got: {entry!r}", file=sys.stderr)
                sys.exit(1)
            form_ref = normalize_form_ref(str(form_raw))
            h = get_field(entry, ["height", "Height"])
            height = int(h) if h is not None else 10
            vis = get_field(entry, ["visibility", "Visibility"])
            common = bool(vis) if vis is not None else True
            roles = get_field(entry, ["roles"])
        else:
            print(f"Home page item must be string or object, got: {entry!r}", file=sys.stderr)
            sys.exit(1)

        vis_parts = [f"{indent}\t\t<xr:Common>{str(common).lower()}</xr:Common>"]
        if roles and isinstance(roles, dict):
            for rname, rval in roles.items():
                if not rname.startswith("Role.") and not UUID_RE.match(rname):
                    rname = f"Role.{rname}"
                rval_s = str(bool(rval)).lower()
                vis_parts.append(f'{indent}\t\t<xr:Value name="{html_escape(rname, quote=True)}">{rval_s}</xr:Value>')
        vis_block = "\r\n".join(vis_parts)
        esc_form = html_escape(form_ref, quote=True)
        return (
            f"{indent}<Item>\r\n"
            f"{indent}\t<Form>{esc_form}</Form>\r\n"
            f"{indent}\t<Height>{height}</Height>\r\n"
            f"{indent}\t<Visibility>\r\n"
            f"{vis_block}\r\n"
            f"{indent}\t</Visibility>\r\n"
            f"{indent}</Item>"
        )

    def do_set_home_page(value):
        nonlocal modify_count
        layout = value
        if isinstance(layout, str):
            layout = ci_json(parse_json_input(
                layout, "-Value for operation 'set-home-page'", "a JSON object with home page layout", inline=True))
        if not isinstance(layout, dict) or not layout:
            print("set-home-page value must be non-empty object", file=sys.stderr)
            sys.exit(1)

        allowed_templates = ("OneColumn", "TwoColumnsEqualWidth", "TwoColumnsVariableWidth")
        tmpl = get_field(layout, ["template", "WorkingAreaTemplate"]) or "TwoColumnsEqualWidth"
        if tmpl not in allowed_templates:
            print(f"Unknown template '{tmpl}'. Allowed: {', '.join(allowed_templates)}", file=sys.stderr)
            sys.exit(1)

        left_items = get_field(layout, ["left", "LeftColumn"])
        right_items = get_field(layout, ["right", "RightColumn"])

        known = {"template", "WorkingAreaTemplate", "left", "LeftColumn", "right", "RightColumn"}
        for k in layout.keys():
            if k not in known:
                print(f"Unknown key '{k}'. Allowed: template, left, right", file=sys.stderr)
                sys.exit(1)

        if tmpl == "OneColumn" and right_items:
            print("Template 'OneColumn' cannot have items in 'right' column", file=sys.stderr)
            sys.exit(1)

        def build_column(tag, items):
            if not items:
                return f"\t<{tag}/>"
            if not isinstance(items, list):
                items = [items]
            if not items:
                return f"\t<{tag}/>"
            blocks = [build_home_page_item_xml(it, "\t\t") for it in items]
            body = "\r\n".join(blocks)
            return f"\t<{tag}>\r\n{body}\r\n\t</{tag}>"

        left_xml = build_column("LeftColumn", left_items)
        right_xml = build_column("RightColumn", right_items)

        hp_xml = (
            '<?xml version="1.0" encoding="UTF-8"?>\r\n'
            '<HomePageWorkArea xmlns="http://v8.1c.ru/8.3/xcf/extrnprops" '
            'xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" '
            'xmlns:xs="http://www.w3.org/2001/XMLSchema" '
            f'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="{format_version}">\r\n'
            f'\t<WorkingAreaTemplate>{tmpl}</WorkingAreaTemplate>\r\n'
            f'{left_xml}\r\n'
            f'{right_xml}\r\n'
            '</HomePageWorkArea>'
        )

        ext_dir = os.path.join(config_dir, "Ext")
        os.makedirs(ext_dir, exist_ok=True)
        hp_path = os.path.join(ext_dir, "HomePageWorkArea.xml")
        with open(hp_path, "w", encoding="utf-8-sig", newline="") as fh:
            fh.write(hp_xml)
        modify_count += 1
        info(f"Wrote home page layout: {hp_path}")

    # --- Execute operations ---
    operations = []
    if args.DefinitionFile:
        def_file = args.DefinitionFile
        if not os.path.isabs(def_file):
            def_file = os.path.join(os.getcwd(), def_file)
        ops = ci_json(parse_json_input(read_json_file(def_file), def_file))
        if isinstance(ops, list):
            operations = ops
        else:
            operations = [ops]
    else:
        operations = [{"operation": args.Operation, "value": args.Value or ""}]

    for op in operations:
        op_name = op.get("operation", args.Operation or "")
        # PS сравнивает имя операции через switch, а он регистронезависим.
        op_key = str(op_name).lower()
        op_value = op.get("value", args.Value or "")

        if op_key == "modify-property":
            do_modify_property(op_value if isinstance(op_value, str) else str(op_value))
        elif op_key == "add-childobject":
            do_add_child_object(op_value if isinstance(op_value, str) else str(op_value))
        elif op_key == "remove-childobject":
            do_remove_child_object(op_value if isinstance(op_value, str) else str(op_value))
        elif op_key == "add-defaultrole":
            do_add_default_role(op_value if isinstance(op_value, str) else str(op_value))
        elif op_key == "remove-defaultrole":
            do_remove_default_role(op_value if isinstance(op_value, str) else str(op_value))
        elif op_key == "set-defaultroles":
            do_set_default_roles(op_value if isinstance(op_value, str) else str(op_value))
        elif op_key == "set-panels":
            do_set_panels(op_value)
        elif op_key == "set-home-page":
            do_set_home_page(op_value)
        elif op_key == "sort-childobjects":
            do_sort_child_objects(op_value if isinstance(op_value, str) else str(op_value))
        else:
            print(f"Unknown operation: {op_name}", file=sys.stderr)
            sys.exit(1)

    # --- Save ---
    save_xml_bom(tree, resolved_path)
    info(f"Saved: {resolved_path}")

    # --- Auto-validate ---
    if not args.NoValidate:
        validate_script = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", "cf-validate", "scripts", "cf-validate.py"))
        if os.path.isfile(validate_script):
            print()
            print("--- Running cf-validate ---")
            subprocess.run([sys.executable, validate_script, "-ConfigPath", resolved_path])

    # --- Summary ---
    print()
    print("=== cf-edit summary ===")
    print(f"  Configuration: {obj_name}")
    print(f"  Added:         {add_count}")
    print(f"  Removed:       {remove_count}")
    print(f"  Modified:      {modify_count}")
    sys.exit(0)


if __name__ == "__main__":
    main()
