# form-edit v1.18 — Edit 1C managed form elements (Python port) (+esc_xml/esc_xml_text: разное экранирование атрибута и текста)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse
import json
import os
import re
import sys

from lxml import etree

sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")

# Регистронезависимый ввод — паритет с 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


# ── arg parsing ──────────────────────────────────────────────

parser = argparse.ArgumentParser(allow_abbrev=False)
parser.add_argument("-FormPath", "-Path", required=True)
parser.add_argument("-JsonPath", required=True)
args = ci_parse_args(parser)

form_path = args.FormPath
json_path = args.JsonPath

# ── namespaces ───────────────────────────────────────────────

FORM_NS = "http://v8.1c.ru/8.3/xcf/logform"
V8_NS = "http://v8.1c.ru/8.1/data/core"
NS = {
    "f": FORM_NS,
    "v8": V8_NS,
}

ALL_NS_DECL = (
    'xmlns="http://v8.1c.ru/8.3/xcf/logform"'
    ' xmlns:v8="http://v8.1c.ru/8.1/data/core"'
    ' xmlns:v8ui="http://v8.1c.ru/8.1/data/ui"'
    ' xmlns:xr="http://v8.1c.ru/8.3/xcf/readable"'
    ' xmlns:xs="http://www.w3.org/2001/XMLSchema"'
    ' xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config"'
    ' xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings"'
    ' xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core"'
    ' xmlns:dcssch="http://v8.1c.ru/8.1/data-composition-system/schema"'
)


def local_name(node):
    return etree.QName(node.tag).localname


# ── helpers ──────────────────────────────────────────────────

def esc_xml(s):
    # Эскейп ЗНАЧЕНИЯ АТРИБУТА: & < > и кавычка — внутри "..." литеральная " невалидна.
    return s.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;').replace('"', '&quot;')


def esc_xml_text(s):
    """Экранирование ТЕКСТА элемента: только & < > . Кавычки платформа в тексте не экранирует
    (92142 сырых кавычки на корпус, ни одной &quot;); &quot; она принимает, но нормализует обратно."""
    return s.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')


# ── 1. Load Form.xml ────────────────────────────────────────

if not os.path.exists(form_path):
    print(f"File not found: {form_path}", file=sys.stderr)
    sys.exit(1)
if not os.path.exists(json_path):
    print(f"File not found: {json_path}", file=sys.stderr)
    sys.exit(1)

resolved_form_path = os.path.abspath(form_path)
assert_edit_allowed(resolved_form_path, "editable")
xml_parser = etree.XMLParser(remove_blank_text=False)
try:
    tree = etree.parse(resolved_form_path, xml_parser)
except etree.XMLSyntaxError as e:
    print(f"[ERROR] XML parse error: {e}")
    sys.exit(1)

root = tree.getroot()

# ── 2. Load JSON ────────────────────────────────────────────

defn = ci_json(parse_json_input(read_json_file(json_path), json_path))

# ── 3. Form name + header ───────────────────────────────────

form_name = os.path.splitext(os.path.basename(form_path))[0]
parent_dir = os.path.dirname(resolved_form_path)
if parent_dir:
    ext_dir = os.path.basename(parent_dir)
    if ext_dir == "Ext":
        form_dir = os.path.dirname(parent_dir)
        if form_dir:
            form_name = os.path.basename(form_dir)

print(f"=== form-edit: {form_name} ===")
print()

# ── 4. Scan max IDs per pool ────────────────────────────────

next_elem_id = 0
next_attr_id = 0
next_cmd_id = 0


def _scan_id(node, attr="id"):
    val = node.get(attr)
    if val and val != "-1":
        try:
            return int(val)
        except ValueError:
            pass
    return -1


# Scan element IDs
root_ci = root.find("f:ChildItems", NS)
if root_ci is not None:
    for elem in root_ci.iter():
        v = _scan_id(elem)
        if v > next_elem_id:
            next_elem_id = v

acb = root.find("f:AutoCommandBar", NS)
if acb is not None:
    v = _scan_id(acb)
    if v > next_elem_id:
        next_elem_id = v

# Scan attribute IDs (including column IDs - same pool)
for attr_el in root.findall("f:Attributes/f:Attribute", NS):
    v = _scan_id(attr_el)
    if v > next_attr_id:
        next_attr_id = v
    for col_el in attr_el.findall("f:Columns/f:Column", NS):
        v = _scan_id(col_el)
        if v > next_attr_id:
            next_attr_id = v

# Scan command IDs
for cmd_el in root.findall("f:Commands/f:Command", NS):
    v = _scan_id(cmd_el)
    if v > next_cmd_id:
        next_cmd_id = v

next_elem_id += 1
next_attr_id += 1
next_cmd_id += 1

# --- 4b. Auto-detect extension mode (BaseForm present) ---
is_extension = False
base_form = root.find("f:BaseForm", NS)
if base_form is not None:
    is_extension = True
    if next_attr_id < 1000000:
        next_attr_id = 1000000
    if next_cmd_id < 1000000:
        next_cmd_id = 1000000
    if next_elem_id < 1000000:
        next_elem_id = 1000000


def new_elem_id():
    global next_elem_id
    _id = next_elem_id
    next_elem_id += 1
    return _id


def new_attr_id():
    global next_attr_id
    _id = next_attr_id
    next_attr_id += 1
    return _id


def new_cmd_id():
    global next_cmd_id
    _id = next_cmd_id
    next_cmd_id += 1
    return _id


new_id = new_elem_id  # alias for element emitters

# ── 5. Fragment helpers (StringBuilder + Emit-* from form-compile) ──

xml_lines = []


def X(text):
    xml_lines.append(text)


# --- Type emitter ---

_FORM_TYPE_SYNONYMS = {
    "строка": "string", "число": "decimal", "булево": "boolean",
    "дата": "date", "датавремя": "dateTime",
    "number": "decimal", "bool": "boolean",
    "справочникссылка": "CatalogRef", "справочникобъект": "CatalogObject",
    "документссылка": "DocumentRef", "документобъект": "DocumentObject",
    "перечислениессылка": "EnumRef",
    "плансчетовссылка": "ChartOfAccountsRef",
    "планвидовхарактеристикссылка": "ChartOfCharacteristicTypesRef",
    "планвидоврасчётассылка": "ChartOfCalculationTypesRef",
    "планвидоврасчетассылка": "ChartOfCalculationTypesRef",
    "планобменассылка": "ExchangePlanRef",
    "бизнеспроцессссылка": "BusinessProcessRef",
    "задачассылка": "TaskRef",
    "определяемыйтип": "DefinedType",
}


# Алиас на локальный словарь: тело resolve_type_str ниже — общая реализация,
# одинаковая во всех навыках (реестр в tests/skills/check-inline-drift.mjs).
TYPE_SYNONYMS = _FORM_TYPE_SYNONYMS


def resolve_type_str(type_str):
    if not type_str:
        return type_str
    # Прощающий ввод: ведущий префикс приходит копипастой из выгрузки. Без срезания он ломает
    # поиск в словаре — русское имя типа остаётся непереведённым, и платформа отвечает
    # «Неизвестное имя типа». cfg: снимаем всегда — он однозначно означает текущую конфигурацию.
    # Сгенерированный dNpM: (в корпусе на этом URI встречаются d4p1, d5p1, d6p1 — имя префикса
    # платформа выдаёт по порядку объявления) снимаем ТОЛЬКО у ссылочных типов, с точкой:
    # сам по себе префикс многозначен — в формах d5p1:Chart, d5p1:TextDocument,
    # d5p1:GeographicalSchema адресуют чужие пространства имён, и там он часть значения.
    if type_str.startswith('cfg:'):
        type_str = type_str[4:]
    elif '.' in type_str and re.match(r'^d\d+p\d+:', type_str):
        type_str = type_str[type_str.index(':') + 1:]
    # Параметризованные типы: Number(15,2), Строка(100)
    m = re.match(r'^([^(]+)\((.+)\)$', type_str)
    if m:
        base_name = m.group(1).strip()
        params = m.group(2)
        resolved = TYPE_SYNONYMS.get(base_name.lower())
        if resolved:
            return f'{resolved}({params})'
        return type_str
    # Ссылочные типы: СправочникСсылка.Организации -> CatalogRef.Организации
    if '.' in type_str:
        dot_idx = type_str.index('.')
        prefix = type_str[:dot_idx]
        suffix = type_str[dot_idx:]  # includes the dot
        resolved = TYPE_SYNONYMS.get(prefix.lower())
        if resolved:
            return f'{resolved}{suffix}'
        return type_str
    # Простое имя
    resolved = TYPE_SYNONYMS.get(type_str.lower())
    if resolved:
        return resolved
    return type_str
def emit_type(type_str, indent):
    if not type_str:
        X(f"{indent}<Type/>")
        return
    type_string = str(type_str)
    parts = [p.strip() for p in re.split(r'[|+]', type_string)]
    X(f"{indent}<Type>")
    for part in parts:
        emit_single_type(part, indent + "\t")
    X(f"{indent}</Type>")


def emit_single_type(type_str, indent):
    type_str = resolve_type_str(type_str)
    if type_str == "boolean":
        X(f"{indent}<v8:Type>xs:boolean</v8:Type>")
        return

    m = re.match(r'^string(\((\d+)\))?$', type_str)
    if m:
        length = m.group(2) if m.group(2) else "0"
        X(f"{indent}<v8:Type>xs:string</v8:Type>")
        X(f"{indent}<v8:StringQualifiers>")
        X(f"{indent}\t<v8:Length>{length}</v8:Length>")
        X(f"{indent}\t<v8:AllowedLength>Variable</v8:AllowedLength>")
        X(f"{indent}</v8:StringQualifiers>")
        return

    m = re.match(r'^decimal\((\d+),(\d+)(,nonneg)?\)$', type_str)
    if m:
        digits = m.group(1)
        fraction = m.group(2)
        sign = "Nonnegative" if m.group(3) else "Any"
        X(f"{indent}<v8:Type>xs:decimal</v8:Type>")
        X(f"{indent}<v8:NumberQualifiers>")
        X(f"{indent}\t<v8:Digits>{digits}</v8:Digits>")
        X(f"{indent}\t<v8:FractionDigits>{fraction}</v8:FractionDigits>")
        X(f"{indent}\t<v8:AllowedSign>{sign}</v8:AllowedSign>")
        X(f"{indent}</v8:NumberQualifiers>")
        return

    m = re.match(r'^(date|dateTime|time)$', type_str)
    if m:
        fractions_map = {"date": "Date", "dateTime": "DateTime", "time": "Time"}
        fractions = fractions_map[type_str]
        X(f"{indent}<v8:Type>xs:dateTime</v8:Type>")
        X(f"{indent}<v8:DateQualifiers>")
        X(f"{indent}\t<v8:DateFractions>{fractions}</v8:DateFractions>")
        X(f"{indent}</v8:DateQualifiers>")
        return

    v8_types = {
        "ValueTable": "v8:ValueTable", "ValueTree": "v8:ValueTree", "ValueList": "v8:ValueListType",
        "TypeDescription": "v8:TypeDescription", "Universal": "v8:Universal",
        "FixedArray": "v8:FixedArray", "FixedStructure": "v8:FixedStructure",
    }
    if type_str in v8_types:
        X(f"{indent}<v8:Type>{v8_types[type_str]}</v8:Type>")
        return

    ui_types = {"FormattedString": "v8ui:FormattedString", "Picture": "v8ui:Picture", "Color": "v8ui:Color", "Font": "v8ui:Font"}
    if type_str in ui_types:
        X(f"{indent}<v8:Type>{ui_types[type_str]}</v8:Type>")
        return

    if type_str == "DynamicList":
        X(f"{indent}<v8:Type>cfg:DynamicList</v8:Type>")
        return

    if type_str.startswith("DataComposition"):
        dcs_map = {
            "DataCompositionSettings": "dcsset:DataCompositionSettings",
            "DataCompositionSchema": "dcssch:DataCompositionSchema",
            "DataCompositionComparisonType": "dcscor:DataCompositionComparisonType",
        }
        if type_str in dcs_map:
            X(f"{indent}<v8:Type>{dcs_map[type_str]}</v8:Type>")
            return

    if re.match(r'^(CatalogRef|CatalogObject|DocumentRef|DocumentObject|EnumRef|ChartOfAccountsRef|ChartOfCharacteristicTypesRef|ChartOfCalculationTypesRef|ExchangePlanRef|BusinessProcessRef|TaskRef|InformationRegisterRecordSet|AccumulationRegisterRecordSet|DataProcessorObject)\.', type_str):
        X(f"{indent}<v8:Type>cfg:{type_str}</v8:Type>")
        return

    if "." in type_str:
        X(f"{indent}<v8:Type>cfg:{type_str}</v8:Type>")
    else:
        X(f"{indent}<v8:Type>{type_str}</v8:Type>")


def emit_mltext(tag, text, indent):
    X(f"{indent}<{tag}>")
    X(f"{indent}\t<v8:item>")
    X(f"{indent}\t\t<v8:lang>ru</v8:lang>")
    X(f"{indent}\t\t<v8:content>{esc_xml_text(text)}</v8:content>")
    X(f"{indent}\t</v8:item>")
    X(f"{indent}</{tag}>")


# --- Event handler name generator ---

event_suffix_map = {
    "OnChange": "\u041f\u0440\u0438\u0418\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0438",
    "StartChoice": "\u041d\u0430\u0447\u0430\u043b\u043e\u0412\u044b\u0431\u043e\u0440\u0430",
    "ChoiceProcessing": "\u041e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430\u0412\u044b\u0431\u043e\u0440\u0430",
    "AutoComplete": "\u0410\u0432\u0442\u043e\u041f\u043e\u0434\u0431\u043e\u0440",
    "Clearing": "\u041e\u0447\u0438\u0441\u0442\u043a\u0430",
    "Opening": "\u041e\u0442\u043a\u0440\u044b\u0442\u0438\u0435",
    "Click": "\u041d\u0430\u0436\u0430\u0442\u0438\u0435",
    "OnActivateRow": "\u041f\u0440\u0438\u0410\u043a\u0442\u0438\u0432\u0438\u0437\u0430\u0446\u0438\u0438\u0421\u0442\u0440\u043e\u043a\u0438",
    "BeforeAddRow": "\u041f\u0435\u0440\u0435\u0434\u041d\u0430\u0447\u0430\u043b\u043e\u043c\u0414\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0438\u044f",
    "BeforeDeleteRow": "\u041f\u0435\u0440\u0435\u0434\u0423\u0434\u0430\u043b\u0435\u043d\u0438\u0435\u043c",
    "BeforeRowChange": "\u041f\u0435\u0440\u0435\u0434\u041d\u0430\u0447\u0430\u043b\u043e\u043c\u0418\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u044f",
    "OnStartEdit": "\u041f\u0440\u0438\u041d\u0430\u0447\u0430\u043b\u0435\u0420\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f",
    "OnEndEdit": "\u041f\u0440\u0438\u041e\u043a\u043e\u043d\u0447\u0430\u043d\u0438\u0438\u0420\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f",
    "Selection": "\u0412\u044b\u0431\u043e\u0440\u0421\u0442\u0440\u043e\u043a\u0438",
    "OnCurrentPageChange": "\u041f\u0440\u0438\u0421\u043c\u0435\u043d\u0435\u0421\u0442\u0440\u0430\u043d\u0438\u0446\u044b",
    "TextEditEnd": "\u041e\u043a\u043e\u043d\u0447\u0430\u043d\u0438\u0435\u0412\u0432\u043e\u0434\u0430\u0422\u0435\u043a\u0441\u0442\u0430",
    "URLProcessing": "\u041e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430\u041d\u0430\u0432\u0438\u0433\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439\u0421\u0441\u044b\u043b\u043a\u0438",
    "DragStart": "\u041d\u0430\u0447\u0430\u043b\u043e\u041f\u0435\u0440\u0435\u0442\u0430\u0441\u043a\u0438\u0432\u0430\u043d\u0438\u044f",
    "Drag": "\u041f\u0435\u0440\u0435\u0442\u0430\u0441\u043a\u0438\u0432\u0430\u043d\u0438\u0435",
    "DragCheck": "\u041f\u0440\u043e\u0432\u0435\u0440\u043a\u0430\u041f\u0435\u0440\u0435\u0442\u0430\u0441\u043a\u0438\u0432\u0430\u043d\u0438\u044f",
    "Drop": "\u041f\u043e\u043c\u0435\u0449\u0435\u043d\u0438\u0435",
    "AfterDeleteRow": "\u041f\u043e\u0441\u043b\u0435\u0423\u0434\u0430\u043b\u0435\u043d\u0438\u044f",
}


def get_handler_name(element_name, event_name):
    suffix = event_suffix_map.get(event_name)
    if suffix:
        return f"{element_name}{suffix}"
    return f"{element_name}{event_name}"


# --- Element helpers ---

def get_element_name(el, type_key):
    if "name" in el and el["name"]:
        return str(el["name"])
    return str(el[type_key])


def _assert_edit_unique(name, seen, ctx):
    # Уникальность имён внутри JSON-определения (1С: своя коллекция — свой неймспейс).
    if name in seen:
        print(f"[ERROR] Duplicate {ctx} '{name}' in JSON definition — names must be unique in 1C form")
        sys.exit(1)
    seen.add(name)


known_events = {
    "input": ["OnChange", "StartChoice", "ChoiceProcessing", "AutoComplete", "TextEditEnd", "Clearing", "Creating", "EditTextChange"],
    "check": ["OnChange"],
    "label": ["Click", "URLProcessing"],
    "labelField": ["OnChange", "StartChoice", "ChoiceProcessing", "Click", "URLProcessing", "Clearing"],
    "table": ["Selection", "BeforeAddRow", "AfterDeleteRow", "BeforeDeleteRow", "OnActivateRow", "OnEditEnd", "OnStartEdit", "BeforeRowChange", "BeforeEditEnd", "ValueChoice", "OnActivateCell", "OnActivateField", "Drag", "DragStart", "DragCheck", "DragEnd", "OnGetDataAtServer", "BeforeLoadUserSettingsAtServer", "OnUpdateUserSettingSetAtServer", "OnChange"],
    "pages": ["OnCurrentPageChange"],
    "page": ["OnCurrentPageChange"],
    "button": ["Click"],
    "picField": ["OnChange", "StartChoice", "ChoiceProcessing", "Click", "Clearing"],
    "calendar": ["OnChange", "OnActivate"],
    "picture": ["Click"],
    "cmdBar": [],
    "popup": [],
    "group": [],
}


def emit_events(el, element_name, indent, type_key):
    on_list = el.get("on")
    if not on_list:
        return

    # Validate event names
    if type_key and type_key in known_events:
        allowed = known_events[type_key]
        for evt in on_list:
            evt_str = evt if isinstance(evt, str) else str(evt.get("event", ""))
            if allowed and evt_str not in allowed:
                print(f"[WARN] Unknown event '{evt_str}' for {type_key} '{element_name}'. Known: {', '.join(allowed)}")

    X(f"{indent}<Events>")
    handlers_map = el.get("handlers", {}) or {}
    for evt in on_list:
        if isinstance(evt, str):
            evt_name = evt
            handler = handlers_map.get(evt_name) or get_handler_name(element_name, evt_name)
            X(f'{indent}\t<Event name="{evt_name}">{handler}</Event>')
        elif not evt.get("event"):
            evt_name = str(evt)
            handler = handlers_map.get(evt_name) or get_handler_name(element_name, evt_name)
            X(f'{indent}\t<Event name="{evt_name}">{handler}</Event>')
        else:
            evt_name = str(evt["event"])
            handler = evt.get("handler") or handlers_map.get(evt_name) or get_handler_name(element_name, evt_name)
            call_type_attr = f' callType="{evt["callType"]}"' if evt.get("callType") else ""
            X(f'{indent}\t<Event name="{evt_name}"{call_type_attr}>{handler}</Event>')
    X(f"{indent}</Events>")


def emit_companion(tag, name, indent):
    _id = new_id()
    X(f'{indent}<{tag} name="{name}" id="{_id}"/>')


def emit_common_flags(el, indent):
    if el.get("visible") is False or el.get("hidden") is True:
        X(f"{indent}<Visible>false</Visible>")
    if el.get("enabled") is False or el.get("disabled") is True:
        X(f"{indent}<Enabled>false</Enabled>")
    if el.get("readOnly") is True:
        X(f"{indent}<ReadOnly>true</ReadOnly>")


def emit_title(el, name, indent):
    if el.get("title"):
        emit_mltext("Title", str(el["title"]), indent)


# --- Element emitters ---

def emit_group(el, name, _id, indent):
    X(f'{indent}<UsualGroup name="{name}" id="{_id}">')
    inner = indent + "\t"
    emit_title(el, name, inner)
    group_val = str(el.get("group", ""))
    orientation_map = {"horizontal": "Horizontal", "vertical": "Vertical", "alwaysHorizontal": "AlwaysHorizontal", "alwaysVertical": "AlwaysVertical"}
    orientation = orientation_map.get(group_val)
    if orientation:
        X(f"{inner}<Group>{orientation}</Group>")
    if group_val == "collapsible":
        X(f"{inner}<Group>Vertical</Group>")
        X(f"{inner}<Behavior>Collapsible</Behavior>")
    if el.get("representation"):
        repr_map = {"none": "None", "normal": "NormalSeparation", "weak": "WeakSeparation", "strong": "StrongSeparation"}
        repr_val = repr_map.get(str(el["representation"]), str(el["representation"]))
        X(f"{inner}<Representation>{repr_val}</Representation>")
    if el.get("showTitle") is False:
        X(f"{inner}<ShowTitle>false</ShowTitle>")
    if el.get("united") is False:
        X(f"{inner}<United>false</United>")
    emit_common_flags(el, inner)
    emit_companion("ExtendedTooltip", f"{name}\u0420\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u043d\u0430\u044f\u041f\u043e\u0434\u0441\u043a\u0430\u0437\u043a\u0430", inner)
    children = el.get("children")
    if children and len(children) > 0:
        X(f"{inner}<ChildItems>")
        for child in children:
            emit_element(child, inner + "\t")
        X(f"{inner}</ChildItems>")
    X(f"{indent}</UsualGroup>")


def emit_input(el, name, _id, indent):
    X(f'{indent}<InputField name="{name}" id="{_id}">')
    inner = indent + "\t"
    if el.get("path"):
        X(f"{inner}<DataPath>{el['path']}</DataPath>")
    emit_title(el, name, inner)
    emit_common_flags(el, inner)
    if el.get("titleLocation"):
        loc_map = {"none": "None", "left": "Left", "right": "Right", "top": "Top", "bottom": "Bottom"}
        loc = loc_map.get(str(el["titleLocation"]), str(el["titleLocation"]))
        X(f"{inner}<TitleLocation>{loc}</TitleLocation>")
    if el.get("multiLine") is True:
        X(f"{inner}<MultiLine>true</MultiLine>")
    if el.get("passwordMode") is True:
        X(f"{inner}<PasswordMode>true</PasswordMode>")
    if el.get("choiceButton") is False:
        X(f"{inner}<ChoiceButton>false</ChoiceButton>")
    if el.get("clearButton") is True:
        X(f"{inner}<ClearButton>true</ClearButton>")
    if el.get("spinButton") is True:
        X(f"{inner}<SpinButton>true</SpinButton>")
    if el.get("dropListButton") is True:
        X(f"{inner}<DropListButton>true</DropListButton>")
    if el.get("markIncomplete") is True:
        X(f"{inner}<AutoMarkIncomplete>true</AutoMarkIncomplete>")
    if el.get("skipOnInput") is True:
        X(f"{inner}<SkipOnInput>true</SkipOnInput>")
    if el.get("autoMaxWidth") is False:
        X(f"{inner}<AutoMaxWidth>false</AutoMaxWidth>")
    if el.get("autoMaxHeight") is False:
        X(f"{inner}<AutoMaxHeight>false</AutoMaxHeight>")
    if el.get("width"):
        X(f"{inner}<Width>{el['width']}</Width>")
    if el.get("height"):
        X(f"{inner}<Height>{el['height']}</Height>")
    if el.get("horizontalStretch") is True:
        X(f"{inner}<HorizontalStretch>true</HorizontalStretch>")
    if el.get("verticalStretch") is True:
        X(f"{inner}<VerticalStretch>true</VerticalStretch>")
    if el.get("inputHint"):
        emit_mltext("InputHint", str(el["inputHint"]), inner)
    emit_companion("ContextMenu", f"{name}\u041a\u043e\u043d\u0442\u0435\u043a\u0441\u0442\u043d\u043e\u0435\u041c\u0435\u043d\u044e", inner)
    emit_companion("ExtendedTooltip", f"{name}\u0420\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u043d\u0430\u044f\u041f\u043e\u0434\u0441\u043a\u0430\u0437\u043a\u0430", inner)
    emit_events(el, name, inner, "input")
    X(f"{indent}</InputField>")


def emit_check(el, name, _id, indent):
    X(f'{indent}<CheckBoxField name="{name}" id="{_id}">')
    inner = indent + "\t"
    if el.get("path"):
        X(f"{inner}<DataPath>{el['path']}</DataPath>")
    emit_title(el, name, inner)
    emit_common_flags(el, inner)
    if el.get("titleLocation"):
        X(f"{inner}<TitleLocation>{el['titleLocation']}</TitleLocation>")
    emit_companion("ContextMenu", f"{name}\u041a\u043e\u043d\u0442\u0435\u043a\u0441\u0442\u043d\u043e\u0435\u041c\u0435\u043d\u044e", inner)
    emit_companion("ExtendedTooltip", f"{name}\u0420\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u043d\u0430\u044f\u041f\u043e\u0434\u0441\u043a\u0430\u0437\u043a\u0430", inner)
    emit_events(el, name, inner, "check")
    X(f"{indent}</CheckBoxField>")


def emit_label(el, name, _id, indent):
    X(f'{indent}<LabelDecoration name="{name}" id="{_id}">')
    inner = indent + "\t"
    if el.get("title"):
        formatted = "true" if el.get("hyperlink") is True else "false"
        X(f'{inner}<Title formatted="{formatted}">')
        X(f"{inner}\t<v8:item>")
        X(f"{inner}\t\t<v8:lang>ru</v8:lang>")
        X(f"{inner}\t\t<v8:content>{esc_xml_text(str(el['title']))}</v8:content>")
        X(f"{inner}\t</v8:item>")
        X(f"{inner}</Title>")
    emit_common_flags(el, inner)
    if el.get("hyperlink") is True:
        X(f"{inner}<Hyperlink>true</Hyperlink>")
    if el.get("autoMaxWidth") is False:
        X(f"{inner}<AutoMaxWidth>false</AutoMaxWidth>")
    if el.get("autoMaxHeight") is False:
        X(f"{inner}<AutoMaxHeight>false</AutoMaxHeight>")
    if el.get("width"):
        X(f"{inner}<Width>{el['width']}</Width>")
    if el.get("height"):
        X(f"{inner}<Height>{el['height']}</Height>")
    emit_companion("ContextMenu", f"{name}\u041a\u043e\u043d\u0442\u0435\u043a\u0441\u0442\u043d\u043e\u0435\u041c\u0435\u043d\u044e", inner)
    emit_companion("ExtendedTooltip", f"{name}\u0420\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u043d\u0430\u044f\u041f\u043e\u0434\u0441\u043a\u0430\u0437\u043a\u0430", inner)
    emit_events(el, name, inner, "label")
    X(f"{indent}</LabelDecoration>")


def emit_label_field(el, name, _id, indent):
    X(f'{indent}<LabelField name="{name}" id="{_id}">')
    inner = indent + "\t"
    if el.get("path"):
        X(f"{inner}<DataPath>{el['path']}</DataPath>")
    emit_title(el, name, inner)
    emit_common_flags(el, inner)
    if el.get("hyperlink") is True:
        X(f"{inner}<Hyperlink>true</Hyperlink>")
    emit_companion("ContextMenu", f"{name}\u041a\u043e\u043d\u0442\u0435\u043a\u0441\u0442\u043d\u043e\u0435\u041c\u0435\u043d\u044e", inner)
    emit_companion("ExtendedTooltip", f"{name}\u0420\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u043d\u0430\u044f\u041f\u043e\u0434\u0441\u043a\u0430\u0437\u043a\u0430", inner)
    emit_events(el, name, inner, "labelField")
    X(f"{indent}</LabelField>")


def emit_table(el, name, _id, indent):
    X(f'{indent}<Table name="{name}" id="{_id}">')
    inner = indent + "\t"
    if el.get("path"):
        X(f"{inner}<DataPath>{el['path']}</DataPath>")
    emit_title(el, name, inner)
    emit_common_flags(el, inner)
    if el.get("representation"):
        X(f"{inner}<Representation>{el['representation']}</Representation>")
    if el.get("changeRowSet") is True:
        X(f"{inner}<ChangeRowSet>true</ChangeRowSet>")
    if el.get("changeRowOrder") is True:
        X(f"{inner}<ChangeRowOrder>true</ChangeRowOrder>")
    if el.get("height"):
        X(f"{inner}<HeightInTableRows>{el['height']}</HeightInTableRows>")
    if el.get("header") is False:
        X(f"{inner}<Header>false</Header>")
    if el.get("footer") is True:
        X(f"{inner}<Footer>true</Footer>")
    if el.get("commandBarLocation"):
        X(f"{inner}<CommandBarLocation>{el['commandBarLocation']}</CommandBarLocation>")
    if el.get("searchStringLocation"):
        X(f"{inner}<SearchStringLocation>{el['searchStringLocation']}</SearchStringLocation>")
    emit_companion("ContextMenu", f"{name}\u041a\u043e\u043d\u0442\u0435\u043a\u0441\u0442\u043d\u043e\u0435\u041c\u0435\u043d\u044e", inner)
    emit_companion("AutoCommandBar", f"{name}\u041a\u043e\u043c\u0430\u043d\u0434\u043d\u0430\u044f\u041f\u0430\u043d\u0435\u043b\u044c", inner)
    emit_companion("SearchStringAddition", f"{name}\u0421\u0442\u0440\u043e\u043a\u0430\u041f\u043e\u0438\u0441\u043a\u0430", inner)
    emit_companion("ViewStatusAddition", f"{name}\u0421\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0435\u041f\u0440\u043e\u0441\u043c\u043e\u0442\u0440\u0430", inner)
    emit_companion("SearchControlAddition", f"{name}\u0423\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u0435\u041f\u043e\u0438\u0441\u043a\u043e\u043c", inner)
    columns = el.get("columns")
    if columns and len(columns) > 0:
        X(f"{inner}<ChildItems>")
        for col in columns:
            emit_element(col, inner + "\t")
        X(f"{inner}</ChildItems>")
    emit_events(el, name, inner, "table")
    X(f"{indent}</Table>")


def emit_pages(el, name, _id, indent):
    X(f'{indent}<Pages name="{name}" id="{_id}">')
    inner = indent + "\t"
    if el.get("pagesRepresentation"):
        X(f"{inner}<PagesRepresentation>{el['pagesRepresentation']}</PagesRepresentation>")
    emit_common_flags(el, inner)
    emit_companion("ExtendedTooltip", f"{name}\u0420\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u043d\u0430\u044f\u041f\u043e\u0434\u0441\u043a\u0430\u0437\u043a\u0430", inner)
    emit_events(el, name, inner, "pages")
    children = el.get("children")
    if children and len(children) > 0:
        X(f"{inner}<ChildItems>")
        for child in children:
            emit_element(child, inner + "\t")
        X(f"{inner}</ChildItems>")
    X(f"{indent}</Pages>")


def emit_page(el, name, _id, indent):
    X(f'{indent}<Page name="{name}" id="{_id}">')
    inner = indent + "\t"
    emit_title(el, name, inner)
    emit_common_flags(el, inner)
    if el.get("group"):
        orientation_map = {"horizontal": "Horizontal", "vertical": "Vertical", "alwaysHorizontal": "AlwaysHorizontal", "alwaysVertical": "AlwaysVertical"}
        orientation = orientation_map.get(str(el["group"]))
        if orientation:
            X(f"{inner}<Group>{orientation}</Group>")
    emit_companion("ExtendedTooltip", f"{name}\u0420\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u043d\u0430\u044f\u041f\u043e\u0434\u0441\u043a\u0430\u0437\u043a\u0430", inner)
    children = el.get("children")
    if children and len(children) > 0:
        X(f"{inner}<ChildItems>")
        for child in children:
            emit_element(child, inner + "\t")
        X(f"{inner}</ChildItems>")
    X(f"{indent}</Page>")


def emit_button(el, name, _id, indent):
    X(f'{indent}<Button name="{name}" id="{_id}">')
    inner = indent + "\t"
    if el.get("type"):
        btn_map = {"usual": "UsualButton", "hyperlink": "Hyperlink", "commandBar": "CommandBarButton"}
        btn_type = btn_map.get(str(el["type"]), str(el["type"]))
        X(f"{inner}<Type>{btn_type}</Type>")
    if el.get("command"):
        X(f"{inner}<CommandName>Form.Command.{el['command']}</CommandName>")
    if el.get("stdCommand"):
        sc = str(el["stdCommand"])
        m = re.match(r'^(.+)\.(.+)$', sc)
        if m:
            X(f"{inner}<CommandName>Form.Item.{m.group(1)}.StandardCommand.{m.group(2)}</CommandName>")
        else:
            X(f"{inner}<CommandName>Form.StandardCommand.{sc}</CommandName>")
    emit_title(el, name, inner)
    emit_common_flags(el, inner)
    if el.get("defaultButton") is True:
        X(f"{inner}<DefaultButton>true</DefaultButton>")
    if el.get("picture"):
        X(f"{inner}<Picture>")
        X(f"{inner}\t<xr:Ref>{el['picture']}</xr:Ref>")
        X(f"{inner}\t<xr:LoadTransparent>true</xr:LoadTransparent>")
        X(f"{inner}</Picture>")
    if el.get("representation"):
        X(f"{inner}<Representation>{el['representation']}</Representation>")
    if el.get("locationInCommandBar"):
        X(f"{inner}<LocationInCommandBar>{el['locationInCommandBar']}</LocationInCommandBar>")
    emit_companion("ExtendedTooltip", f"{name}\u0420\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u043d\u0430\u044f\u041f\u043e\u0434\u0441\u043a\u0430\u0437\u043a\u0430", inner)
    emit_events(el, name, inner, "button")
    X(f"{indent}</Button>")


def emit_picture_decoration(el, name, _id, indent):
    X(f'{indent}<PictureDecoration name="{name}" id="{_id}">')
    inner = indent + "\t"
    emit_title(el, name, inner)
    emit_common_flags(el, inner)
    ref = el.get("src") or el.get("picture")
    if ref:
        X(f"{inner}<Picture>")
        X(f"{inner}\t<xr:Ref>{ref}</xr:Ref>")
        X(f"{inner}\t<xr:LoadTransparent>true</xr:LoadTransparent>")
        X(f"{inner}</Picture>")
    if el.get("hyperlink") is True:
        X(f"{inner}<Hyperlink>true</Hyperlink>")
    if el.get("width"):
        X(f"{inner}<Width>{el['width']}</Width>")
    if el.get("height"):
        X(f"{inner}<Height>{el['height']}</Height>")
    emit_companion("ContextMenu", f"{name}\u041a\u043e\u043d\u0442\u0435\u043a\u0441\u0442\u043d\u043e\u0435\u041c\u0435\u043d\u044e", inner)
    emit_companion("ExtendedTooltip", f"{name}\u0420\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u043d\u0430\u044f\u041f\u043e\u0434\u0441\u043a\u0430\u0437\u043a\u0430", inner)
    emit_events(el, name, inner, "picture")
    X(f"{indent}</PictureDecoration>")


def emit_picture_field(el, name, _id, indent):
    X(f'{indent}<PictureField name="{name}" id="{_id}">')
    inner = indent + "\t"
    if el.get("path"):
        X(f"{inner}<DataPath>{el['path']}</DataPath>")
    emit_title(el, name, inner)
    emit_common_flags(el, inner)
    if el.get("width"):
        X(f"{inner}<Width>{el['width']}</Width>")
    if el.get("height"):
        X(f"{inner}<Height>{el['height']}</Height>")
    emit_companion("ContextMenu", f"{name}\u041a\u043e\u043d\u0442\u0435\u043a\u0441\u0442\u043d\u043e\u0435\u041c\u0435\u043d\u044e", inner)
    emit_companion("ExtendedTooltip", f"{name}\u0420\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u043d\u0430\u044f\u041f\u043e\u0434\u0441\u043a\u0430\u0437\u043a\u0430", inner)
    emit_events(el, name, inner, "picField")
    X(f"{indent}</PictureField>")


def emit_calendar(el, name, _id, indent):
    X(f'{indent}<CalendarField name="{name}" id="{_id}">')
    inner = indent + "\t"
    if el.get("path"):
        X(f"{inner}<DataPath>{el['path']}</DataPath>")
    emit_title(el, name, inner)
    emit_common_flags(el, inner)
    emit_companion("ContextMenu", f"{name}\u041a\u043e\u043d\u0442\u0435\u043a\u0441\u0442\u043d\u043e\u0435\u041c\u0435\u043d\u044e", inner)
    emit_companion("ExtendedTooltip", f"{name}\u0420\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u043d\u0430\u044f\u041f\u043e\u0434\u0441\u043a\u0430\u0437\u043a\u0430", inner)
    emit_events(el, name, inner, "calendar")
    X(f"{indent}</CalendarField>")


def emit_command_bar_el(el, name, _id, indent):
    X(f'{indent}<CommandBar name="{name}" id="{_id}">')
    inner = indent + "\t"
    if el.get("autofill") is True:
        X(f"{inner}<Autofill>true</Autofill>")
    emit_common_flags(el, inner)
    children = el.get("children")
    if children and len(children) > 0:
        X(f"{inner}<ChildItems>")
        for child in children:
            emit_element(child, inner + "\t")
        X(f"{inner}</ChildItems>")
    X(f"{indent}</CommandBar>")


def emit_popup(el, name, _id, indent):
    X(f'{indent}<Popup name="{name}" id="{_id}">')
    inner = indent + "\t"
    emit_title(el, name, inner)
    emit_common_flags(el, inner)
    if el.get("picture"):
        X(f"{inner}<Picture>")
        X(f"{inner}\t<xr:Ref>{el['picture']}</xr:Ref>")
        X(f"{inner}\t<xr:LoadTransparent>true</xr:LoadTransparent>")
        X(f"{inner}</Picture>")
    if el.get("representation"):
        X(f"{inner}<Representation>{el['representation']}</Representation>")
    children = el.get("children")
    if children and len(children) > 0:
        X(f"{inner}<ChildItems>")
        for child in children:
            emit_element(child, inner + "\t")
        X(f"{inner}</ChildItems>")
    X(f"{indent}</Popup>")


# --- Element dispatcher ---

ELEMENT_KEYS = ["group", "input", "check", "label", "labelField", "table", "pages", "page", "button", "picture", "picField", "calendar", "cmdBar", "popup"]

KNOWN_KEYS = {
    "group", "input", "check", "label", "labelField", "table", "pages", "page",
    "button", "picture", "picField", "calendar", "cmdBar", "popup",
    "name", "path", "title",
    "visible", "hidden", "enabled", "disabled", "readOnly",
    "on", "handlers",
    "titleLocation", "representation", "width", "height",
    "horizontalStretch", "verticalStretch", "autoMaxWidth", "autoMaxHeight",
    "multiLine", "passwordMode", "choiceButton", "clearButton",
    "spinButton", "dropListButton", "markIncomplete", "skipOnInput", "inputHint",
    "hyperlink", "showTitle", "united", "children", "columns",
    "changeRowSet", "changeRowOrder", "header", "footer",
    "commandBarLocation", "searchStringLocation", "pagesRepresentation",
    "type", "command", "stdCommand", "defaultButton", "locationInCommandBar",
    "src", "autofill",
}

EMITTER_MAP = {
    "group": emit_group,
    "input": emit_input,
    "check": emit_check,
    "label": emit_label,
    "labelField": emit_label_field,
    "table": emit_table,
    "pages": emit_pages,
    "page": emit_page,
    "button": emit_button,
    "picture": emit_picture_decoration,
    "picField": emit_picture_field,
    "calendar": emit_calendar,
    "cmdBar": emit_command_bar_el,
    "popup": emit_popup,
}


def emit_element(el, indent):
    type_key = None
    for key in ELEMENT_KEYS:
        if key in el and el[key] is not None:
            type_key = key
            break
    if not type_key:
        print("[WARN] Unknown element type, skipping")
        return

    # Validate known keys
    for p in el:
        if p not in KNOWN_KEYS:
            print(f"[WARN] Element '{el[type_key]}': unknown key '{p}' -- ignored.")

    name = get_element_name(el, type_key)
    _id = new_id()

    emitter = EMITTER_MAP.get(type_key)
    if emitter:
        emitter(el, name, _id, indent)


# ── 6. Find element by name recursively ─────────────────────

def find_element(start_node, target_name):
    for child in start_node:
        if not isinstance(child.tag, str):
            continue
        child_name = child.get("name")
        if child_name == target_name:
            return child
        ci = child.find("f:ChildItems", NS)
        if ci is not None:
            found = find_element(ci, target_name)
            if found is not None:
                return found
    return None


# ── 7. Detect indent level of a container's children ────────

def get_child_indent(container):
    for child_node in container:
        if not isinstance(child_node.tag, str):
            # text nodes - check preceding/following text
            pass
    # Check text content of container (tail/text)
    for i, child in enumerate(container):
        # Check text before this child
        if i == 0:
            txt = container.text
        else:
            txt = container[i - 1].tail
        if txt:
            m = re.search(r'\n(\t+)$', txt)
            if m:
                return m.group(1)

    # Fallback: count depth from root
    depth = 0
    current = container
    while current is not None:
        parent = current.getparent()
        if parent is None:
            break
        depth += 1
        current = parent
    return "\t" * (depth + 1)


# ── 8. Insert node into container ───────────────────────────

def insert_into_container(container, new_node, after_name, child_indent):
    ref_idx = None

    if after_name:
        # Find the after-element
        after_elem = None
        for i, child in enumerate(container):
            if isinstance(child.tag, str) and child.get("name") == after_name:
                after_elem = child
                ref_idx = i + 1
                break
        if after_elem is None:
            print(f"[WARN] after='{after_name}' not found in target container, appending at end")

    children = list(container)
    if ref_idx is not None:
        # Insert after the after-element
        if ref_idx < len(children):
            children[ref_idx - 1].tail = "\n" + child_indent
            children[ref_idx - 1].addnext(new_node)
            new_node.tail = "\n" + child_indent
        else:
            # Append at end
            if len(children) > 0:
                children[-1].tail = "\n" + child_indent
            container.append(new_node)
            parent_indent = child_indent[:-1] if len(child_indent) > 1 else ""
            new_node.tail = "\n" + parent_indent
    else:
        # Append at end
        if len(children) > 0:
            # Insert before trailing whitespace (append after last child)
            children[-1].tail = "\n" + child_indent
            container.append(new_node)
            parent_indent = child_indent[:-1] if len(child_indent) > 1 else ""
            new_node.tail = "\n" + parent_indent
        else:
            # Container is empty
            container.text = "\n" + child_indent
            container.append(new_node)
            parent_indent = child_indent[:-1] if len(child_indent) > 1 else ""
            new_node.tail = "\n" + parent_indent


# ── 9. Generate fragment, parse, import nodes ────────────────

def parse_fragment(xml_text):
    frag_parser = etree.XMLParser(remove_blank_text=False)
    frag_doc = etree.fromstring(xml_text.encode("utf-8"), frag_parser)
    return frag_doc


def import_element_nodes(frag_root):
    nodes = []
    for child in frag_root:
        if isinstance(child.tag, str):
            nodes.append(child)
    return nodes


# ── 10. Add elements ────────────────────────────────────────

added_elems = []
companion_count = 0

elements_list = defn.get("elements") or []
if elements_list:
    # Resolve target container
    target_ci = None
    into_name = defn.get("into")
    after_name = defn.get("after")

    if into_name:
        target_group = find_element(root_ci, into_name)
        if target_group is None:
            print(f"[ERROR] Target group '{into_name}' not found")
            sys.exit(1)
        target_ci = target_group.find("f:ChildItems", NS)
        if target_ci is None:
            # Create ChildItems for the group
            target_ci = etree.SubElement(target_group, f"{{{FORM_NS}}}ChildItems")
    elif after_name:
        after_elem = find_element(root_ci, after_name)
        if after_elem is None:
            print(f"[ERROR] Element '{after_name}' not found")
            sys.exit(1)
        target_ci = after_elem.getparent()
    else:
        target_ci = root_ci

    if target_ci is None:
        # Create ChildItems section in form — insert after Events or AutoCommandBar
        target_ci = etree.Element(f"{{{FORM_NS}}}ChildItems")
        insert_after = root.find("f:Events", NS)
        if insert_after is None:
            insert_after = root.find("f:AutoCommandBar", NS)
        if insert_after is not None:
            idx = list(root).index(insert_after) + 1
            root.insert(idx, target_ci)
        else:
            root.append(target_ci)
        root_ci = target_ci

    # Detect indent level
    child_indent = get_child_indent(target_ci)

    # Имена элементов уникальны (требование 1С). Сначала — внутри самого JSON-определения
    # (рекурсивно по children/columns).
    def _walk_elem_names(el, seen):
        tk = None
        for key in ELEMENT_KEYS:
            if key in el and el[key] is not None:
                tk = key
                break
        if tk:
            _assert_edit_unique(get_element_name(el, tk), seen, "element name")
        for c in el.get("children", []):
            _walk_elem_names(c, seen)
        for c in el.get("columns", []):
            _walk_elem_names(c, seen)

    dsl_elem_names = set()
    for el in elements_list:
        _walk_elem_names(el, dsl_elem_names)

    # Затем — против уже существующих элементов формы (дубль = битый XML, форма не откроется).
    for el in elements_list:
        type_key = None
        for key in ELEMENT_KEYS:
            if key in el and el[key] is not None:
                type_key = key
                break
        if type_key:
            el_name = get_element_name(el, type_key)
            existing = find_element(root_ci, el_name) if root_ci is not None else None
            if existing is not None:
                print(f"[ERROR] Element '{el_name}' already exists in form (id={existing.get('id')}) — element names must be unique")
                sys.exit(1)

    # Remember starting element ID for companion counting
    start_elem_id = next_elem_id

    # Generate fragment
    xml_lines.clear()
    X(f"<_F {ALL_NS_DECL}>")
    for el in elements_list:
        emit_element(el, child_indent)
    X("</_F>")

    frag_text = "\n".join(xml_lines)
    frag_root = parse_fragment(frag_text)
    imported_nodes = import_element_nodes(frag_root)

    # Count actual elements for reporting
    tag_map = {
        "group": "Group", "input": "Input", "check": "Check", "label": "Label", "labelField": "LabelField",
        "table": "Table", "pages": "Pages", "page": "Page", "button": "Button",
        "picture": "Picture", "picField": "PicField", "calendar": "Calendar", "cmdBar": "CmdBar", "popup": "Popup",
    }
    for el in elements_list:
        type_key = None
        for key in ELEMENT_KEYS:
            if key in el and el[key] is not None:
                type_key = key
                break
        name = get_element_name(el, type_key)
        path_str = f" -> {el['path']}" if el.get("path") else ""
        on_list = el.get("on")
        evt_str = f" {{{', '.join(str(e) for e in on_list)}}}" if on_list else ""
        added_elems.append(f"  + [{tag_map.get(type_key, type_key)}] {name}{path_str}{evt_str}")

    # Insert each imported node
    for node in imported_nodes:
        insert_into_container(target_ci, node, after_name, child_indent)
        after_name = node.get("name")

    total_new_elem_ids = next_elem_id - start_elem_id
    companion_count = total_new_elem_ids - len(elements_list)

# ── 11. Add attributes ──────────────────────────────────────

added_attrs = []

attrs_list = defn.get("attributes") or []
if attrs_list:
    attrs_section = root.find("f:Attributes", NS)
    if attrs_section is None:
        attrs_section = etree.SubElement(root, f"{{{FORM_NS}}}Attributes")

    attr_child_indent = get_child_indent(attrs_section)
    if not attr_child_indent:
        attr_child_indent = "\t\t"

    # Уникальность имён реквизитов: внутри JSON-определения (+ колонки в пределах реквизита) и
    # против уже существующих реквизитов формы.
    dsl_attr_names = set()
    for attr in attrs_list:
        _assert_edit_unique(str(attr["name"]), dsl_attr_names, "attribute name")
        if attr.get("columns"):
            dsl_col_names = set()
            for col in attr["columns"]:
                _assert_edit_unique(str(col["name"]), dsl_col_names, f"column name of '{attr['name']}'")
        if attrs_section.find(f"f:Attribute[@name='{attr['name']}']", NS) is not None:
            print(f"[ERROR] Attribute '{attr['name']}' already exists in form — attribute names must be unique")
            sys.exit(1)

    # Generate attribute fragments
    xml_lines.clear()
    X(f"<_F {ALL_NS_DECL}>")
    for attr in attrs_list:
        attr_id = new_attr_id()
        attr_name = str(attr["name"])
        X(f'{attr_child_indent}<Attribute name="{attr_name}" id="{attr_id}">')
        inner = attr_child_indent + "\t"

        if attr.get("title"):
            emit_mltext("Title", str(attr["title"]), inner)
        if attr.get("type"):
            emit_type(str(attr["type"]), inner)
        else:
            X(f"{inner}<Type/>")
        if attr.get("main") is True:
            X(f"{inner}<MainAttribute>true</MainAttribute>")
        if attr.get("savedData") is True:
            X(f"{inner}<SavedData>true</SavedData>")
        if attr.get("fillChecking"):
            X(f"{inner}<FillChecking>{attr['fillChecking']}</FillChecking>")

        columns = attr.get("columns")
        if columns and len(columns) > 0:
            X(f"{inner}<Columns>")
            col_id = 1
            for col in columns:
                X(f'{inner}\t<Column name="{col["name"]}" id="{col_id}">')
                if col.get("title"):
                    emit_mltext("Title", str(col["title"]), inner + "\t\t")
                emit_type(str(col["type"]), inner + "\t\t")
                X(f'{inner}\t</Column>')
                col_id += 1
            X(f"{inner}</Columns>")

        X(f"{attr_child_indent}</Attribute>")
        type_str = str(attr["type"]) if attr.get("type") else "(no type)"
        added_attrs.append(f"  + {attr_name}: {type_str} (id={attr_id})")
    X("</_F>")

    frag_text = "\n".join(xml_lines)
    frag_root = parse_fragment(frag_text)
    imported_attrs = import_element_nodes(frag_root)

    for node in imported_attrs:
        insert_into_container(attrs_section, node, None, attr_child_indent)

# ── 12. Add commands ────────────────────────────────────────

added_cmds = []

cmds_list = defn.get("commands") or []
if cmds_list:
    cmds_section = root.find("f:Commands", NS)
    if cmds_section is None:
        cmds_section = etree.SubElement(root, f"{{{FORM_NS}}}Commands")

    cmd_child_indent = get_child_indent(cmds_section)
    if not cmd_child_indent:
        cmd_child_indent = "\t\t"

    # Уникальность имён команд: внутри JSON-определения и против существующих команд формы.
    dsl_cmd_names = set()
    for cmd in cmds_list:
        _assert_edit_unique(str(cmd["name"]), dsl_cmd_names, "command name")
        if cmds_section.find(f"f:Command[@name='{cmd['name']}']", NS) is not None:
            print(f"[ERROR] Command '{cmd['name']}' already exists in form — command names must be unique")
            sys.exit(1)

    xml_lines.clear()
    X(f"<_F {ALL_NS_DECL}>")
    for cmd in cmds_list:
        cmd_id = new_cmd_id()
        cmd_name = str(cmd["name"])
        X(f'{cmd_child_indent}<Command name="{cmd_name}" id="{cmd_id}">')
        inner = cmd_child_indent + "\t"

        if cmd.get("title"):
            emit_mltext("Title", str(cmd["title"]), inner)

        if cmd.get("actions"):
            for act in cmd["actions"]:
                act_handler = str(act["handler"])
                call_type_attr = f' callType="{act["callType"]}"' if act.get("callType") else ""
                X(f"{inner}<Action{call_type_attr}>{act_handler}</Action>")
        elif cmd.get("action"):
            call_type_attr = f' callType="{cmd["callType"]}"' if cmd.get("callType") else ""
            X(f"{inner}<Action{call_type_attr}>{cmd['action']}</Action>")

        if cmd.get("shortcut"):
            X(f"{inner}<Shortcut>{cmd['shortcut']}</Shortcut>")
        if cmd.get("picture"):
            X(f"{inner}<Picture>")
            X(f"{inner}\t<xr:Ref>{cmd['picture']}</xr:Ref>")
            X(f"{inner}\t<xr:LoadTransparent>true</xr:LoadTransparent>")
            X(f"{inner}</Picture>")
        if cmd.get("representation"):
            X(f"{inner}<Representation>{cmd['representation']}</Representation>")

        X(f"{cmd_child_indent}</Command>")
        action_str = ""
        if cmd.get("action"):
            action_str = f" -> {cmd['action']}"
        elif cmd.get("actions"):
            action_str = f" -> {len(cmd['actions'])} action(s)"
        added_cmds.append(f"  + {cmd_name}{action_str} (id={cmd_id})")
    X("</_F>")

    frag_text = "\n".join(xml_lines)
    frag_root = parse_fragment(frag_text)
    imported_cmds = import_element_nodes(frag_root)

    for node in imported_cmds:
        insert_into_container(cmds_section, node, None, cmd_child_indent)

# ── 12b. Add form-level events ──────────────────────────────

added_form_events = []

form_events_list = defn.get("formEvents") or []
if form_events_list:
    events_section = root.find("f:Events", NS)
    if events_section is None:
        events_section = etree.Element(f"{{{FORM_NS}}}Events")
        # Insert after AutoCommandBar (Events come after AutoCommandBar in 1C)
        acb_node = root.find("f:AutoCommandBar", NS)
        if acb_node is not None:
            acb_idx = list(root).index(acb_node)
            acb_node.tail = (acb_node.tail or "") + "\r\n\t"
            root.insert(acb_idx + 1, events_section)
        else:
            root.append(events_section)

    evt_child_indent = get_child_indent(events_section)
    if not evt_child_indent:
        evt_child_indent = "\t\t"

    xml_lines.clear()
    X(f"<_F {ALL_NS_DECL}>")
    for fe in form_events_list:
        fe_name = str(fe["name"])
        fe_handler = str(fe["handler"])
        call_type_attr = f' callType="{fe["callType"]}"' if fe.get("callType") else ""
        X(f'{evt_child_indent}<Event name="{fe_name}"{call_type_attr}>{fe_handler}</Event>')
        ct_str = f"[{fe['callType']}]" if fe.get("callType") else ""
        added_form_events.append(f"  + {fe_name}{ct_str} -> {fe_handler}")
    X("</_F>")

    frag_text = "\n".join(xml_lines)
    frag_root = parse_fragment(frag_text)
    imported_events = import_element_nodes(frag_root)

    for node in imported_events:
        insert_into_container(events_section, node, None, evt_child_indent)

# ── 12c. Add element-level events ───────────────────────────

added_elem_events = []

elem_events_list = defn.get("elementEvents") or []
if elem_events_list:
    if root_ci is None:
        root_ci = root.find("f:ChildItems", NS)

    for ee in elem_events_list:
        target_name = str(ee["element"])
        target_el = find_element(root_ci, target_name)
        if target_el is None:
            print(f"[WARN] Element '{target_name}' not found -- skipping elementEvent")
            continue

        # Find or create Events element within the target
        target_events = target_el.find("f:Events", NS)
        if target_events is None:
            target_events = etree.SubElement(target_el, f"{{{FORM_NS}}}Events")

        ee_child_indent = get_child_indent(target_events)
        if not ee_child_indent:
            parent_indent = get_child_indent(target_el)
            ee_child_indent = parent_indent + "\t"

        ee_name = str(ee["name"])
        ee_handler = str(ee["handler"])
        call_type_attr = f' callType="{ee["callType"]}"' if ee.get("callType") else ""

        xml_lines.clear()
        X(f"<_F {ALL_NS_DECL}>")
        X(f'{ee_child_indent}<Event name="{ee_name}"{call_type_attr}>{ee_handler}</Event>')
        X("</_F>")

        frag_text = "\n".join(xml_lines)
        frag_root = parse_fragment(frag_text)
        imported_ee = import_element_nodes(frag_root)

        for node in imported_ee:
            insert_into_container(target_events, node, None, ee_child_indent)

        ct_str = f"[{ee['callType']}]" if ee.get("callType") else ""
        added_elem_events.append(f"  + {target_name}.{ee_name}{ct_str} -> {ee_handler}")

# ── 13. Save ────────────────────────────────────────────────

# Round-trip: определить стиль исходного файла (на диске он ещё не перезаписан).
try:
    _fe_raw = open(resolved_form_path, "rb").read()
except OSError:
    _fe_raw = None
if _fe_raw is not None:
    _fe_bom = _fe_raw.startswith(b"\xef\xbb\xbf")
    _fe_body = _fe_raw[3:] if _fe_bom else _fe_raw
    _fe_crlf = b"\r\n" in _fe_body
    _fe_enc_m = re.search(rb'encoding="([^"]+)"', _fe_body[:200])
    _fe_enc = _fe_enc_m.group(1).decode("ascii") if _fe_enc_m else "utf-8"
    _fe_final_nl = _fe_body.endswith(b"\n")
else:
    _fe_bom, _fe_crlf, _fe_enc, _fe_final_nl = True, False, "utf-8", True

xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
# Восстановить регистр encoding как в оригинале.
xml_bytes = xml_bytes.replace(
    b"<?xml version='1.0' encoding='UTF-8'?>",
    b'<?xml version="1.0" encoding="' + _fe_enc.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"))
# Финальный перенос — как в оригинале.
xml_bytes = xml_bytes.rstrip(b"\n")
if _fe_final_nl:
    xml_bytes += b"\n"
# EOL — как в оригинале.
if _fe_crlf:
    xml_bytes = xml_bytes.replace(b"\n", b"\r\n")
# Write preserving BOM as in original.
with open(resolved_form_path, "wb") as f:
    if _fe_bom:
        f.write(b'\xef\xbb\xbf')
    f.write(xml_bytes)

# ── 14. Summary ─────────────────────────────────────────────

if is_extension:
    print("[EXTENSION] BaseForm detected -- IDs start at 1000000+")
    print()

if added_form_events:
    print("Added form events:")
    for line in added_form_events:
        print(line)
    print()

if added_elem_events:
    print("Added element events:")
    for line in added_elem_events:
        print(line)
    print()

if added_elems:
    pos_str = ""
    if defn.get("into"):
        pos_str += f"into {defn['into']}"
    if defn.get("after"):
        if pos_str:
            pos_str += ", "
        pos_str += f"after {defn['after']}"
    if pos_str:
        pos_str = f" ({pos_str})"
    print(f"Added elements{pos_str}:")
    for line in added_elems:
        print(line)
    print()

if added_attrs:
    print("Added attributes:")
    for line in added_attrs:
        print(line)
    print()

if added_cmds:
    print("Added commands:")
    for line in added_cmds:
        print(line)
    print()

print("---")
total_parts = []
if added_form_events:
    total_parts.append(f"{len(added_form_events)} form event(s)")
if added_elem_events:
    total_parts.append(f"{len(added_elem_events)} element event(s)")
if added_elems:
    comp_str = f" (+{companion_count} companions)" if companion_count > 0 else ""
    total_parts.append(f"{len(added_elems)} element(s){comp_str}")
if added_attrs:
    total_parts.append(f"{len(added_attrs)} attribute(s)")
if added_cmds:
    total_parts.append(f"{len(added_cmds)} command(s)")
print(f"Total: {', '.join(total_parts)}")
print("Run /form-validate to verify.")
