#!/usr/bin/env python3
# form-compile v1.196 — Compile 1C managed form from JSON or object metadata (гвард на группу additionalColumns без ключа columns)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
import argparse
import copy
import json
import os
import re
import sys
import uuid
import xml.etree.ElementTree as ET
from collections import OrderedDict

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

# ═══════════════════════════════════════════════════════════════════════════
# FROM-OBJECT MODE: functions for metadata parsing, presets, DSL generation
# ═══════════════════════════════════════════════════════════════════════════

NS = {
    'md': 'http://v8.1c.ru/8.3/MDClasses',
    'xr': 'http://v8.1c.ru/8.3/xcf/readable',
    'v8': 'http://v8.1c.ru/8.1/data/core',
}


def _et_find(node, path):
    """Find with namespace map."""
    return node.find(path, NS)


def _et_findall(node, path):
    """Findall with namespace map."""
    return node.findall(path, NS)


def _et_text(node, path, default=''):
    """Get text of a sub-element, or default."""
    el = node.find(path, NS)
    return el.text if el is not None and el.text else default


def parse_object_meta(object_path):
    """Parse 1C metadata XML and return dict with Type, Name, Synonym, Attributes, TabularSections, etc."""
    tree = ET.parse(object_path)
    root = tree.getroot()

    # Detect object type from root child
    meta_root = _et_find(root, '.')
    # Root is MetaDataObject; first child is the type node
    type_node = None
    for child in root:
        type_node = child
        break
    if type_node is None:
        print("Not a 1C metadata XML: " + object_path, file=sys.stderr)
        sys.exit(1)

    # Extract local name (strip namespace)
    obj_type = type_node.tag.split('}')[-1] if '}' in type_node.tag else type_node.tag

    props_node = _et_find(type_node, 'md:Properties')
    child_objs = _et_find(type_node, 'md:ChildObjects')

    # Name
    obj_name = _et_text(props_node, 'md:Name')

    # Synonym (Russian)
    synonym = obj_name
    syn_node = _et_find(props_node, "md:Synonym/v8:item[v8:lang='ru']/v8:content")
    if syn_node is not None and syn_node.text:
        synonym = syn_node.text

    def extract_type(type_parent):
        """Extract type string from md:Type element."""
        if type_parent is None:
            return 'string'
        types = []
        for t in _et_findall(type_parent, 'v8:Type'):
            if t.text:
                types.append(t.text)
        if not types:
            return 'string'
        return ' | '.join(types)

    def is_ref_type(t):
        return bool(re.search(r'Ref\.', t) or re.search(r'\u0441\u0441\u044b\u043b\u043a\u0430\.', t))

    def extract_fields(parent_node, tag_name='Attribute'):
        """Extract field list from ChildObjects by tag name (Attribute, Dimension, Resource, AccountingFlag, ExtDimensionAccountingFlag)."""
        result = []
        if parent_node is None:
            return result
        for field_node in _et_findall(parent_node, f'md:{tag_name}'):
            fp = _et_find(field_node, 'md:Properties')
            f_name = _et_text(fp, 'md:Name')
            f_syn_node = _et_find(fp, "md:Synonym/v8:item[v8:lang='ru']/v8:content")
            f_syn = f_syn_node.text if f_syn_node is not None and f_syn_node.text else f_name
            f_type_node = _et_find(fp, 'md:Type')
            f_type = extract_type(f_type_node)
            result.append({
                'Name': f_name,
                'Synonym': f_syn,
                'Type': f_type,
                'IsRef': is_ref_type(f_type),
            })
        return result

    # Attributes
    attributes = extract_fields(child_objs, 'Attribute')

    # Tabular sections
    tabular_sections = []
    if child_objs is not None:
        for ts_node in _et_findall(child_objs, 'md:TabularSection'):
            tsp = _et_find(ts_node, 'md:Properties')
            ts_name = _et_text(tsp, 'md:Name')
            ts_syn_node = _et_find(tsp, "md:Synonym/v8:item[v8:lang='ru']/v8:content")
            ts_syn = ts_syn_node.text if ts_syn_node is not None and ts_syn_node.text else ts_name
            ts_co = _et_find(ts_node, 'md:ChildObjects')
            ts_cols = extract_fields(ts_co, 'Attribute')
            tabular_sections.append({
                'Name': ts_name,
                'Synonym': ts_syn,
                'Columns': ts_cols,
            })

    meta = {
        'Type': obj_type,
        'Name': obj_name,
        'Synonym': synonym,
        'Attributes': attributes,
        'TabularSections': tabular_sections,
    }

    # Type-specific properties
    if obj_type == 'Document':
        nt_node = _et_find(props_node, 'md:NumberType')
        meta['NumberType'] = nt_node.text if nt_node is not None and nt_node.text else 'String'
    elif obj_type == 'Catalog':
        cl_node = _et_find(props_node, 'md:CodeLength')
        meta['CodeLength'] = int(cl_node.text) if cl_node is not None and cl_node.text else 0
        dl_node = _et_find(props_node, 'md:DescriptionLength')
        meta['DescriptionLength'] = int(dl_node.text) if dl_node is not None and dl_node.text else 0
        hi_node = _et_find(props_node, 'md:Hierarchical')
        meta['Hierarchical'] = (hi_node is not None and hi_node.text == 'true')
        ht_node = _et_find(props_node, 'md:HierarchyType')
        meta['HierarchyType'] = ht_node.text if ht_node is not None and ht_node.text else 'HierarchyFoldersAndItems'
        owners = []
        for ow in _et_findall(props_node, 'md:Owners/xr:Item'):
            if ow.text:
                owners.append(ow.text)
        meta['Owners'] = owners
    elif obj_type == 'InformationRegister':
        meta['Dimensions'] = extract_fields(child_objs, 'Dimension')
        meta['Resources'] = extract_fields(child_objs, 'Resource')
        prd_node = _et_find(props_node, 'md:InformationRegisterPeriodicity')
        meta['Periodicity'] = prd_node.text if prd_node is not None and prd_node.text else 'Nonperiodical'
        wm_node = _et_find(props_node, 'md:WriteMode')
        meta['WriteMode'] = wm_node.text if wm_node is not None and wm_node.text else 'Independent'
    elif obj_type == 'AccumulationRegister':
        meta['Dimensions'] = extract_fields(child_objs, 'Dimension')
        meta['Resources'] = extract_fields(child_objs, 'Resource')
        rt_node = _et_find(props_node, 'md:RegisterType')
        meta['RegisterType'] = rt_node.text if rt_node is not None and rt_node.text else 'Balances'
    elif obj_type == 'ChartOfCharacteristicTypes':
        cl_node = _et_find(props_node, 'md:CodeLength')
        meta['CodeLength'] = int(cl_node.text) if cl_node is not None and cl_node.text else 0
        dl_node = _et_find(props_node, 'md:DescriptionLength')
        meta['DescriptionLength'] = int(dl_node.text) if dl_node is not None and dl_node.text else 0
        hi_node = _et_find(props_node, 'md:Hierarchical')
        meta['Hierarchical'] = (hi_node is not None and hi_node.text == 'true')
        ht_node = _et_find(props_node, 'md:HierarchyType')
        meta['HierarchyType'] = ht_node.text if ht_node is not None and ht_node.text else 'HierarchyFoldersAndItems'
        owners = []
        for ow in _et_findall(props_node, 'md:Owners/xr:Item'):
            if ow.text:
                owners.append(ow.text)
        meta['Owners'] = owners
        meta['HasValueType'] = True
    elif obj_type == 'ExchangePlan':
        cl_node = _et_find(props_node, 'md:CodeLength')
        meta['CodeLength'] = int(cl_node.text) if cl_node is not None and cl_node.text else 0
        dl_node = _et_find(props_node, 'md:DescriptionLength')
        meta['DescriptionLength'] = int(dl_node.text) if dl_node is not None and dl_node.text else 0
        meta['Hierarchical'] = False
        meta['HierarchyType'] = None
        meta['Owners'] = []
    elif obj_type == 'ChartOfAccounts':
        cl_node = _et_find(props_node, 'md:CodeLength')
        meta['CodeLength'] = int(cl_node.text) if cl_node is not None and cl_node.text else 0
        dl_node = _et_find(props_node, 'md:DescriptionLength')
        meta['DescriptionLength'] = int(dl_node.text) if dl_node is not None and dl_node.text else 0
        meta['Hierarchical'] = True
        ht_node = _et_find(props_node, 'md:HierarchyType')
        meta['HierarchyType'] = ht_node.text if ht_node is not None and ht_node.text else 'HierarchyFoldersAndItems'
        meta['Owners'] = []
        max_ed_node = _et_find(props_node, 'md:MaxExtDimensionCount')
        meta['MaxExtDimensionCount'] = int(max_ed_node.text) if max_ed_node is not None and max_ed_node.text else 0
        meta['AccountingFlags'] = extract_fields(child_objs, 'AccountingFlag')
        meta['ExtDimensionAccountingFlags'] = extract_fields(child_objs, 'ExtDimensionAccountingFlag')

    return meta


def _deep_merge(base, overlay):
    """Deep merge two dicts. overlay wins on conflicts."""
    if not overlay:
        return base
    if not base:
        return overlay
    result = {}
    for k in base:
        result[k] = base[k]
    for k in overlay:
        if k in result and isinstance(result[k], dict) and isinstance(overlay[k], dict):
            result[k] = _deep_merge(result[k], overlay[k])
        else:
            result[k] = overlay[k]
    return result


def load_preset(preset_name, script_dir, out_path_resolved):
    """Load preset: hardcoded defaults -> built-in JSON -> project-level JSON, with deep merge."""
    defaults = {
        'document.item': {
            'header': {'position': 'insidePage', 'layout': '2col', 'distribute': 'even', 'dateTitle': '\u043e\u0442'},
            'footer': {'fields': ['\u041a\u043e\u043c\u043c\u0435\u043d\u0442\u0430\u0440\u0438\u0439'], 'position': 'insidePage'},
            'tabularSections': {'container': 'pages', 'exclude': ['\u0414\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0435\u0420\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u044b'], 'lineNumber': True},
            'additional': {'position': 'page', 'layout': '2col', 'bspGroup': True},
            'fieldDefaults': {'ref': {'choiceButton': True}, 'boolean': {'element': 'check'}},
            'commandBar': 'auto',
            'properties': {'autoTitle': False},
        },
        'document.list': {
            'columns': 'all', 'columnType': 'labelField', 'hiddenRef': True,
            'tableCommandBar': 'none', 'commandBar': 'auto',
            'properties': {},
        },
        'document.choice': {
            'basedOn': 'document.list',
            'properties': {'windowOpeningMode': 'LockOwnerWindow'},
        },
        'catalog.item': {
            'header': {'layout': '1col', 'distribute': 'left'},
            'codeDescription': {'layout': 'horizontal', 'order': 'descriptionFirst'},
            'parent': {'title': '\u0412\u0445\u043e\u0434\u0438\u0442 \u0432 \u0433\u0440\u0443\u043f\u043f\u0443', 'position': 'afterCodeDescription'},
            'owner': {'readOnly': True, 'position': 'first'},
            'tabularSections': {'container': 'inline', 'exclude': ['\u0414\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0435\u0420\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u044b', '\u041f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u044f'], 'lineNumber': True},
            'footer': {'fields': [], 'position': 'none'},
            'additional': {'position': 'none', 'bspGroup': True},
            'fieldDefaults': {'ref': {'choiceButton': True}, 'boolean': {'element': 'check'}},
            'commandBar': 'auto',
            'properties': {},
        },
        'catalog.folder': {
            'parent': {'title': '\u0412\u0445\u043e\u0434\u0438\u0442 \u0432 \u0433\u0440\u0443\u043f\u043f\u0443'},
            'properties': {'windowOpeningMode': 'LockOwnerWindow'},
        },
        'catalog.list': {
            'columns': 'all', 'columnType': 'labelField', 'hiddenRef': True,
            'tableCommandBar': 'none', 'commandBar': 'auto',
            'properties': {},
        },
        'catalog.choice': {
            'basedOn': 'catalog.list', 'choiceMode': True,
            'properties': {'windowOpeningMode': 'LockOwnerWindow'},
        },
        # --- Register defaults ---
        'informationRegister.record': {
            'fieldDefaults': {'ref': {'choiceButton': True}, 'boolean': {'element': 'check'}},
            'properties': {'windowOpeningMode': 'LockOwnerWindow'},
        },
        'informationRegister.list': {
            'columns': 'all', 'columnType': 'labelField',
            'tableCommandBar': 'none', 'commandBar': 'auto',
            'properties': {},
        },
        'accumulationRegister.list': {
            'columns': 'all', 'columnType': 'labelField',
            'tableCommandBar': 'none', 'commandBar': 'auto',
            'properties': {},
        },
        # --- Catalog-like type defaults ---
        'chartOfCharacteristicTypes.item': {'basedOn': 'catalog.item'},
        'chartOfCharacteristicTypes.folder': {'basedOn': 'catalog.folder'},
        'chartOfCharacteristicTypes.list': {'basedOn': 'catalog.list'},
        'chartOfCharacteristicTypes.choice': {'basedOn': 'catalog.choice'},
        'exchangePlan.item': {'basedOn': 'catalog.item'},
        'exchangePlan.list': {'basedOn': 'catalog.list'},
        'exchangePlan.choice': {'basedOn': 'catalog.choice'},
        # --- ChartOfAccounts defaults ---
        'chartOfAccounts.item': {
            'parent': {'title': '\u041f\u043e\u0434\u0447\u0438\u043d\u0435\u043d \u0441\u0447\u0435\u0442\u0443'},
            'fieldDefaults': {'ref': {'choiceButton': True}, 'boolean': {'element': 'check'}},
            'properties': {},
        },
        'chartOfAccounts.folder': {
            'parent': {'title': '\u041f\u043e\u0434\u0447\u0438\u043d\u0435\u043d \u0441\u0447\u0435\u0442\u0443'},
            'properties': {'windowOpeningMode': 'LockOwnerWindow'},
        },
        'chartOfAccounts.list': {'basedOn': 'catalog.list'},
        'chartOfAccounts.choice': {'basedOn': 'catalog.choice'},
    }

    # Try built-in preset
    preset_dir = os.path.join(os.path.dirname(script_dir), 'presets')
    built_in_path = os.path.join(preset_dir, f'{preset_name}.json')
    if os.path.isfile(built_in_path):
        preset_data = ci_json(parse_json_input(read_json_file(built_in_path), built_in_path))
        for k in list(preset_data.keys()):
            defaults[k] = _deep_merge(defaults.get(k), preset_data[k])

    # Try project-level preset (scan up from output path)
    scan_dir = os.path.dirname(out_path_resolved)
    while scan_dir:
        proj_preset = os.path.join(scan_dir, 'presets', 'skills', 'form', f'{preset_name}.json')
        if os.path.isfile(proj_preset):
            proj_data = parse_json_input(read_json_file(proj_preset), proj_preset)
            for k in list(proj_data.keys()):
                defaults[k] = _deep_merge(defaults.get(k), proj_data[k])
            break
        parent_dir = os.path.dirname(scan_dir)
        if parent_dir == scan_dir:
            break
        scan_dir = parent_dir

    # Resolve basedOn references
    for k in list(defaults.keys()):
        sect = defaults[k]
        if isinstance(sect, dict) and 'basedOn' in sect:
            base_name = sect['basedOn']
            if base_name in defaults:
                merged = _deep_merge(defaults[base_name], sect)
                merged.pop('basedOn', None)
                defaults[k] = merged

    return defaults


# Non-displayable types — cannot be bound to form elements
NON_DISPLAYABLE_TYPES = ('ValueStorage', 'v8:ValueStorage', 'ХранилищеЗначения')

def is_displayable_type(type_str):
    return not any(nd in type_str for nd in NON_DISPLAYABLE_TYPES)

def new_field_element(attr_name, data_path, attr_type, field_defaults, extra_props=None):
    """Build a field element DSL entry."""
    is_ref = bool(re.search(r'Ref\.', attr_type))
    is_bool = bool(re.match(r'^\s*xs:boolean\s*$', attr_type) or attr_type == 'boolean' or re.search(r'Boolean', attr_type))

    el_type = 'input'
    if is_bool and field_defaults and field_defaults.get('boolean') and field_defaults['boolean'].get('element') == 'check':
        el_type = 'check'

    el = OrderedDict()
    el[el_type] = attr_name
    el['path'] = data_path

    # (ChoiceButton у ref-полей платформа выводит сама; компилятор эмитит true по StartChoice-эвристике.
    #  Явный choiceButton из декомпиляции эмитится verbatim. Дефолт-«true» здесь НЕ ставим, чтобы
    #  from-object вывод совпадал с сертифицированным и не плодил ChoiceButton на каждом ref-поле.)

    # Extra props
    if extra_props:
        for k in extra_props:
            el[k] = extra_props[k]

    return el


# --- Catalog DSL generators ---

def generate_catalog_dsl(meta, preset_data, purpose):
    purpose_key = f"catalog.{purpose.lower()}"
    p = preset_data.get(purpose_key, {})
    fd = p.get('fieldDefaults', {})

    dispatch = {
        'Folder': lambda: generate_catalog_folder_dsl(meta, p),
        'List': lambda: generate_catalog_list_dsl(meta, p),
        'Choice': lambda: generate_catalog_choice_dsl(meta, p, preset_data),
        'Item': lambda: generate_catalog_item_dsl(meta, p, fd),
    }
    return dispatch[purpose]()


def generate_catalog_folder_dsl(meta, p):
    elements = []
    # Code (if CodeLength > 0)
    if meta.get('CodeLength', 0) > 0:
        elements.append(OrderedDict([('input', '\u041a\u043e\u0434'), ('path', '\u041e\u0431\u044a\u0435\u043a\u0442.Code')]))
    # Description
    elements.append(OrderedDict([('input', '\u041d\u0430\u0438\u043c\u0435\u043d\u043e\u0432\u0430\u043d\u0438\u0435'), ('path', '\u041e\u0431\u044a\u0435\u043a\u0442.Description')]))
    # Parent
    parent_title = p.get('parent', {}).get('title')
    parent_el = OrderedDict([('input', '\u0420\u043e\u0434\u0438\u0442\u0435\u043b\u044c'), ('path', '\u041e\u0431\u044a\u0435\u043a\u0442.Parent')])
    if parent_title:
        parent_el['title'] = parent_title
    elements.append(parent_el)

    props = OrderedDict([('windowOpeningMode', 'LockOwnerWindow')])
    if p.get('properties'):
        for k in p['properties']:
            props[k] = p['properties'][k]

    form_props = OrderedDict([('useForFoldersAndItems', 'Folders')])
    for k in props:
        form_props[k] = props[k]

    return OrderedDict([
        ('title', meta['Synonym']),
        ('properties', form_props),
        ('elements', elements),
        ('attributes', [
            OrderedDict([('name', '\u041e\u0431\u044a\u0435\u043a\u0442'), ('type', f"CatalogObject.{meta['Name']}"), ('main', True)])
        ]),
    ])


def generate_catalog_list_dsl(meta, p):
    columns = []
    # Description always first
    columns.append(OrderedDict([('labelField', '\u041d\u0430\u0438\u043c\u0435\u043d\u043e\u0432\u0430\u043d\u0438\u0435'), ('path', '\u0421\u043f\u0438\u0441\u043e\u043a.Description')]))
    # Code if present
    if meta.get('CodeLength', 0) > 0:
        columns.append(OrderedDict([('labelField', '\u041a\u043e\u0434'), ('path', '\u0421\u043f\u0438\u0441\u043e\u043a.Code')]))
    # Custom attributes
    for attr in meta['Attributes']:
        if not is_displayable_type(attr['Type']):
            continue
        columns.append(OrderedDict([('labelField', attr['Name']), ('path', f"\u0421\u043f\u0438\u0441\u043e\u043a.{attr['Name']}")]))
    # Hidden ref
    if p.get('hiddenRef', True) is not False:
        columns.append(OrderedDict([('labelField', '\u0421\u0441\u044b\u043b\u043a\u0430'), ('path', '\u0421\u043f\u0438\u0441\u043e\u043a.Ref'), ('userVisible', False)]))

    table_el = OrderedDict([
        ('table', '\u0421\u043f\u0438\u0441\u043e\u043a'), ('path', '\u0421\u043f\u0438\u0441\u043e\u043a'),
        ('rowPictureDataPath', '\u0421\u043f\u0438\u0441\u043e\u043a.DefaultPicture'),
        ('commandBarLocation', 'None'),
        ('tableAutofill', False),
        ('columns', columns),
    ])
    # Hierarchical properties
    if meta.get('Hierarchical'):
        table_el['initialTreeView'] = 'ExpandTopLevel'
        table_el['enableStartDrag'] = True
        table_el['enableDrag'] = True

    form_props = OrderedDict()
    if p.get('properties'):
        for k in p['properties']:
            form_props[k] = p['properties'][k]

    return OrderedDict([
        ('title', meta['Synonym']),
        ('properties', form_props),
        ('elements', [table_el]),
        ('attributes', [
            OrderedDict([
                ('name', '\u0421\u043f\u0438\u0441\u043e\u043a'), ('type', 'DynamicList'), ('main', True),
                ('settings', OrderedDict([('mainTable', f"Catalog.{meta['Name']}"), ('dynamicDataRead', True)])),
            ])
        ]),
    ])


def generate_catalog_choice_dsl(meta, p, preset_data):
    # Start from list
    list_key = 'catalog.list'
    lp = preset_data.get(list_key, {})
    dsl = generate_catalog_list_dsl(meta, lp)

    # Add choice-specific properties
    dsl['properties']['windowOpeningMode'] = 'LockOwnerWindow'
    if p.get('properties'):
        for k in p['properties']:
            dsl['properties'][k] = p['properties'][k]

    # Set ChoiceMode on table
    dsl['elements'][0]['choiceMode'] = True

    return dsl


def generate_catalog_item_dsl(meta, p, fd):
    header_children = []

    # Owner (if subordinate)
    if meta.get('Owners') and len(meta['Owners']) > 0:
        owner_el = OrderedDict([('input', '\u0412\u043b\u0430\u0434\u0435\u043b\u0435\u0446'), ('path', '\u041e\u0431\u044a\u0435\u043a\u0442.Owner'), ('readOnly', True)])
        header_children.append(owner_el)

    # Code + Description
    cd_layout = (p.get('codeDescription') or {}).get('layout', 'horizontal')
    cd_order = (p.get('codeDescription') or {}).get('order', 'descriptionFirst')
    has_code = meta.get('CodeLength', 0) > 0

    if cd_layout == 'horizontal' and has_code:
        cd_children = []
        desc_el = OrderedDict([('input', '\u041d\u0430\u0438\u043c\u0435\u043d\u043e\u0432\u0430\u043d\u0438\u0435'), ('path', '\u041e\u0431\u044a\u0435\u043a\u0442.Description')])
        code_el = OrderedDict([('input', '\u041a\u043e\u0434'), ('path', '\u041e\u0431\u044a\u0435\u043a\u0442.Code')])
        if cd_order == 'descriptionFirst':
            cd_children = [desc_el, code_el]
        else:
            cd_children = [code_el, desc_el]
        header_children.append(OrderedDict([
            ('group', 'horizontal'), ('name', '\u0413\u0440\u0443\u043f\u043f\u0430\u041a\u043e\u0434\u041d\u0430\u0438\u043c\u0435\u043d\u043e\u0432\u0430\u043d\u0438\u0435'), ('showTitle', False),
            ('representation', 'none'), ('children', cd_children),
        ]))
    else:
        # Vertical or no code
        header_children.append(OrderedDict([('input', '\u041d\u0430\u0438\u043c\u0435\u043d\u043e\u0432\u0430\u043d\u0438\u0435'), ('path', '\u041e\u0431\u044a\u0435\u043a\u0442.Description')]))
        if has_code:
            header_children.append(OrderedDict([('input', '\u041a\u043e\u0434'), ('path', '\u041e\u0431\u044a\u0435\u043a\u0442.Code')]))

    # Parent (for hierarchical catalogs)
    parent_pos = (p.get('parent') or {}).get('position', 'afterCodeDescription')
    parent_title = (p.get('parent') or {}).get('title')
    if meta.get('Hierarchical'):
        parent_el = OrderedDict([('input', '\u0420\u043e\u0434\u0438\u0442\u0435\u043b\u044c'), ('path', '\u041e\u0431\u044a\u0435\u043a\u0442.Parent')])
        if parent_title:
            parent_el['title'] = parent_title
        if parent_pos == 'beforeCodeDescription':
            insert_idx = 1 if (meta.get('Owners') and len(meta['Owners']) > 0) else 0
            header_children.insert(insert_idx, parent_el)
        else:
            # afterCodeDescription (default)
            header_children.append(parent_el)

    # Custom attributes -> header
    footer_field_names = (p.get('footer') or {}).get('fields', [])

    for attr in meta['Attributes']:
        if attr['Name'] in footer_field_names:
            continue
        if not is_displayable_type(attr['Type']):
            continue
        header_children.append(new_field_element(attr['Name'], f"\u041e\u0431\u044a\u0435\u043a\u0442.{attr['Name']}", attr['Type'], fd))

    # Build root elements
    root_elements = []

    # ГруппаШапка
    root_elements.append(OrderedDict([
        ('group', 'vertical'), ('name', '\u0413\u0440\u0443\u043f\u043f\u0430\u0428\u0430\u043f\u043a\u0430'), ('showTitle', False),
        ('representation', 'none'), ('children', header_children),
    ]))

    # Tabular sections
    ts_exclude = ['\u0414\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0435\u0420\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u044b', '\u041f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u044f']
    if (p.get('tabularSections') or {}).get('exclude'):
        ts_exclude = p['tabularSections']['exclude']
    ts_line_number = (p.get('tabularSections') or {}).get('lineNumber', True)

    visible_ts = [ts for ts in meta['TabularSections'] if ts['Name'] not in ts_exclude]

    for ts in visible_ts:
        ts_cols = []
        if ts_line_number:
            ts_cols.append(OrderedDict([('labelField', f"{ts['Name']}\u041d\u043e\u043c\u0435\u0440\u0421\u0442\u0440\u043e\u043a\u0438"), ('path', f"\u041e\u0431\u044a\u0435\u043a\u0442.{ts['Name']}.LineNumber")]))
        for col in ts['Columns']:
            ts_cols.append(new_field_element(f"{ts['Name']}{col['Name']}", f"\u041e\u0431\u044a\u0435\u043a\u0442.{ts['Name']}.{col['Name']}", col['Type'], fd))
        root_elements.append(OrderedDict([('table', ts['Name']), ('path', f"\u041e\u0431\u044a\u0435\u043a\u0442.{ts['Name']}"), ('columns', ts_cols)]))

    # Footer fields
    for fn in footer_field_names:
        f_attr = next((a for a in meta['Attributes'] if a['Name'] == fn), None)
        if f_attr:
            root_elements.append(new_field_element(f_attr['Name'], f"\u041e\u0431\u044a\u0435\u043a\u0442.{f_attr['Name']}", f_attr['Type'], fd))

    # BSP group
    bsp_group = (p.get('additional') or {}).get('bspGroup', True)
    if bsp_group:
        root_elements.append(OrderedDict([('group', 'vertical'), ('name', '\u0413\u0440\u0443\u043f\u043f\u0430\u0414\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0435\u0420\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u044b')]))

    # Properties
    form_props = OrderedDict()
    if p.get('properties'):
        for k in p['properties']:
            form_props[k] = p['properties'][k]
    # UseForFoldersAndItems
    if meta.get('Hierarchical') and meta.get('HierarchyType') == 'HierarchyFoldersAndItems':
        form_props['useForFoldersAndItems'] = 'Items'

    return OrderedDict([
        ('title', meta['Synonym']),
        ('properties', form_props),
        ('elements', root_elements),
        ('attributes', [
            OrderedDict([('name', '\u041e\u0431\u044a\u0435\u043a\u0442'), ('type', f"CatalogObject.{meta['Name']}"), ('main', True)])
        ]),
    ])


# --- Document DSL generators ---

def generate_document_dsl(meta, preset_data, purpose):
    purpose_key = f"document.{purpose.lower()}"
    p = preset_data.get(purpose_key, {})
    fd = p.get('fieldDefaults', {})

    dispatch = {
        'List': lambda: generate_document_list_dsl(meta, p),
        'Choice': lambda: generate_document_choice_dsl(meta, p, preset_data),
        'Item': lambda: generate_document_item_dsl(meta, p, fd),
    }
    return dispatch[purpose]()


def generate_document_list_dsl(meta, p):
    columns = []
    # Standard columns: Number + Date
    columns.append(OrderedDict([('labelField', 'Номер'), ('path', 'Список.Number')]))
    columns.append(OrderedDict([('labelField', 'Дата'), ('path', 'Список.Date')]))
    # All custom attributes as labelField
    for attr in meta['Attributes']:
        if not is_displayable_type(attr['Type']):
            continue
        columns.append(OrderedDict([('labelField', attr['Name']), ('path', f"\u0421\u043f\u0438\u0441\u043e\u043a.{attr['Name']}")]))
    # Hidden ref
    if p.get('hiddenRef', True):
        columns.append(OrderedDict([('labelField', '\u0421\u0441\u044b\u043b\u043a\u0430'), ('path', '\u0421\u043f\u0438\u0441\u043e\u043a.Ref'), ('userVisible', False)]))

    table_el = OrderedDict([
        ('table', '\u0421\u043f\u0438\u0441\u043e\u043a'), ('path', '\u0421\u043f\u0438\u0441\u043e\u043a'),
        ('rowPictureDataPath', '\u0421\u043f\u0438\u0441\u043e\u043a.DefaultPicture'),
        ('commandBarLocation', 'None'),
        ('tableAutofill', False),
        ('columns', columns),
    ])

    form_props = OrderedDict()
    if p.get('properties'):
        for k in p['properties']:
            form_props[k] = p['properties'][k]

    return OrderedDict([
        ('title', meta['Synonym']),
        ('properties', form_props),
        ('elements', [table_el]),
        ('attributes', [
            OrderedDict([
                ('name', '\u0421\u043f\u0438\u0441\u043e\u043a'), ('type', 'DynamicList'), ('main', True),
                ('settings', OrderedDict([('mainTable', f"Document.{meta['Name']}"), ('dynamicDataRead', True)])),
            ])
        ]),
    ])


def generate_document_choice_dsl(meta, p, preset_data):
    list_key = 'document.list'
    lp = preset_data.get(list_key, {})
    dsl = generate_document_list_dsl(meta, lp)

    dsl['properties']['windowOpeningMode'] = 'LockOwnerWindow'
    if p.get('properties'):
        for k in p['properties']:
            dsl['properties'][k] = p['properties'][k]

    return dsl


def generate_document_item_dsl(meta, p, fd):
    header_pos = (p.get('header') or {}).get('position', 'insidePage')
    header_layout = (p.get('header') or {}).get('layout', '2col')
    header_distribute = (p.get('header') or {}).get('distribute', 'even')
    date_title = (p.get('header') or {}).get('dateTitle', '\u043e\u0442')

    footer_fields = (p.get('footer') or {}).get('fields', [])
    footer_pos = (p.get('footer') or {}).get('position', 'insidePage')

    add_pos = (p.get('additional') or {}).get('position', 'page')
    add_layout = (p.get('additional') or {}).get('layout', '2col')
    add_bsp_group = (p.get('additional') or {}).get('bspGroup', True)
    add_left = (p.get('additional') or {}).get('left', [])
    add_right = (p.get('additional') or {}).get('right', [])

    header_right = (p.get('header') or {}).get('right', [])

    ts_exclude = ['\u0414\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0435\u0420\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u044b']
    if (p.get('tabularSections') or {}).get('exclude'):
        ts_exclude = p['tabularSections']['exclude']
    ts_line_number = (p.get('tabularSections') or {}).get('lineNumber', True)

    # Classify attributes
    claimed = {}
    for fn in footer_fields:
        claimed[fn] = 'footer'
    for fn in header_right:
        claimed[fn] = 'header.right'
    for fn in add_left:
        claimed[fn] = 'additional.left'
    for fn in add_right:
        claimed[fn] = 'additional.right'

    unclaimed = [attr for attr in meta['Attributes'] if attr['Name'] not in claimed and is_displayable_type(attr['Type'])]

    # Distribute unclaimed
    left_attrs = []
    right_extra_attrs = []
    if header_distribute == 'left':
        left_attrs = unclaimed
    elif header_distribute == 'right':
        right_extra_attrs = unclaimed
    else:  # "even"
        import math
        half = math.ceil(len(unclaimed) / 2) if unclaimed else 0
        left_attrs = unclaimed[:half]
        right_extra_attrs = unclaimed[half:]

    # Build ГруппаНомерДата
    num_date_children = [
        OrderedDict([('input', '\u041d\u043e\u043c\u0435\u0440'), ('path', '\u041e\u0431\u044a\u0435\u043a\u0442.Number'), ('autoMaxWidth', False), ('width', 9)]),
        OrderedDict([('input', '\u0414\u0430\u0442\u0430'), ('path', '\u041e\u0431\u044a\u0435\u043a\u0442.Date'), ('title', date_title)]),
    ]
    num_date_group = OrderedDict([
        ('group', 'horizontal'), ('name', '\u0413\u0440\u0443\u043f\u043f\u0430\u041d\u043e\u043c\u0435\u0440\u0414\u0430\u0442\u0430'), ('showTitle', False), ('children', num_date_children),
    ])

    # Build left column
    left_children = [num_date_group]
    for attr in left_attrs:
        left_children.append(new_field_element(attr['Name'], f"\u041e\u0431\u044a\u0435\u043a\u0442.{attr['Name']}", attr['Type'], fd))

    # Build right column
    right_children = []
    for rn in header_right:
        r_attr = next((a for a in meta['Attributes'] if a['Name'] == rn), None)
        if r_attr:
            right_children.append(new_field_element(r_attr['Name'], f"\u041e\u0431\u044a\u0435\u043a\u0442.{r_attr['Name']}", r_attr['Type'], fd))
    for attr in right_extra_attrs:
        right_children.append(new_field_element(attr['Name'], f"\u041e\u0431\u044a\u0435\u043a\u0442.{attr['Name']}", attr['Type'], fd))

    # Header group
    if header_layout == '2col' and len(right_children) > 0:
        header_group = OrderedDict([
            ('group', 'horizontal'), ('name', '\u0413\u0440\u0443\u043f\u043f\u0430\u0428\u0430\u043f\u043a\u0430'), ('showTitle', False), ('representation', 'none'),
            ('children', [
                OrderedDict([('group', 'vertical'), ('name', '\u0413\u0440\u0443\u043f\u043f\u0430\u0428\u0430\u043f\u043a\u0430\u041b\u0435\u0432\u043e'), ('showTitle', False), ('children', left_children)]),
                OrderedDict([('group', 'vertical'), ('name', '\u0413\u0440\u0443\u043f\u043f\u0430\u0428\u0430\u043f\u043a\u0430\u041f\u0440\u0430\u0432\u043e'), ('showTitle', False), ('children', right_children)]),
            ]),
        ])
    else:
        # 1col or no right items
        all_header_fields = left_children + right_children
        header_group = OrderedDict([
            ('group', 'horizontal'), ('name', '\u0413\u0440\u0443\u043f\u043f\u0430\u0428\u0430\u043f\u043a\u0430'), ('showTitle', False), ('representation', 'none'),
            ('children', [
                OrderedDict([('group', 'vertical'), ('name', '\u0413\u0440\u0443\u043f\u043f\u0430\u0428\u0430\u043f\u043a\u0430\u041b\u0435\u0432\u043e'), ('showTitle', False), ('children', all_header_fields)]),
            ]),
        ])

    # Footer elements
    footer_elements = []
    for fn in footer_fields:
        f_attr = next((a for a in meta['Attributes'] if a['Name'] == fn), None)
        if f_attr:
            footer_elements.append(new_field_element(f_attr['Name'], f"\u041e\u0431\u044a\u0435\u043a\u0442.{f_attr['Name']}", f_attr['Type'], fd))

    # Visible tabular sections
    visible_ts = [ts for ts in meta['TabularSections'] if ts['Name'] not in ts_exclude]

    # Additional page content
    additional_page = None
    if add_pos == 'page':
        add_left_els = []
        add_right_els = []
        for aln in add_left:
            al_attr = next((a for a in meta['Attributes'] if a['Name'] == aln), None)
            if al_attr:
                add_left_els.append(new_field_element(al_attr['Name'], f"\u041e\u0431\u044a\u0435\u043a\u0442.{al_attr['Name']}", al_attr['Type'], fd))
        for arn in add_right:
            ar_attr = next((a for a in meta['Attributes'] if a['Name'] == arn), None)
            if ar_attr:
                add_right_els.append(new_field_element(ar_attr['Name'], f"\u041e\u0431\u044a\u0435\u043a\u0442.{ar_attr['Name']}", ar_attr['Type'], fd))
        add_page_children = []
        if add_layout == '2col':
            add_page_children.append(OrderedDict([
                ('group', 'horizontal'), ('name', '\u0413\u0440\u0443\u043f\u043f\u0430\u041f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b'), ('showTitle', False),
                ('children', [
                    OrderedDict([('group', 'vertical'), ('name', '\u0413\u0440\u0443\u043f\u043f\u0430\u041f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b\u041b\u0435\u0432\u043e'), ('showTitle', False), ('children', add_left_els)]),
                    OrderedDict([('group', 'vertical'), ('name', '\u0413\u0440\u0443\u043f\u043f\u0430\u041f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b\u041f\u0440\u0430\u0432\u043e'), ('showTitle', False), ('children', add_right_els)]),
                ]),
            ]))
        else:
            add_page_children.extend(add_left_els + add_right_els)
        if add_bsp_group:
            add_page_children.append(OrderedDict([('group', 'vertical'), ('name', '\u0413\u0440\u0443\u043f\u043f\u0430\u0414\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0435\u0420\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u044b')]))
        additional_page = OrderedDict([('page', '\u0413\u0440\u0443\u043f\u043f\u0430\u0414\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u043e'), ('title', '\u0414\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u043e'), ('children', add_page_children)])

    # Build TS page elements
    ts_pages = []
    for ts in visible_ts:
        ts_cols = []
        if ts_line_number:
            ts_cols.append(OrderedDict([('labelField', f"{ts['Name']}\u041d\u043e\u043c\u0435\u0440\u0421\u0442\u0440\u043e\u043a\u0438"), ('path', f"\u041e\u0431\u044a\u0435\u043a\u0442.{ts['Name']}.LineNumber")]))
        for col in ts['Columns']:
            ts_cols.append(new_field_element(f"{ts['Name']}{col['Name']}", f"\u041e\u0431\u044a\u0435\u043a\u0442.{ts['Name']}.{col['Name']}", col['Type'], fd))
        ts_pages.append(OrderedDict([
            ('page', f"\u0413\u0440\u0443\u043f\u043f\u0430{ts['Name']}"), ('title', ts['Synonym']),
            ('children', [
                OrderedDict([('table', ts['Name']), ('path', f"\u041e\u0431\u044a\u0435\u043a\u0442.{ts['Name']}"), ('columns', ts_cols)])
            ]),
        ]))

    # Assemble root elements
    root_elements = []

    if len(visible_ts) == 0:
        # Simple form - no Pages
        root_elements.append(header_group)
        if footer_elements:
            root_elements.extend(footer_elements)
        if add_bsp_group and add_pos != 'none':
            root_elements.append(OrderedDict([('group', 'vertical'), ('name', '\u0413\u0440\u0443\u043f\u043f\u0430\u0414\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0435\u0420\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u044b')]))
    else:
        # Pages form
        if header_pos == 'abovePages':
            root_elements.append(header_group)
            pages_children = list(ts_pages)
            if additional_page:
                pages_children.append(additional_page)
            root_elements.append(OrderedDict([('pages', '\u0413\u0440\u0443\u043f\u043f\u0430\u0421\u0442\u0440\u0430\u043d\u0438\u0446\u044b'), ('children', pages_children)]))
        else:
            # insidePage (default)
            osnovnoe_children = [header_group]
            if footer_pos == 'insidePage' and footer_elements:
                osnovnoe_children.extend(footer_elements)
            pages_children = []
            pages_children.append(OrderedDict([('page', '\u0413\u0440\u0443\u043f\u043f\u0430\u041e\u0441\u043d\u043e\u0432\u043d\u043e\u0435'), ('title', '\u041e\u0441\u043d\u043e\u0432\u043d\u043e\u0435'), ('children', osnovnoe_children)]))
            pages_children.extend(ts_pages)
            if additional_page:
                pages_children.append(additional_page)
            root_elements.append(OrderedDict([('pages', '\u0413\u0440\u0443\u043f\u043f\u0430\u0421\u0442\u0440\u0430\u043d\u0438\u0446\u044b'), ('children', pages_children)]))

        # Footer below pages
        if footer_pos == 'belowPages' and footer_elements:
            root_elements.extend(footer_elements)

    # Properties
    form_props = OrderedDict([('autoTitle', False)])
    if p.get('properties'):
        for k in p['properties']:
            form_props[k] = p['properties'][k]

    return OrderedDict([
        ('title', meta['Synonym']),
        ('properties', form_props),
        ('elements', root_elements),
        ('attributes', [
            OrderedDict([('name', '\u041e\u0431\u044a\u0435\u043a\u0442'), ('type', f"DocumentObject.{meta['Name']}"), ('main', True)])
        ]),
    ])


# --- InformationRegister DSL generators ---

def generate_information_register_dsl(meta, preset_data, purpose):
    p_key = f"informationRegister.{purpose.lower()}"
    p = preset_data.get(p_key, {})
    fd = p.get('fieldDefaults') or {'ref': {'choiceButton': True}, 'boolean': {'element': 'check'}}
    dispatch = {
        'Record': lambda: generate_information_register_record_dsl(meta, p, fd),
        'List': lambda: generate_information_register_list_dsl(meta, p),
    }
    return dispatch[purpose]()


def generate_information_register_record_dsl(meta, p, fd):
    elements = OrderedDict()
    is_periodic = meta.get('Periodicity') and meta['Periodicity'] != 'Nonperiodical'

    # Period first (if periodic)
    if is_periodic:
        elements['\u041f\u0435\u0440\u0438\u043e\u0434'] = {'element': 'input', 'path': '\u0417\u0430\u043f\u0438\u0441\u044c.Period'}
    # Dimensions
    for dim in meta.get('Dimensions', []):
        if not is_displayable_type(dim['Type']):
            continue
        elements[dim['Name']] = new_field_element(dim['Name'], f"\u0417\u0430\u043f\u0438\u0441\u044c.{dim['Name']}", dim['Type'], fd)
    # Resources
    for res in meta.get('Resources', []):
        if not is_displayable_type(res['Type']):
            continue
        elements[res['Name']] = new_field_element(res['Name'], f"\u0417\u0430\u043f\u0438\u0441\u044c.{res['Name']}", res['Type'], fd)
    # Attributes
    for attr in meta['Attributes']:
        if not is_displayable_type(attr['Type']):
            continue
        elements[attr['Name']] = new_field_element(attr['Name'], f"\u0417\u0430\u043f\u0438\u0441\u044c.{attr['Name']}", attr['Type'], fd)

    props = OrderedDict([('windowOpeningMode', 'LockOwnerWindow')])
    if p.get('properties'):
        for k in p['properties']:
            props[k] = p['properties'][k]

    return OrderedDict([
        ('title', meta['Synonym']),
        ('properties', props),
        ('elements', elements),
        ('attributes', [
            {'name': '\u0417\u0430\u043f\u0438\u0441\u044c', 'type': f"InformationRegisterRecordManager.{meta['Name']}", 'main': True, 'savedData': True}
        ]),
    ])


def generate_information_register_list_dsl(meta, p):
    is_periodic = meta.get('Periodicity') and meta['Periodicity'] != 'Nonperiodical'
    is_recorder_subordinate = meta.get('WriteMode') == 'RecorderSubordinate'

    columns_list = []
    # Period
    if is_periodic:
        columns_list.append(OrderedDict([('labelField', '\u041f\u0435\u0440\u0438\u043e\u0434'), ('path', '\u0421\u043f\u0438\u0441\u043e\u043a.Period')]))
    # Recorder/LineNumber for subordinate registers
    if is_recorder_subordinate:
        columns_list.append(OrderedDict([('labelField', '\u0420\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0442\u043e\u0440'), ('path', '\u0421\u043f\u0438\u0441\u043e\u043a.Recorder')]))
        columns_list.append(OrderedDict([('labelField', '\u041d\u043e\u043c\u0435\u0440\u0421\u0442\u0440\u043e\u043a\u0438'), ('path', '\u0421\u043f\u0438\u0441\u043e\u043a.LineNumber')]))
    # Dimensions
    for dim in meta.get('Dimensions', []):
        if not is_displayable_type(dim['Type']):
            continue
        columns_list.append(OrderedDict([('labelField', dim['Name']), ('path', f"\u0421\u043f\u0438\u0441\u043e\u043a.{dim['Name']}")]))
    # Resources
    for res in meta.get('Resources', []):
        if not is_displayable_type(res['Type']):
            continue
        el_key = 'check' if re.match(r'^xs:boolean$|^Boolean$', res['Type']) else 'labelField'
        columns_list.append(OrderedDict([(el_key, res['Name']), ('path', f"\u0421\u043f\u0438\u0441\u043e\u043a.{res['Name']}")]))
    # Attributes
    for attr in meta['Attributes']:
        if not is_displayable_type(attr['Type']):
            continue
        el_key = 'check' if re.match(r'^xs:boolean$|^Boolean$', attr['Type']) else 'labelField'
        columns_list.append(OrderedDict([(el_key, attr['Name']), ('path', f"\u0421\u043f\u0438\u0441\u043e\u043a.{attr['Name']}")]))

    table_el = OrderedDict([
        ('table', '\u0421\u043f\u0438\u0441\u043e\u043a'),
        ('path', '\u0421\u043f\u0438\u0441\u043e\u043a'),
        ('rowPictureDataPath', '\u0421\u043f\u0438\u0441\u043e\u043a.DefaultPicture'),
        ('commandBarLocation', 'None'),
        ('tableAutofill', False),
        ('columns', columns_list),
    ])

    props = OrderedDict()
    if p.get('properties'):
        for k in p['properties']:
            props[k] = p['properties'][k]

    return OrderedDict([
        ('title', meta['Synonym']),
        ('properties', props),
        ('elements', [table_el]),
        ('attributes', [
            {'name': '\u0421\u043f\u0438\u0441\u043e\u043a', 'type': 'DynamicList', 'main': True, 'settings': {'mainTable': f"InformationRegister.{meta['Name']}", 'dynamicDataRead': True}}
        ]),
    ])


# --- AccumulationRegister DSL generators ---

def generate_accumulation_register_dsl(meta, preset_data, purpose):
    p_key = f"accumulationRegister.{purpose.lower()}"
    p = preset_data.get(p_key, {})
    dispatch = {
        'List': lambda: generate_accumulation_register_list_dsl(meta, p),
    }
    return dispatch[purpose]()


def generate_accumulation_register_list_dsl(meta, p):
    columns_list = []
    # AccumulationRegisters always have Period, Recorder, LineNumber
    columns_list.append(OrderedDict([('labelField', '\u041f\u0435\u0440\u0438\u043e\u0434'), ('path', '\u0421\u043f\u0438\u0441\u043e\u043a.Period')]))
    columns_list.append(OrderedDict([('labelField', '\u0420\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0442\u043e\u0440'), ('path', '\u0421\u043f\u0438\u0441\u043e\u043a.Recorder')]))
    columns_list.append(OrderedDict([('labelField', '\u041d\u043e\u043c\u0435\u0440\u0421\u0442\u0440\u043e\u043a\u0438'), ('path', '\u0421\u043f\u0438\u0441\u043e\u043a.LineNumber')]))
    # Dimensions
    for dim in meta.get('Dimensions', []):
        if not is_displayable_type(dim['Type']):
            continue
        columns_list.append(OrderedDict([('labelField', dim['Name']), ('path', f"\u0421\u043f\u0438\u0441\u043e\u043a.{dim['Name']}")]))
    # Resources
    for res in meta.get('Resources', []):
        if not is_displayable_type(res['Type']):
            continue
        el_key = 'check' if re.match(r'^xs:boolean$|^Boolean$', res['Type']) else 'labelField'
        columns_list.append(OrderedDict([(el_key, res['Name']), ('path', f"\u0421\u043f\u0438\u0441\u043e\u043a.{res['Name']}")]))
    # Attributes
    for attr in meta['Attributes']:
        if not is_displayable_type(attr['Type']):
            continue
        el_key = 'check' if re.match(r'^xs:boolean$|^Boolean$', attr['Type']) else 'labelField'
        columns_list.append(OrderedDict([(el_key, attr['Name']), ('path', f"\u0421\u043f\u0438\u0441\u043e\u043a.{attr['Name']}")]))

    table_el = OrderedDict([
        ('table', '\u0421\u043f\u0438\u0441\u043e\u043a'),
        ('path', '\u0421\u043f\u0438\u0441\u043e\u043a'),
        ('rowPictureDataPath', '\u0421\u043f\u0438\u0441\u043e\u043a.DefaultPicture'),
        ('commandBarLocation', 'None'),
        ('tableAutofill', False),
        ('columns', columns_list),
    ])

    props = OrderedDict()
    if p.get('properties'):
        for k in p['properties']:
            props[k] = p['properties'][k]

    return OrderedDict([
        ('title', meta['Synonym']),
        ('properties', props),
        ('elements', [table_el]),
        ('attributes', [
            {'name': '\u0421\u043f\u0438\u0441\u043e\u043a', 'type': 'DynamicList', 'main': True, 'settings': {'mainTable': f"AccumulationRegister.{meta['Name']}", 'dynamicDataRead': True}}
        ]),
    ])


# --- ChartOfCharacteristicTypes (delegates to Catalog) ---

def generate_chart_of_characteristic_types_dsl(meta, preset_data, purpose):
    # Delegate to Catalog generators -- meta already has CodeLength, DescriptionLength, etc.
    dsl = generate_catalog_dsl(meta, preset_data, purpose)

    # Post-patch: replace Catalog types with ChartOfCharacteristicTypes types
    cat_obj_type = f"CatalogObject.{meta['Name']}"
    ccoct_obj_type = f"ChartOfCharacteristicTypesObject.{meta['Name']}"
    cat_list_type = f"Catalog.{meta['Name']}"
    ccoct_list_type = f"ChartOfCharacteristicTypes.{meta['Name']}"

    for a in dsl['attributes']:
        if a.get('type') == cat_obj_type:
            a['type'] = ccoct_obj_type
        if a.get('type') == 'DynamicList' and a.get('settings') and a['settings'].get('mainTable') == cat_list_type:
            a['settings']['mainTable'] = ccoct_list_type

    # For Item forms: inject ValueType field after Description/ГруппаКодНаименование
    if purpose == 'Item' and dsl.get('elements'):
        vt_el = OrderedDict([('input', '\u0422\u0438\u043f\u0417\u043d\u0430\u0447\u0435\u043d\u0438\u044f'), ('path', '\u041e\u0431\u044a\u0435\u043a\u0442.ValueType')])
        els = dsl['elements']
        if isinstance(els, list):
            inserted = False
            new_els = []
            for el in els:
                new_els.append(el)
                if not inserted and isinstance(el, dict):
                    name = el.get('input') or el.get('group') or ''
                    if name in ('\u041d\u0430\u0438\u043c\u0435\u043d\u043e\u0432\u0430\u043d\u0438\u0435', '\u0413\u0440\u0443\u043f\u043f\u0430\u041a\u043e\u0434\u041d\u0430\u0438\u043c\u0435\u043d\u043e\u0432\u0430\u043d\u0438\u0435'):
                        new_els.append(vt_el)
                        inserted = True
            if not inserted:
                new_els.append(vt_el)
            dsl['elements'] = new_els

    return dsl


# --- ExchangePlan (delegates to Catalog) ---

def generate_exchange_plan_dsl(meta, preset_data, purpose):
    # ExchangePlans are not hierarchical and have no Folder form
    dsl = generate_catalog_dsl(meta, preset_data, purpose)

    # Post-patch: replace Catalog types with ExchangePlan types
    cat_obj_type = f"CatalogObject.{meta['Name']}"
    ep_obj_type = f"ExchangePlanObject.{meta['Name']}"
    cat_list_type = f"Catalog.{meta['Name']}"
    ep_list_type = f"ExchangePlan.{meta['Name']}"

    for a in dsl['attributes']:
        if a.get('type') == cat_obj_type:
            a['type'] = ep_obj_type
        if a.get('type') == 'DynamicList' and a.get('settings') and a['settings'].get('mainTable') == cat_list_type:
            a['settings']['mainTable'] = ep_list_type

    # For Item forms: inject SentNo, ReceivedNo after Code/Description
    if purpose == 'Item' and dsl.get('elements'):
        sent_el = OrderedDict([('input', '\u041d\u043e\u043c\u0435\u0440\u041e\u0442\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u043d\u043e\u0433\u043e'), ('path', '\u041e\u0431\u044a\u0435\u043a\u0442.SentNo'), ('readOnly', True)])
        recv_el = OrderedDict([('input', '\u041d\u043e\u043c\u0435\u0440\u041f\u0440\u0438\u043d\u044f\u0442\u043e\u0433\u043e'), ('path', '\u041e\u0431\u044a\u0435\u043a\u0442.ReceivedNo'), ('readOnly', True)])
        els = dsl['elements']
        if isinstance(els, list):
            inserted = False
            new_els = []
            for el in els:
                new_els.append(el)
                if not inserted and isinstance(el, dict):
                    name = el.get('input') or el.get('group') or ''
                    if name in ('\u041d\u0430\u0438\u043c\u0435\u043d\u043e\u0432\u0430\u043d\u0438\u0435', '\u0413\u0440\u0443\u043f\u043f\u0430\u041a\u043e\u0434\u041d\u0430\u0438\u043c\u0435\u043d\u043e\u0432\u0430\u043d\u0438\u0435'):
                        new_els.append(sent_el)
                        new_els.append(recv_el)
                        inserted = True
            if not inserted:
                new_els.append(sent_el)
                new_els.append(recv_el)
            dsl['elements'] = new_els

    return dsl


# --- ChartOfAccounts DSL generators ---

def generate_chart_of_accounts_dsl(meta, preset_data, purpose):
    p_key = f"chartOfAccounts.{purpose.lower()}"
    p = preset_data.get(p_key, {})
    fd = p.get('fieldDefaults') or {'ref': {'choiceButton': True}, 'boolean': {'element': 'check'}}
    dispatch = {
        'Item': lambda: generate_chart_of_accounts_item_dsl(meta, p, fd, preset_data),
        'Folder': lambda: generate_chart_of_accounts_folder_dsl(meta, p),
        'List': lambda: generate_chart_of_accounts_list_dsl(meta, preset_data),
        'Choice': lambda: generate_chart_of_accounts_choice_dsl(meta, preset_data),
    }
    return dispatch[purpose]()


def generate_chart_of_accounts_item_dsl(meta, p, fd, preset_data):
    elements = []

    # Header: Code + Parent
    header_left_children = []
    if meta.get('CodeLength', 0) > 0:
        header_left_children.append(OrderedDict([('input', '\u041a\u043e\u0434'), ('path', '\u041e\u0431\u044a\u0435\u043a\u0442.Code')]))
    header_right_children = []
    if meta.get('Hierarchical'):
        parent_title = (p.get('parent') or {}).get('title', '\u041f\u043e\u0434\u0447\u0438\u043d\u0435\u043d \u0441\u0447\u0435\u0442\u0443')
        header_right_children.append(OrderedDict([('input', '\u0420\u043e\u0434\u0438\u0442\u0435\u043b\u044c'), ('path', '\u041e\u0431\u044a\u0435\u043a\u0442.Parent'), ('title', parent_title)]))

    if len(header_right_children) > 0:
        elements.append(OrderedDict([
            ('group', 'horizontal'), ('name', '\u0413\u0440\u0443\u043f\u043f\u0430\u0428\u0430\u043f\u043a\u0430'), ('showTitle', False), ('representation', 'none'),
            ('children', [
                OrderedDict([('group', 'vertical'), ('name', '\u0413\u0440\u0443\u043f\u043f\u0430\u0428\u0430\u043f\u043a\u0430\u041b\u0435\u0432\u043e'), ('showTitle', False), ('children', header_left_children)]),
                OrderedDict([('group', 'vertical'), ('name', '\u0413\u0440\u0443\u043f\u043f\u0430\u0428\u0430\u043f\u043a\u0430\u041f\u0440\u0430\u0432\u043e'), ('showTitle', False), ('children', header_right_children)]),
            ]),
        ]))
    elif len(header_left_children) > 0:
        elements.extend(header_left_children)

    # Description
    if meta.get('DescriptionLength', 0) > 0:
        elements.append(OrderedDict([('input', '\u041d\u0430\u0438\u043c\u0435\u043d\u043e\u0432\u0430\u043d\u0438\u0435'), ('path', '\u041e\u0431\u044a\u0435\u043a\u0442.Description')]))

    # OffBalance
    elements.append(OrderedDict([('check', '\u0417\u0430\u0431\u0430\u043b\u0430\u043d\u0441\u043e\u0432\u044b\u0439'), ('path', '\u041e\u0431\u044a\u0435\u043a\u0442.OffBalance')]))

    # AccountingFlags as checkboxes
    if meta.get('AccountingFlags') and len(meta['AccountingFlags']) > 0:
        flag_children = []
        for flag in meta['AccountingFlags']:
            flag_children.append(OrderedDict([('check', flag['Name']), ('path', f"\u041e\u0431\u044a\u0435\u043a\u0442.{flag['Name']}")]))
        elements.append(OrderedDict([
            ('group', 'vertical'), ('name', '\u0413\u0440\u0443\u043f\u043f\u0430\u041f\u0440\u0438\u0437\u043d\u0430\u043a\u0438\u0423\u0447\u0435\u0442\u0430'), ('title', '\u041f\u0440\u0438\u0437\u043d\u0430\u043a\u0438 \u0443\u0447\u0435\u0442\u0430'),
            ('children', flag_children),
        ]))

    # ExtDimensionTypes table
    if meta.get('MaxExtDimensionCount', 0) > 0:
        # Column names are prefixed with the table name (like the generic TS path and stock 1C),
        # else a subconto flag column collides with a same-named account accounting-flag checkbox.
        ed_table = '\u0412\u0438\u0434\u044b\u0421\u0443\u0431\u043a\u043e\u043d\u0442\u043e'
        ed_cols = []
        ed_cols.append(OrderedDict([('input', f"{ed_table}\u0412\u0438\u0434\u0421\u0443\u0431\u043a\u043e\u043d\u0442\u043e"), ('path', '\u041e\u0431\u044a\u0435\u043a\u0442.ExtDimensionTypes.ExtDimensionType')]))
        ed_cols.append(OrderedDict([('check', f"{ed_table}\u0422\u043e\u043b\u044c\u043a\u043e\u041e\u0431\u043e\u0440\u043e\u0442\u044b"), ('path', '\u041e\u0431\u044a\u0435\u043a\u0442.ExtDimensionTypes.TurnoversOnly')]))
        if meta.get('ExtDimensionAccountingFlags'):
            for ed_flag in meta['ExtDimensionAccountingFlags']:
                ed_cols.append(OrderedDict([('check', f"{ed_table}{ed_flag['Name']}"), ('path', f"\u041e\u0431\u044a\u0435\u043a\u0442.ExtDimensionTypes.{ed_flag['Name']}")]))
        elements.append(OrderedDict([
            ('table', ed_table),
            ('path', '\u041e\u0431\u044a\u0435\u043a\u0442.ExtDimensionTypes'),
            ('columns', ed_cols),
        ]))

    # Custom attributes
    for attr in meta['Attributes']:
        if not is_displayable_type(attr['Type']):
            continue
        elements.append(new_field_element(attr['Name'], f"\u041e\u0431\u044a\u0435\u043a\u0442.{attr['Name']}", attr['Type'], fd))

    # Tabular sections
    ts_exclude = ['\u0414\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0435\u0420\u0435\u043a\u0432\u0438\u0437\u0438\u0442\u044b', '\u041f\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043b\u0435\u043d\u0438\u044f']
    for ts in meta['TabularSections']:
        if ts['Name'] in ts_exclude:
            continue
        ts_cols = []
        for col in ts['Columns']:
            if not is_displayable_type(col['Type']):
                continue
            ts_cols.append(new_field_element(f"{ts['Name']}{col['Name']}", f"\u041e\u0431\u044a\u0435\u043a\u0442.{ts['Name']}.{col['Name']}", col['Type'], fd))
        elements.append(OrderedDict([('table', ts['Name']), ('path', f"\u041e\u0431\u044a\u0435\u043a\u0442.{ts['Name']}"), ('columns', ts_cols)]))

    props = OrderedDict()
    if p.get('properties'):
        for k in p['properties']:
            props[k] = p['properties'][k]

    return OrderedDict([
        ('title', meta['Synonym']),
        ('properties', props),
        ('elements', elements),
        ('attributes', [
            {'name': '\u041e\u0431\u044a\u0435\u043a\u0442', 'type': f"ChartOfAccountsObject.{meta['Name']}", 'main': True, 'savedData': True}
        ]),
    ])


def generate_chart_of_accounts_folder_dsl(meta, p):
    elements = []
    if meta.get('CodeLength', 0) > 0:
        elements.append(OrderedDict([('input', '\u041a\u043e\u0434'), ('path', '\u041e\u0431\u044a\u0435\u043a\u0442.Code')]))
    if meta.get('DescriptionLength', 0) > 0:
        elements.append(OrderedDict([('input', '\u041d\u0430\u0438\u043c\u0435\u043d\u043e\u0432\u0430\u043d\u0438\u0435'), ('path', '\u041e\u0431\u044a\u0435\u043a\u0442.Description')]))
    if meta.get('Hierarchical'):
        parent_title = (p.get('parent') or {}).get('title', '\u041f\u043e\u0434\u0447\u0438\u043d\u0435\u043d \u0441\u0447\u0435\u0442\u0443')
        elements.append(OrderedDict([('input', '\u0420\u043e\u0434\u0438\u0442\u0435\u043b\u044c'), ('path', '\u041e\u0431\u044a\u0435\u043a\u0442.Parent'), ('title', parent_title)]))

    props = OrderedDict([('windowOpeningMode', 'LockOwnerWindow')])
    if p.get('properties'):
        for k in p['properties']:
            props[k] = p['properties'][k]

    return OrderedDict([
        ('title', meta['Synonym']),
        ('useForFoldersAndItems', 'Folders'),
        ('properties', props),
        ('elements', elements),
        ('attributes', [
            {'name': '\u041e\u0431\u044a\u0435\u043a\u0442', 'type': f"ChartOfAccountsObject.{meta['Name']}", 'main': True, 'savedData': True}
        ]),
    ])


def generate_chart_of_accounts_list_dsl(meta, preset_data):
    # Delegate to Catalog List and patch types
    dsl = generate_catalog_dsl(meta, preset_data, 'List')
    for a in dsl['attributes']:
        if a.get('type') == 'DynamicList' and a.get('settings') and a['settings'].get('mainTable') == f"Catalog.{meta['Name']}":
            a['settings']['mainTable'] = f"ChartOfAccounts.{meta['Name']}"
    return dsl


def generate_chart_of_accounts_choice_dsl(meta, preset_data):
    dsl = generate_catalog_dsl(meta, preset_data, 'Choice')
    for a in dsl['attributes']:
        if a.get('type') == 'DynamicList' and a.get('settings') and a['settings'].get('mainTable') == f"Catalog.{meta['Name']}":
            a['settings']['mainTable'] = f"ChartOfAccounts.{meta['Name']}"
    return dsl


# ═══════════════════════════════════════════════════════════════════════════
# END OF FROM-OBJECT MODE FUNCTIONS
# ═══════════════════════════════════════════════════════════════════════════


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


def esc_xml_text(s):
    # Экранирование ТЕКСТА элемента (<v8:content>, <Value>): только & < > .
    # Кавычки/апострофы в тексте 1С не экранирует (пишет литерально) — &quot; ломал бы раундтрип.
    return s.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')


def di_attr(el):
    # DisplayImportance — атрибут открывающего тега элемента (адаптивная важность). "" если нет.
    if isinstance(el, dict) and el.get('displayImportance'):
        return f' DisplayImportance="{esc_xml(str(el["displayImportance"]))}"'
    return ''


# Базовая директория для @file-ссылок в query динсписка (устанавливается в main)
QUERY_BASE_DIR = None


def resolve_query_value(val, base_dir):
    if not val.startswith('@'):
        return val
    file_path = val[1:]
    if os.path.isabs(file_path):
        candidates = [file_path]
    else:
        candidates = [os.path.join(base_dir or os.getcwd(), file_path), os.path.join(os.getcwd(), file_path)]
    for c in candidates:
        if os.path.exists(c):
            with open(c, 'r', encoding='utf-8-sig') as f:
                return f.read().rstrip()
    print(f"Query file not found: {file_path} (searched: {', '.join(candidates)})", file=sys.stderr)
    sys.exit(1)


def emit_ml_items(lines, indent, val):
    # строка → один ru-элемент; объект {lang: text} → по элементу на язык
    if isinstance(val, dict):
        for k, v in val.items():
            lines.append(f"{indent}<v8:item>")
            lines.append(f"{indent}\t<v8:lang>{k}</v8:lang>")
            lines.append(f"{indent}\t<v8:content>{esc_xml_text(str(v))}</v8:content>")
            lines.append(f"{indent}</v8:item>")
    else:
        lines.append(f"{indent}<v8:item>")
        lines.append(f"{indent}\t<v8:lang>ru</v8:lang>")
        lines.append(f"{indent}\t<v8:content>{esc_xml_text(str(val))}</v8:content>")
        lines.append(f"{indent}</v8:item>")


def emit_mltext(lines, indent, tag, text, xsi_type=None):
    attr = f' xsi:type="{xsi_type}"' if xsi_type else ''
    if not text:
        lines.append(f"{indent}<{tag}{attr}/>")
        return
    lines.append(f"{indent}<{tag}{attr}>")
    emit_ml_items(lines, f"{indent}\t", text)
    lines.append(f"{indent}</{tag}>")


def emit_us_presentation(lines, indent, tag, val):
    # <dcsset:userSettingPresentation>: плоская строка → xsi:type="xs:string"; мультиязычный → v8:LocalStringType
    if val is None:
        return
    if isinstance(val, str):
        lines.append(f'{indent}<{tag} xsi:type="xs:string">{esc_xml_text(val)}</{tag}>')
    else:
        emit_mltext(lines, indent, tag, val, xsi_type='v8:LocalStringType')


# Каноничные GUID пустых контейнеров ListSettings (умолчание платформы, ~90% форм).
CANON_FILTER_ID = 'dfcece9d-5077-440b-b6b3-45a5cb4538eb'
CANON_ORDER_ID = '88619765-ccb3-46c6-ac52-38e9c992ebd4'
CANON_CA_ID = 'b75fecce-942b-4aed-abc9-e6a02e460fb3'
CANON_ITEMS_ID = '911b6018-f537-43e8-a417-da56b22f9aec'


def new_uuid():
    return str(uuid.uuid4())


# ─────────────────────────────────────────────────────────────────────────────
# Настройки компоновщика ListSettings: filter/order/conditionalAppearance.
# Грамматика DSL и эмиссия dcsset скопированы из skd-compile (навыки автономны).
# ─────────────────────────────────────────────────────────────────────────────
COMPARISON_TYPES = {
    '=': 'Equal', '<>': 'NotEqual',
    '>': 'Greater', '>=': 'GreaterOrEqual',
    '<': 'Less', '<=': 'LessOrEqual',
    'in': 'InList', 'notIn': 'NotInList',
    'inHierarchy': 'InHierarchy', 'inListByHierarchy': 'InListByHierarchy',
    'contains': 'Contains', 'notContains': 'NotContains',
    'beginsWith': 'BeginsWith', 'notBeginsWith': 'NotBeginsWith',
    'like': 'Like', 'notLike': 'NotLike',
    'подобно': 'Like', 'неподобно': 'NotLike',  # рус. синоним
    'filled': 'Filled', 'notFilled': 'NotFilled',
}
# Регистронезависимый лукап (зеркало PS-хэша): Like/LIKE/ПОДОБНО → канон
_COMPARISON_TYPES_CI = {k.lower(): v for k, v in COMPARISON_TYPES.items()}

_REF_TYPE_RE = re.compile(
    r'^(Перечисление|Справочник|ПланСчетов|Документ|ПланВидовХарактеристик|ПланВидовРасчета|'
    r'БизнесПроцесс|Задача|РегистрСведений|ПланОбмена|Catalog|Enum|Document|ChartOfAccounts|'
    r'ChartOfCharacteristicTypes|ChartOfCalculationTypes|BusinessProcess|Task|'
    r'InformationRegister|ExchangePlan)\.')


def parse_filter_shorthand(s):
    result = {'field': '', 'op': 'Equal', 'value': None, 'use': True,
              'userSettingID': None, 'viewMode': None, 'presentation': None}
    if re.search(r'@user', s):
        result['userSettingID'] = 'auto'
        s = re.sub(r'\s*@user', '', s)
    if re.search(r'@off', s):
        result['use'] = False
        s = re.sub(r'\s*@off', '', s)
    if re.search(r'@quickAccess', s):
        result['viewMode'] = 'QuickAccess'
        s = re.sub(r'\s*@quickAccess', '', s)
    if re.search(r'@normal', s):
        result['viewMode'] = 'Normal'
        s = re.sub(r'\s*@normal', '', s)
    if re.search(r'@inaccessible', s):
        result['viewMode'] = 'Inaccessible'
        s = re.sub(r'\s*@inaccessible', '', s)
    s = s.strip()
    op_patterns = ['<>', '>=', '<=', '=', '>', '<',
                   r'notIn\b', r'in\b', r'inHierarchy\b', r'inListByHierarchy\b',
                   r'notContains\b', r'contains\b', r'notBeginsWith\b', r'beginsWith\b',
                   r'notLike\b', r'like\b', r'неподобно\b', r'подобно\b',
                   r'notFilled\b', r'filled\b']
    op_joined = '|'.join(op_patterns)
    m = re.match(r'^(.+?)\s+(' + op_joined + r')\s*(.*)?$', s, re.IGNORECASE)
    if m:
        result['field'] = m.group(1).strip()
        result['op'] = m.group(2).strip()
        val_part = m.group(3).strip() if m.group(3) else ''
        if val_part and val_part != '_':
            if val_part == 'true' or val_part == 'false':
                result['value'] = (val_part == 'true')
                result['valueType'] = 'xs:boolean'
            elif re.match(r'^\d{4}-\d{2}-\d{2}T', val_part):
                # дата без valueType → emit_filter_item выведет StandardBeginningDate Custom (дефолт даты в фильтре)
                result['value'] = val_part
            elif re.match(r'^\d+(\.\d+)?$', val_part):
                result['value'] = val_part
                result['valueType'] = 'xs:decimal'
            elif re.match(r'^(Перечисление|Справочник|ПланСчетов|Документ|ПланВидовХарактеристик|ПланВидовРасчета)\.', val_part):
                result['value'] = val_part
                result['valueType'] = 'dcscor:DesignTimeValue'
            else:
                result['value'] = val_part
                result['valueType'] = 'xs:string'
    else:
        result['field'] = s
    return result


def _value_type_for(v, explicit=None):
    if explicit:
        return explicit
    if isinstance(v, bool):
        return 'xs:boolean'
    if isinstance(v, (int, float)):
        return 'xs:decimal'
    vs = str(v)
    if re.match(r'^\d{4}-\d{2}-\d{2}T', vs):
        return 'xs:dateTime'
    if re.match(r'^-?\d+(\.\d+)?$', vs):
        return 'xs:decimal'
    if _REF_TYPE_RE.match(vs):
        return 'dcscor:DesignTimeValue'
    return 'xs:string'


# Значение типа v8:Type (напр. тип «Неопределено» = <prefix>:Undefined) ссылается на тип
# платформы из namespace http://v8.1c.ru/8.2/data/types — платформа объявляет его ЛОКАЛЬНО
# на теге значения (префикс авто: d6p1/d8p1/dN…). Без объявления QName битый.
def _value_type_ns_attr(value_type, value):
    if value_type == 'v8:Type':
        m = re.match(r'^([A-Za-z]\w*):', str(value))
        if m and m.group(1) not in ('xs', 'cfg', 'v8', 'v8ui', 'ent', 'dcscor', 'dcsset', 'dcssch'):
            return f' xmlns:{m.group(1)}="http://v8.1c.ru/8.2/data/types"'
    return ''


def emit_filter_item(lines, item, indent):
    if item.get('group'):
        g = str(item['group'])
        group_type = {'And': 'AndGroup', 'Or': 'OrGroup', 'Not': 'NotGroup'}.get(g, g + 'Group')
        lines.append(f'{indent}<dcsset:item xsi:type="dcsset:FilterItemGroup">')
        if item.get('use') is False:
            lines.append(f'{indent}\t<dcsset:use>false</dcsset:use>')   # группа отключена (перед groupType)
        lines.append(f'{indent}\t<dcsset:groupType>{group_type}</dcsset:groupType>')
        if item.get('items'):
            for sub in item['items']:
                if isinstance(sub, str):
                    parsed = parse_filter_shorthand(sub)
                    obj = {'field': parsed['field'], 'op': parsed['op']}
                    if parsed['use'] is False:
                        obj['use'] = False
                    if parsed['value'] is not None:
                        obj['value'] = parsed['value']
                    if parsed.get('valueType'):
                        obj['valueType'] = parsed['valueType']
                    if parsed.get('userSettingID'):
                        obj['userSettingID'] = parsed['userSettingID']
                    if parsed.get('viewMode'):
                        obj['viewMode'] = parsed['viewMode']
                    sub = obj
                emit_filter_item(lines, sub, f'{indent}\t')
        if item.get('presentation'):
            emit_us_presentation(lines, f'{indent}\t', 'dcsset:presentation', item['presentation'])
        if item.get('viewMode'):
            lines.append(f'{indent}\t<dcsset:viewMode>{esc_xml_text(str(item["viewMode"]))}</dcsset:viewMode>')
        if item.get('userSettingID'):
            guid = new_uuid() if str(item['userSettingID']) == 'auto' else str(item['userSettingID'])
            lines.append(f'{indent}\t<dcsset:userSettingID>{esc_xml_text(guid)}</dcsset:userSettingID>')
        if item.get('userSettingPresentation'):
            emit_us_presentation(lines, f'{indent}\t', 'dcsset:userSettingPresentation', item['userSettingPresentation'])
        lines.append(f'{indent}</dcsset:item>')
        return

    lines.append(f'{indent}<dcsset:item xsi:type="dcsset:FilterItemComparison">')
    if item.get('use') is False:
        lines.append(f'{indent}\t<dcsset:use>false</dcsset:use>')
    lines.append(f'{indent}\t<dcsset:left xsi:type="dcscor:Field">{esc_xml_text(str(item.get("field", "")))}</dcsset:left>')
    # Регистронезависимый лукап (зеркало PS): Like/LIKE/ПОДОБНО → канон; иначе — как есть
    comp_type = _COMPARISON_TYPES_CI.get(str(item.get('op')).lower())
    if not comp_type:
        comp_type = str(item.get('op'))
    lines.append(f'{indent}\t<dcsset:comparisonType>{esc_xml_text(comp_type)}</dcsset:comparisonType>')
    val = item.get('value')
    if isinstance(val, list):
        if len(val) == 0:
            lines.append(f'{indent}\t<dcsset:right xsi:type="v8:ValueListType">')
            lines.append(f'{indent}\t\t<v8:valueType/>')
            lines.append(f'{indent}\t\t<v8:lastId xsi:type="xs:decimal">-1</v8:lastId>')
            lines.append(f'{indent}\t</dcsset:right>')
        else:
            for v in val:
                vt = _value_type_for(v, item.get('valueType'))
                v_str = str(v).lower() if isinstance(v, bool) else esc_xml_text(str(v))
                ns_attr = _value_type_ns_attr(vt, v)
                lines.append(f'{indent}\t<dcsset:right{ns_attr} xsi:type="{vt}">{v_str}</dcsset:right>')
    elif val is not None and (
            re.search(r'Standard(Beginning|End)Date$', str(item.get('valueType') or '')) or
            (not item.get('valueType') and isinstance(val, str) and re.match(r'^\d{4}-\d{2}-\d{2}T', val))):
        # Стандартная дата начала/окончания. Формы: объект {variant, date?} (Custom несёт <v8:date>);
        # строка-вариант "BeginningOfThisDay" (именованный без даты); голая ISO-дата без valueType —
        # шорткат для Custom+date (дата в фильтре почти всегда SBD Custom, корпус 268 vs 2 xs:dateTime).
        sd_type = re.sub(r'^v8:', '', str(item['valueType'])) if item.get('valueType') else 'StandardBeginningDate'
        if isinstance(val, dict):
            variant = str(val.get('variant', '')); date_v = val.get('date')
        elif isinstance(val, str) and re.match(r'^\d{4}-\d{2}-\d{2}T', val):
            variant = 'Custom'; date_v = val
        else:
            variant = str(val); date_v = None
        lines.append(f'{indent}\t<dcsset:right xsi:type="v8:{sd_type}">')
        lines.append(f'{indent}\t\t<v8:variant xsi:type="v8:{sd_type}Variant">{esc_xml_text(variant)}</v8:variant>')
        if date_v is not None:
            lines.append(f'{indent}\t\t<v8:date>{esc_xml_text(str(date_v))}</v8:date>')
        lines.append(f'{indent}\t</dcsset:right>')
    elif str(val) == '_':
        # "_" — маркер пустого значения: платформа эмитит пустой self-closing <dcsset:right>
        # (напр. <dcsset:right xsi:type="dcscor:Field"/> — сравнение с незаданным полем).
        vt = str(item['valueType']) if item.get('valueType') else 'xs:string'
        lines.append(f'{indent}\t<dcsset:right xsi:type="{vt}"/>')
    elif val is not None:
        vt = _value_type_for(val, item.get('valueType'))
        v_str = str(val).lower() if isinstance(val, bool) else esc_xml_text(str(val))
        ns_attr = _value_type_ns_attr(vt, val)
        lines.append(f'{indent}\t<dcsset:right{ns_attr} xsi:type="{vt}">{v_str}</dcsset:right>')
    if item.get('presentation'):
        emit_us_presentation(lines, f'{indent}\t', 'dcsset:presentation', item['presentation'])
    if item.get('viewMode'):
        lines.append(f'{indent}\t<dcsset:viewMode>{esc_xml_text(str(item["viewMode"]))}</dcsset:viewMode>')
    if item.get('userSettingID'):
        uid = new_uuid() if str(item['userSettingID']) == 'auto' else str(item['userSettingID'])
        lines.append(f'{indent}\t<dcsset:userSettingID>{esc_xml_text(uid)}</dcsset:userSettingID>')
    if item.get('userSettingPresentation'):
        emit_us_presentation(lines, f'{indent}\t', 'dcsset:userSettingPresentation', item['userSettingPresentation'])
    lines.append(f'{indent}</dcsset:item>')


def emit_filter(lines, items, indent, block_view_mode=None, block_user_setting_id=None, block_user_setting_presentation=None):
    has_items = bool(items) and len(items) > 0
    has_block_meta = (block_view_mode is not None) or (block_user_setting_id is not None) or (block_user_setting_presentation is not None)
    if not has_items and not has_block_meta:
        return
    lines.append(f'{indent}<dcsset:filter>')
    for item in (items or []):
        if isinstance(item, str):
            parsed = parse_filter_shorthand(item)
            obj = {'field': parsed['field'], 'op': parsed['op']}
            if parsed['use'] is False:
                obj['use'] = False
            if parsed['value'] is not None:
                obj['value'] = parsed['value']
            if parsed.get('valueType'):
                obj['valueType'] = parsed['valueType']
            if parsed.get('userSettingID'):
                obj['userSettingID'] = parsed['userSettingID']
            if parsed.get('viewMode'):
                obj['viewMode'] = parsed['viewMode']
            emit_filter_item(lines, obj, f'{indent}\t')
        else:
            emit_filter_item(lines, item, f'{indent}\t')
    if block_view_mode is not None:
        lines.append(f'{indent}\t<dcsset:viewMode>{esc_xml_text(str(block_view_mode))}</dcsset:viewMode>')
    if block_user_setting_id is not None:
        uid = new_uuid() if str(block_user_setting_id) == 'auto' else str(block_user_setting_id)
        lines.append(f'{indent}\t<dcsset:userSettingID>{esc_xml_text(uid)}</dcsset:userSettingID>')
    if block_user_setting_presentation is not None:
        emit_us_presentation(lines, f'{indent}\t', 'dcsset:userSettingPresentation', block_user_setting_presentation)
    lines.append(f'{indent}</dcsset:filter>')


def emit_order(lines, items, indent, skip_auto=False, block_view_mode=None, block_user_setting_id=None, block_user_setting_presentation=None):
    has_items = bool(items) and len(items) > 0
    has_block_meta = (block_view_mode is not None) or (block_user_setting_id is not None) or (block_user_setting_presentation is not None)
    if not has_items and not has_block_meta:
        return
    lines.append(f'{indent}<dcsset:order>')
    for item in (items or []):
        if isinstance(item, str):
            if item == 'Auto':
                if not skip_auto:
                    lines.append(f'{indent}\t<dcsset:item xsi:type="dcsset:OrderItemAuto"/>')
            else:
                parts = re.split(r'\s+', item)
                field = parts[0]
                direction = 'Asc'
                if len(parts) > 1 and re.match(r'(?i)^(desc|убыв)', parts[1]):
                    direction = 'Desc'
                elif len(parts) > 1 and re.match(r'(?i)^(asc|возр)', parts[1]):
                    direction = 'Asc'
                lines.append(f'{indent}\t<dcsset:item xsi:type="dcsset:OrderItemField">')
                lines.append(f'{indent}\t\t<dcsset:field>{esc_xml_text(field)}</dcsset:field>')
                lines.append(f'{indent}\t\t<dcsset:orderType>{direction}</dcsset:orderType>')
                lines.append(f'{indent}\t</dcsset:item>')
        else:
            if item.get('field') == 'Auto' or item.get('type') == 'auto':
                if not skip_auto:
                    lines.append(f'{indent}\t<dcsset:item xsi:type="dcsset:OrderItemAuto"/>')
                continue
            direction = str(item['direction']) if item.get('direction') else 'Asc'
            if re.match(r'(?i)^(desc|убыв)', direction):
                direction = 'Desc'
            elif re.match(r'(?i)^(asc|возр)', direction):
                direction = 'Asc'
            lines.append(f'{indent}\t<dcsset:item xsi:type="dcsset:OrderItemField">')
            if item.get('use') is False:
                lines.append(f'{indent}\t\t<dcsset:use>false</dcsset:use>')
            lines.append(f'{indent}\t\t<dcsset:field>{esc_xml_text(str(item.get("field", "")))}</dcsset:field>')
            lines.append(f'{indent}\t\t<dcsset:orderType>{direction}</dcsset:orderType>')
            if item.get('viewMode'):
                lines.append(f'{indent}\t\t<dcsset:viewMode>{esc_xml_text(str(item["viewMode"]))}</dcsset:viewMode>')
            lines.append(f'{indent}\t</dcsset:item>')
    if block_view_mode is not None:
        lines.append(f'{indent}\t<dcsset:viewMode>{esc_xml_text(str(block_view_mode))}</dcsset:viewMode>')
    if block_user_setting_id is not None:
        uid = new_uuid() if str(block_user_setting_id) == 'auto' else str(block_user_setting_id)
        lines.append(f'{indent}\t<dcsset:userSettingID>{esc_xml_text(uid)}</dcsset:userSettingID>')
    if block_user_setting_presentation is not None:
        emit_us_presentation(lines, f'{indent}\t', 'dcsset:userSettingPresentation', block_user_setting_presentation)
    lines.append(f'{indent}</dcsset:order>')


def emit_appearance_value(lines, key, val, indent):
    lines.append(f'{indent}<dcscor:item xsi:type="dcsset:SettingsParameterValue">')

    def _has_key(o, k):
        return isinstance(o, dict) and (k in o)

    def _get(o, k):
        return o.get(k) if isinstance(o, dict) else None

    is_top_level_line = _has_key(val, '@type') and (str(_get(val, '@type')) == 'Line')
    use_wrapper = False
    inner_val = val
    nested_items = None
    if is_top_level_line:
        if _has_key(val, 'use') and (_get(val, 'use') is False):
            use_wrapper = True
        if _has_key(val, 'items'):
            nested_items = _get(val, 'items')
    elif _has_key(val, 'value') and isinstance(val, dict):
        inner_val = _get(val, 'value')
        if _has_key(val, 'use') and (_get(val, 'use') is False):
            use_wrapper = True
        if _has_key(val, 'items'):
            nested_items = _get(val, 'items')
    if use_wrapper:
        lines.append(f'{indent}\t<dcscor:use>false</dcscor:use>')
    lines.append(f'{indent}\t<dcscor:parameter>{esc_xml_text(key)}</dcscor:parameter>')

    is_font_dict = isinstance(inner_val, dict) and inner_val.get('@type') is not None and str(inner_val.get('@type')) == 'Font'
    is_line_dict = _has_key(inner_val, '@type') and (str(_get(inner_val, '@type')) == 'Line')
    is_dict = isinstance(inner_val, dict)
    if is_line_dict:
        lw = _get(inner_val, 'width') if _has_key(inner_val, 'width') else 0
        lg = ('true' if _get(inner_val, 'gap') else 'false') if _has_key(inner_val, 'gap') else 'false'
        ls = str(_get(inner_val, 'style')) if _has_key(inner_val, 'style') else 'None'
        lines.append(f'{indent}\t<dcscor:value xsi:type="v8ui:Line" width="{lw}" gap="{lg}">')
        lines.append(f'{indent}\t\t<v8ui:style xsi:type="v8ui:SpreadsheetDocumentCellLineType">{esc_xml_text(ls)}</v8ui:style>')
        lines.append(f'{indent}\t</dcscor:value>')
    elif is_font_dict:
        attr_parts = []
        for attr_name in ('ref', 'faceName', 'height', 'bold', 'italic', 'underline', 'strikeout', 'kind', 'scale'):
            if attr_name in inner_val:
                av = inner_val[attr_name]
                if av is not None:
                    attr_parts.append(f'{attr_name}="{esc_xml(str(av))}"')
        lines.append(f'{indent}\t<dcscor:value xsi:type="v8ui:Font" {" ".join(attr_parts)}/>')
    elif is_dict and _has_key(inner_val, 'field'):
        # Ссылка на поле (dcscor:Field) — значение параметра оформления = поле компоновки
        lines.append(f'{indent}\t<dcscor:value xsi:type="dcscor:Field">{esc_xml_text(str(_get(inner_val, "field")))}</dcscor:value>')
    elif is_dict:
        # Локализуемый текст параметра оформления: платформа объявляет xsi:type на dcscor:value
        emit_mltext(lines, f'{indent}\t', 'dcscor:value', inner_val, xsi_type='v8:LocalStringType')
    else:
        actual_val = str(inner_val)
        key_type_map = {
            'Размещение': 'dcscor:DataCompositionTextPlacementType',
            'ГоризонтальноеПоложение': 'v8ui:HorizontalAlign',
            'ВертикальноеПоложение': 'v8ui:VerticalAlign',
            'ОриентацияТекста': 'xs:decimal',
            'РасположениеИтогов': 'dcscor:DataCompositionTotalPlacement',
            'ТипМакета': 'dcsset:DataCompositionGroupTemplateType',
        }
        key_type = key_type_map.get(key)
        if key_type:
            lines.append(f'{indent}\t<dcscor:value xsi:type="{key_type}">{esc_xml_text(actual_val)}</dcscor:value>')
        elif re.match(r'^(style|web|win):', actual_val):
            lines.append(f'{indent}\t<dcscor:value xsi:type="v8ui:Color">{esc_xml_text(actual_val)}</dcscor:value>')
        elif actual_val == 'true' or actual_val == 'false':
            lines.append(f'{indent}\t<dcscor:value xsi:type="xs:boolean">{actual_val}</dcscor:value>')
        elif key == 'Текст' or key == 'Заголовок' or key == 'Формат':
            # Голая строка = плоский xs:string (нелокализованный литерал). Локализуемый → объект {ru,en}.
            # Пустая строка → самозакрывающийся тег (как у платформы).
            if actual_val == '':
                lines.append(f'{indent}\t<dcscor:value xsi:type="xs:string"/>')
            else:
                lines.append(f'{indent}\t<dcscor:value xsi:type="xs:string">{esc_xml_text(actual_val)}</dcscor:value>')
        elif re.match(r'^-?\d+(\.\d+)?$', actual_val):
            lines.append(f'{indent}\t<dcscor:value xsi:type="xs:decimal">{actual_val}</dcscor:value>')
        elif key == 'ЦветТекста' or key == 'ЦветФона' or key == 'ЦветГраницы':
            lines.append(f'{indent}\t<dcscor:value xsi:type="v8ui:Color">{esc_xml_text(actual_val)}</dcscor:value>')
        else:
            lines.append(f'{indent}\t<dcscor:value xsi:type="xs:string">{esc_xml_text(actual_val)}</dcscor:value>')
    if nested_items:
        if isinstance(nested_items, dict):
            for nk, nv in nested_items.items():
                emit_appearance_value(lines, nk, nv, f'{indent}\t')
    lines.append(f'{indent}</dcscor:item>')


# === Группировка строк динамического списка (DCS-структура ListSettings) ===
# Линейная цепочка <dcsset:item StructureItemGroup> (каждый уровень = одно поле в groupItems;
# вложенность — через дочерний <dcsset:item>). Плоская модель уровней (список всегда линеен).
def get_list_grouping_value(s):
    for k in ('grouping', 'structure', 'группировка'):
        if s.get(k):
            return s[k]
    return None


def parse_list_grouping(grouping):
    # Шорткат "A > B > C" → массив имён; массив строк/объектов → как есть.
    if not grouping:
        return []
    if isinstance(grouping, str):
        return [p.strip() for p in re.split(r'\s*>\s*', grouping) if p.strip()]
    return list(grouping)


def emit_group_item_field(lines, level, indent):
    if isinstance(level, str):
        field, gt, pat = level, 'Items', 'None'
        pab = pae = '0001-01-01T00:00:00'
    else:
        field = str(level.get('field', ''))
        gt = str(level.get('groupType') or 'Items')
        pat = str(level.get('periodAdditionType') or 'None')
        pab = str(level.get('periodAdditionBegin') or '0001-01-01T00:00:00')
        pae = str(level.get('periodAdditionEnd') or '0001-01-01T00:00:00')
    lines.append(f'{indent}<dcsset:item xsi:type="dcsset:GroupItemField">')
    lines.append(f'{indent}\t<dcsset:field>{esc_xml_text(field)}</dcsset:field>')
    lines.append(f'{indent}\t<dcsset:groupType>{esc_xml_text(gt)}</dcsset:groupType>')
    lines.append(f'{indent}\t<dcsset:periodAdditionType>{esc_xml_text(pat)}</dcsset:periodAdditionType>')
    # Авто-детект: ISO-дата → xs:dateTime, иначе путь → dcscor:Field.
    pab_t = 'xs:dateTime' if re.match(r'^\d{4}-\d{2}-\d{2}T', pab) else 'dcscor:Field'
    pae_t = 'xs:dateTime' if re.match(r'^\d{4}-\d{2}-\d{2}T', pae) else 'dcscor:Field'
    lines.append(f'{indent}\t<dcsset:periodAdditionBegin xsi:type="{pab_t}">{esc_xml_text(pab)}</dcsset:periodAdditionBegin>')
    lines.append(f'{indent}\t<dcsset:periodAdditionEnd xsi:type="{pae_t}">{esc_xml_text(pae)}</dcsset:periodAdditionEnd>')
    lines.append(f'{indent}</dcsset:item>')


def emit_list_grouping_levels(lines, levels, i, indent):
    lines.append(f'{indent}<dcsset:item xsi:type="dcsset:StructureItemGroup">')
    lines.append(f'{indent}\t<dcsset:groupItems>')
    emit_group_item_field(lines, levels[i], f'{indent}\t\t')
    lines.append(f'{indent}\t</dcsset:groupItems>')
    if i < len(levels) - 1:
        emit_list_grouping_levels(lines, levels, i + 1, f'{indent}\t')
    lines.append(f'{indent}</dcsset:item>')


def emit_list_grouping(lines, grouping, indent):
    levels = parse_list_grouping(grouping)
    if not levels:
        return
    emit_list_grouping_levels(lines, levels, 0, indent)


# === Вычисляемые поля DataSet динамического списка (<CalculatedField>) ===
# Зеркало skd calculatedFields: shorthand "Имя [Заголовок]: тип = Выражение #noField #noFilter
# #noGroup #noOrder" или объект. Форм-специфика: dcssch:-теги + presentationExpression/orderExpression.
_CALC_RESTRICT_MAP = {'noField': 'field', 'noFilter': 'condition', 'noCondition': 'condition',
                      'noGroup': 'group', 'noOrder': 'order'}
_DCS_COMMON_NS = 'http://v8.1c.ru/8.1/data-composition-system/common'


def parse_calc_shorthand(s):
    restrict = re.findall(r'#(noField|noFilter|noCondition|noGroup|noOrder)\b', s)
    s = re.sub(r'\s*#(?:noField|noFilter|noCondition|noGroup|noOrder)\b', '', s)
    eq = s.find('=')
    lhs, rhs = (s[:eq], s[eq + 1:].strip()) if eq > 0 else (s, '')
    title = ''
    m = re.search(r'\[([^\]]+)\]', lhs)
    if m:
        title = m.group(1)
        lhs = re.sub(r'\s*\[[^\]]+\]', '', lhs)
    lhs = lhs.strip()
    typ, data_path = '', lhs
    if ':' in lhs:
        data_path, t = lhs.split(':', 1)
        data_path, typ = data_path.strip(), resolve_type_str(t.strip())
    return {'dataPath': data_path, 'expression': rhs, 'type': typ, 'title': title, 'restrict': restrict}


def emit_calc_fields(lines, calc_fields, indent):
    if not calc_fields:
        return
    for cf in calc_fields:
        if isinstance(cf, str):
            p = parse_calc_shorthand(cf)
            data_path, expression, title = p['dataPath'], p['expression'], p['title']
            type_str = p['type']
            restrict = [_CALC_RESTRICT_MAP[r] for r in p['restrict'] if r in _CALC_RESTRICT_MAP]
            pres_expr = order_expr = None
        else:
            data_path = str(cf.get('dataPath') or cf.get('field') or cf.get('name', ''))
            expression = str(cf.get('expression', ''))
            title = cf.get('title')
            type_str = cf.get('valueType') or cf.get('type')
            type_str = str(type_str) if type_str else None
            ur = cf.get('useRestriction') or cf.get('restrict')
            if isinstance(ur, dict):
                restrict = [k for k in ('field', 'condition', 'group', 'order') if ur.get(k)]
            elif isinstance(ur, str):
                restrict = [_CALC_RESTRICT_MAP.get(t.strip().lstrip('#'), t.strip().lstrip('#')) for t in ur.split() if t.strip()]
            elif isinstance(ur, list):
                restrict = [_CALC_RESTRICT_MAP.get(str(r), str(r)) for r in ur]
            else:
                restrict = []
            pres_expr = cf.get('presentationExpression')
            order_expr = cf.get('orderExpression')
        ci = f'{indent}\t'
        lines.append(f'{indent}<CalculatedField>')
        lines.append(f'{ci}<dcssch:dataPath>{esc_xml_text(data_path)}</dcssch:dataPath>')
        lines.append(f'{ci}<dcssch:expression>{esc_xml_text(expression)}</dcssch:expression>')
        if title:
            emit_mltext(lines, ci, 'dcssch:title', title, xsi_type='v8:LocalStringType')
        if restrict:
            lines.append(f'{ci}<dcssch:useRestriction>')
            for r in ('field', 'condition', 'group', 'order'):
                if r in restrict:
                    lines.append(f'{ci}\t<dcssch:{r}>true</dcssch:{r}>')
            lines.append(f'{ci}</dcssch:useRestriction>')
        if pres_expr:
            lines.append(f'{ci}<dcssch:presentationExpression>{esc_xml_text(str(pres_expr))}</dcssch:presentationExpression>')
        if order_expr:
            for oe in (order_expr if isinstance(order_expr, list) else [order_expr]):
                if isinstance(oe, str):
                    expr_v, otype, auto = oe, 'Asc', 'false'
                else:
                    expr_v = str(oe.get('expression', ''))
                    otype = str(oe.get('orderType', 'Asc'))
                    auto = 'true' if oe.get('autoOrder') else 'false'
                lines.append(f'{ci}<dcssch:orderExpression>')
                lines.append(f'{ci}\t<expression xmlns="{_DCS_COMMON_NS}">{esc_xml_text(expr_v)}</expression>')
                lines.append(f'{ci}\t<orderType xmlns="{_DCS_COMMON_NS}">{otype}</orderType>')
                lines.append(f'{ci}\t<autoOrder xmlns="{_DCS_COMMON_NS}">{auto}</autoOrder>')
                lines.append(f'{ci}</dcssch:orderExpression>')
        if type_str:
            emit_dl_value_type(lines, type_str, ci)
        lines.append(f'{indent}</CalculatedField>')


# Ограничения использования поля/вычисляемого поля (useRestriction / attributeUseRestriction).
# Значение: объект {field?,condition?,group?,order?} | флаг-строка "#noField …" | массив.
def parse_restrict(ur):
    if not ur:
        return []
    if isinstance(ur, dict):
        return [k for k in ('field', 'condition', 'group', 'order') if ur.get(k)]
    if isinstance(ur, str):
        return [_CALC_RESTRICT_MAP.get(t.strip().lstrip('#'), t.strip().lstrip('#')) for t in ur.split() if t.strip()]
    if isinstance(ur, list):
        return [_CALC_RESTRICT_MAP.get(str(r), str(r)) for r in ur]
    return []


def emit_restrict_block(lines, tag, ur, indent):
    r = parse_restrict(ur)
    if not r:
        return
    lines.append(f'{indent}<dcssch:{tag}>')
    for k in ('field', 'condition', 'group', 'order'):
        if k in r:
            lines.append(f'{indent}\t<dcssch:{k}>true</dcssch:{k}>')
    lines.append(f'{indent}</dcssch:{tag}>')


def emit_conditional_appearance(lines, items, indent, block_view_mode=None, block_user_setting_id=None, wrap_tag='dcsset:conditionalAppearance', block_user_setting_presentation=None):
    has_items = bool(items) and len(items) > 0
    has_block_meta = (block_view_mode is not None) or (block_user_setting_id is not None) or (block_user_setting_presentation is not None)
    if not has_items and not has_block_meta:
        return
    lines.append(f'{indent}<{wrap_tag}>')
    for ca in (items or []):
        lines.append(f'{indent}\t<dcsset:item>')
        if ca.get('use') is False:
            lines.append(f'{indent}\t\t<dcsset:use>false</dcsset:use>')
        if ca.get('selection') and len(ca['selection']) > 0:
            lines.append(f'{indent}\t\t<dcsset:selection>')
            for sel in ca['selection']:
                lines.append(f'{indent}\t\t\t<dcsset:item>')
                lines.append(f'{indent}\t\t\t\t<dcsset:field>{esc_xml_text(str(sel))}</dcsset:field>')
                lines.append(f'{indent}\t\t\t</dcsset:item>')
            lines.append(f'{indent}\t\t</dcsset:selection>')
        else:
            lines.append(f'{indent}\t\t<dcsset:selection/>')
        if ca.get('filter') and len(ca['filter']) > 0:
            emit_filter(lines, ca['filter'], f'{indent}\t\t')
        else:
            lines.append(f'{indent}\t\t<dcsset:filter/>')
        if ca.get('appearance'):
            lines.append(f'{indent}\t\t<dcsset:appearance>')
            for k, v in ca['appearance'].items():
                emit_appearance_value(lines, k, v, f'{indent}\t\t\t')
            lines.append(f'{indent}\t\t</dcsset:appearance>')
        if ca.get('presentation'):
            if isinstance(ca['presentation'], dict):
                # Мультиязык → LocalStringType (платформа объявляет тип у локализованного presentation)
                lines.append(f'{indent}\t\t<dcsset:presentation xsi:type="v8:LocalStringType">')
                emit_ml_items(lines, f'{indent}\t\t\t', ca['presentation'])
                lines.append(f'{indent}\t\t</dcsset:presentation>')
            else:
                lines.append(f'{indent}\t\t<dcsset:presentation xsi:type="xs:string">{esc_xml_text(str(ca["presentation"]))}</dcsset:presentation>')
        if ca.get('viewMode'):
            lines.append(f'{indent}\t\t<dcsset:viewMode>{esc_xml_text(str(ca["viewMode"]))}</dcsset:viewMode>')
        if ca.get('userSettingID'):
            uid = new_uuid() if str(ca['userSettingID']) == 'auto' else str(ca['userSettingID'])
            lines.append(f'{indent}\t\t<dcsset:userSettingID>{esc_xml_text(uid)}</dcsset:userSettingID>')
        if ca.get('userSettingPresentation'):
            emit_us_presentation(lines, f'{indent}\t\t', 'dcsset:userSettingPresentation', ca['userSettingPresentation'])
        if ca.get('useInDontUse') and len(ca['useInDontUse']) > 0:
            use_in_order = ['group', 'hierarchicalGroup', 'overall', 'fieldsHeader', 'header',
                            'parameters', 'filter', 'resourceFieldsHeader', 'overallHeader',
                            'overallResourceFieldsHeader']
            sset = {str(n): True for n in ca['useInDontUse']}
            for n in use_in_order:
                if n in sset:
                    tag = 'useIn' + n[0].upper() + n[1:]
                    lines.append(f'{indent}\t\t<dcsset:{tag}>DontUse</dcsset:{tag}>')
        lines.append(f'{indent}\t</dcsset:item>')
    if block_view_mode is not None:
        lines.append(f'{indent}\t<dcsset:viewMode>{esc_xml_text(str(block_view_mode))}</dcsset:viewMode>')
    if block_user_setting_id is not None:
        uid = new_uuid() if str(block_user_setting_id) == 'auto' else str(block_user_setting_id)
        lines.append(f'{indent}\t<dcsset:userSettingID>{esc_xml_text(uid)}</dcsset:userSettingID>')
    if block_user_setting_presentation is not None:
        emit_us_presentation(lines, f'{indent}\t', 'dcsset:userSettingPresentation', block_user_setting_presentation)
    lines.append(f'{indent}</{wrap_tag}>')


def write_utf8_bom(path, content):
    # newline='' — без трансляции: иначе текстовый режим Python дал бы CRLF на Windows
    # и LF на macOS, то есть вывод навыка зависел бы от ОС.
    with open(path, 'w', encoding='utf-8-sig', newline='') as f:
        f.write(content)



# --- ID allocator ---
_next_id = 0

def new_id():
    global _next_id
    _next_id += 1
    return _next_id


# Уникальность имён внутри коллекции (1С: элементы/реквизиты/команды/параметры/колонки — каждое своё
# пространство имён). Дубль → битый XML, форма не открывается, поэтому fail-fast.
_seen_element_names = set()  # пул имён элементов (глобально по всей форме)

def _ensure_unique(name, seen, kind):
    if name in seen:
        print(f"[ERROR] Duplicate {kind} name '{name}' — names must be unique within their collection in a 1C form (set a unique 'name')", file=sys.stderr)
        sys.exit(1)
    seen.add(name)


# --- 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",
}

KNOWN_EVENTS = {
    "input": ["OnChange", "StartChoice", "ChoiceProcessing", "AutoComplete", "TextEditEnd", "Clearing", "Creating", "EditTextChange"],
    "check": ["OnChange"],
    "radio": ["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": [],
}

KNOWN_FORM_EVENTS = [
    "OnCreateAtServer", "OnOpen", "BeforeClose", "OnClose", "NotificationProcessing",
    "ChoiceProcessing", "OnReadAtServer", "AfterWriteAtServer", "BeforeWriteAtServer",
    "AfterWrite", "BeforeWrite", "OnWriteAtServer", "FillCheckProcessingAtServer",
    "OnLoadDataFromSettingsAtServer", "BeforeLoadDataFromSettingsAtServer",
    "OnSaveDataInSettingsAtServer", "ExternalEvent", "OnReopen", "Opening",
]

KNOWN_KEYS = {
    "group", "columnGroup", "buttonGroup", "input", "check", "radio", "label", "labelField", "table", "pages", "page",
    "button", "picture", "picField", "calendar", "cmdBar", "popup",
    "showInHeader",
    "radioButtonType", "choiceList", "columnsCount", "checkBoxType", "editMode",
    "name", "path", "title", "tooltip", "tooltipRepresentation", "extendedTooltip",
    "visible", "hidden", "enabled", "disabled", "readOnly", "userVisible",
    "events", "on", "handlers",
    "selectionMode", "showCurrentDate", "widthInMonths", "heightInMonths", "showMonthsPanel",
    "titleLocation", "representation", "width", "height",
    "horizontalStretch", "verticalStretch", "autoMaxWidth", "autoMaxHeight",
    "maxWidth", "maxHeight",
    "groupHorizontalAlign", "groupVerticalAlign", "horizontalAlign",
    "multiLine", "passwordMode", "choiceButton", "clearButton",
    "spinButton", "dropListButton", "markIncomplete", "skipOnInput", "inputHint",
    "textEdit", "choiceList",
    "wrap", "openButton", "listChoiceMode", "showInHeader", "showInFooter",
    "extendedEditMultipleValues", "chooseType", "autoCellHeight",
    "choiceButtonRepresentation", "footerHorizontalAlign", "headerHorizontalAlign",
    "headerDataPath", "headerFormat", "currentRowUse",
    "format", "editFormat", "choiceParameters", "choiceParameterLinks", "typeLink",
    "hyperlink", "formatted",
    "collapsedTitle", "showTitle", "united", "collapsed", "behavior",
    "children", "columns",
    "changeRowSet", "changeRowOrder", "autoInsertNewRow", "rowFilter", "header", "footer",
    "commandBarLocation", "searchStringLocation", "viewStatusLocation", "searchControlLocation",
    "excludedCommands",
    "pagesRepresentation",
    "type", "command", "commandName", "stdCommand", "parameter", "defaultButton", "locationInCommandBar", "displayImportance",
    "commandBar", "contextMenu", "commandSource",
    "src", "valuesPicture", "loadTransparent", "headerPicture", "footerPicture",
    "autofill",
    "choiceMode", "initialTreeView", "enableDrag", "enableStartDrag",
    "rowSelectionMode", "verticalLines", "horizontalLines",
    "rowPictureDataPath", "tableAutofill", "heightInTableRows",
    "multipleChoice", "searchOnInput", "shortcut",
    # dynamic-list table block
    "defaultItem", "useAlternationRowColor", "fileDragMode", "autoRefresh",
    "autoRefreshPeriod", "choiceFoldersAndItems", "restoreCurrentRow", "showRoot",
    "allowRootChoice", "updateOnDataChange", "allowGettingCurrentRowURL",
    "userSettingsGroup", "rowsPicture",
    # AutoCommandBar-маркер (autofill heuristic) на элементе/таблице
    "autoCmdBar",
    # дополнения командной панели таблицы (тип-ключи + свойства)
    "searchString", "viewStatus", "searchControl", "source", "horizontalLocation", "additions",
    # generic-скаляры (pass-through)
    "verticalAlign", "throughAlign", "enableContentChange", "pictureSize", "titleHeight",
    "childItemsWidth", "showLeftMargin", "cellHyperlink", "viewMode", "verticalScrollBar",
    "rowInputMode", "mask", "createButton", "fixingInTable", "verticalSpacing",
    # InputField choice-скаляры
    "choiceListButton", "quickChoice", "autoChoiceIncomplete",
    "choiceForm", "choiceHistoryOnInput", "footerDataPath", "minValue", "maxValue",
    # Button — пометка toggle-кнопки
    "checked",
    # спец-поля (документ/датчик/диаграмма) — тип-ключи + типоспец. скаляры
    "spreadsheet", "html", "textDoc", "formattedDoc", "progressBar", "trackBar",
    "chart", "ganttChart", "graphicalSchema", "planner", "periodField", "dendrogram", "ganttTable",
    "showPercent", "largeStep", "markingStep", "step",
    "horizontalScrollBar", "viewScalingMode", "output", "selectionShowMode", "protection",
    "edit", "showGrid", "showGroups", "showHeaders", "showRowAndColumnNames", "showCellNames",
    "pointerType", "drawingSelectionShowMode", "warningOnEditRepresentation", "markingAppearance",
    # report-form контекст (generic-скаляры элементов)
    "horizontalSpacing", "representationInContextMenu", "settingsNamedItemDetailedRepresentation",
    # хвост: высота элемента списка / ширина выпадающего списка / картинка кнопки выбора / прозрачный пиксель
    "itemHeight", "dropListWidth", "choiceButtonPicture", "transparentPixel",
    # хвост CI-форм: динамический заголовок / расширенное редактирование / высота таблицы
    "titleDataPath", "extendedEdit", "maxRowsCount", "autoMaxRowsCount", "heightControlVariant",
    "warningOnEdit", "nonselectedPictureText", "editTextUpdate", "footerText",
}

# picture/picField — НИЗКИЙ приоритет: 'picture' это и тип (PictureDecoration), и свойство-иконка
# у popup/button/cmdBar. Тип-ключ владельца (popup/button/…) должен выиграть.
# pages/page ПЕРЕД group: у Page/Pages ключ 'group' — это направление раскладки детей
# (<Group>Horizontal</Group>), а не тип UsualGroup. Реальная UsualGroup ключа page/pages не несёт.
TYPE_KEYS = ["columnGroup", "buttonGroup", "pages", "page", "group", "input", "check", "radio", "label", "labelField", "table",
             "button", "calendar", "cmdBar", "popup", "searchString", "viewStatus", "searchControl", "picField", "picture",
             "spreadsheet", "html", "textDoc", "formattedDoc", "progressBar", "trackBar",
             "chart", "ganttChart", "graphicalSchema", "planner", "periodField", "dendrogram"]

# Synonyms: model often writes XML name or Russian (ПолеПереключателя/RadioButtonField → radio)
ELEMENT_TYPE_SYNONYMS = {
    "commandBar": "cmdBar",
    "autoCommandBar": "autoCmdBar",
    "КоманднаяПанель": "cmdBar",
    "InputField": "input",
    "ПолеВвода": "input",
    "CheckBoxField": "check",
    "ПолеФлажка": "check",
    "RadioButtonField": "radio",
    "ПолеПереключателя": "radio",
    "radioButton": "radio",
    "PictureField": "picField",
    "ПолеКартинки": "picField",
    "LabelField": "labelField",
    "ПолеНадписи": "labelField",
    "CalendarField": "calendar",
    "ПолеКалендаря": "calendar",
    "LabelDecoration": "label",
    "Надпись": "label",
    "PictureDecoration": "picture",
    "Картинка": "picture",
    "UsualGroup": "group",
    "Группа": "group",
    "ОбычнаяГруппа": "group",
    "ColumnGroup": "columnGroup",
    "ГруппаКолонок": "columnGroup",
    "Pages": "pages",
    "ГруппаСтраниц": "pages",
    "Page": "page",
    "Страница": "page",
    "Table": "table",
    "Таблица": "table",
    "Button": "button",
    "Кнопка": "button",
    "Popup": "popup",
    "ВсплывающееМеню": "popup",
    # дополнения командной панели таблицы — forgiving: XML-тег/Type/рус.имя → канон
    "SearchStringAddition": "searchString",
    "SearchStringRepresentation": "searchString",
    "строкаПоиска": "searchString",
    "отображениеСтрокиПоиска": "searchString",
    "Отображение строки поиска": "searchString",
    "ViewStatusAddition": "viewStatus",
    "ViewStatusRepresentation": "viewStatus",
    "состояниеПросмотра": "viewStatus",
    "Состояние просмотра": "viewStatus",
    "SearchControlAddition": "searchControl",
    "SearchControl": "searchControl",
    "управлениеПоиском": "searchControl",
    "Управление поиском": "searchControl",
    # Спец-поля (документ/датчик) — XML-имя/рус. → канон
    "SpreadSheetDocumentField": "spreadsheet",
    "ПолеТабличногоДокумента": "spreadsheet",
    "HTMLDocumentField": "html",
    "ПолеHTMLДокумента": "html",
    "TextDocumentField": "textDoc",
    "ПолеТекстовогоДокумента": "textDoc",
    "FormattedDocumentField": "formattedDoc",
    "ПолеФорматированногоДокумента": "formattedDoc",
    "ProgressBarField": "progressBar",
    "ПолеИндикатора": "progressBar",
    "TrackBarField": "trackBar",
    "ПолеПолосыРегулирования": "trackBar",
    "ChartField": "chart",
    "ПолеДиаграммы": "chart",
    "GanttChartField": "ganttChart",
    "ПолеДиаграммыГанта": "ganttChart",
    "GraphicalSchemaField": "graphicalSchema",
    "ПолеГрафическойСхемы": "graphicalSchema",
    "PlannerField": "planner",
    "ПолеПланировщика": "planner",
    "PeriodField": "periodField",
    "ПолеПериода": "periodField",
    "DendrogramField": "dendrogram",
    "ПолеДендрограммы": "dendrogram",
}

# Тип-синонимы, применяемые ТОЛЬКО к строковому значению (имя элемента); объект/массив
# у того же слова — companion-панель (свойство), см. normalize_panel_synonyms.
STR_ONLY_TYPE_SYNONYMS = {"commandBar", "autoCommandBar", "КоманднаяПанель"}

# Companion-панели как СВОЙСТВА (значение объект/массив): синоним → каноника.
PANEL_SYNONYMS = {
    'commandBar': ['commandBar', 'autoCommandBar', 'AutoCommandBar', 'autoCmdBar', 'cmdBar', 'КоманднаяПанель'],
    'contextMenu': ['contextMenu', 'ContextMenu', 'КонтекстноеМеню'],
}


def normalize_panel_synonyms(el):
    if not isinstance(el, dict):
        return
    for canon, syns in PANEL_SYNONYMS.items():
        for syn in syns:
            if syn in el and isinstance(el[syn], (list, dict)):
                if syn != canon and canon not in el:
                    el[canon] = el.pop(syn)
                break


# Maps Russian/English root of typed reference path to canonical English root
REF_ROOT_SYNONYMS = {
    "Перечисление": "Enum",
    "Справочник": "Catalog",
    "Документ": "Document",
    "ПланСчетов": "ChartOfAccounts",
    "ПланВидовХарактеристик": "ChartOfCharacteristicTypes",
    "ПланВидовРасчета": "ChartOfCalculationTypes",
    "ПланВидовРасчёта": "ChartOfCalculationTypes",
    "ПланОбмена": "ExchangePlan",
    "БизнесПроцесс": "BusinessProcess",
    "Задача": "Task",
    "РегистрСведений": "InformationRegister",
    "РегистрНакопления": "AccumulationRegister",
    "РегистрБухгалтерии": "AccountingRegister",
    "РегистрРасчета": "CalculationRegister",
    "РегистрРасчёта": "CalculationRegister",
    "ЖурналДокументов": "DocumentJournal",
    "КритерийОтбора": "FilterCriterion",
}
ENUM_VALUE_SYNONYMS = {"EnumValue", "ЗначениеПеречисления"}


def normalize_meta_type_ref(ref):
    # "Справочник.Контрагенты" → "Catalog.Контрагенты"; уже англ — без изменений
    if not ref:
        return ref
    dot = ref.find('.')
    if dot < 1:
        return ref
    root = ref[:dot]
    if root in REF_ROOT_SYNONYMS:
        return REF_ROOT_SYNONYMS[root] + ref[dot:]
    return ref


def normalize_choice_value(value):
    """Returns dict {xsi_type, text} for a choiceList item value."""
    if isinstance(value, bool):
        return {"xsi_type": "xs:boolean", "text": "true" if value else "false"}
    if isinstance(value, (int, float)):
        return {"xsi_type": "xs:decimal", "text": str(value)}

    s = "" if value is None else str(value)
    if not s:
        return {"xsi_type": "xs:string", "text": ""}

    # ISO datetime ("2020-01-01T00:00:00") → xs:dateTime
    if re.fullmatch(r'\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}', s):
        return {"xsi_type": "xs:dateTime", "text": s}

    # Raw-ссылка по GUID (метаданные.значение) "GUID.GUID" → xr:DesignTimeRef (всегда ссылка, не строка)
    if re.fullmatch(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}\.[0-9a-fA-F]{8}-[0-9a-fA-F-]+', s):
        return {"xsi_type": "xr:DesignTimeRef", "text": s}

    parts = s.split(".")
    if len(parts) >= 2:
        root = parts[0]
        canon_root = None
        if root in REF_ROOT_SYNONYMS:
            canon_root = REF_ROOT_SYNONYMS[root]
        elif root in REF_ROOT_SYNONYMS.values():
            canon_root = root

        if canon_root:
            type_name = parts[1]
            normalized = None
            if canon_root == "Enum":
                if len(parts) == 3 and parts[2] == 'EmptyRef':
                    # "Enum.X.EmptyRef" — пустая ссылка, НЕ значение перечисления (без .EnumValue.)
                    normalized = f"Enum.{type_name}.EmptyRef"
                elif len(parts) == 3:
                    normalized = f"Enum.{type_name}.EnumValue.{parts[2]}"
                elif len(parts) >= 4:
                    member = parts[2]
                    if member in ENUM_VALUE_SYNONYMS:
                        rest = ".".join(parts[3:])
                    else:
                        rest = ".".join(parts[2:])
                    normalized = f"Enum.{type_name}.EnumValue.{rest}"
            else:
                if len(parts) >= 3:
                    tail = ".".join(parts[1:])
                    normalized = f"{canon_root}.{tail}"

            if normalized:
                return {"xsi_type": "xr:DesignTimeRef", "text": normalized}

    return {"xsi_type": "xs:string", "text": s}


def emit_choice_presentation(lines, pres, indent):
    """Accepts None/empty → <Presentation/>; str → ru only; dict → multi-lang."""
    if pres is None or (isinstance(pres, str) and pres == ""):
        lines.append(f"{indent}<Presentation/>")
        return

    if isinstance(pres, str):
        pairs = [("ru", pres)]
    elif isinstance(pres, dict):
        pairs = [(str(k), str(v)) for k, v in pres.items()]
    else:
        pairs = [("ru", str(pres))]

    lines.append(f"{indent}<Presentation>")
    for lang, content in pairs:
        lines.append(f"{indent}\t<v8:item>")
        lines.append(f"{indent}\t\t<v8:lang>{lang}</v8:lang>")
        lines.append(f"{indent}\t\t<v8:content>{esc_xml_text(content)}</v8:content>")
        lines.append(f"{indent}\t</v8:item>")
    lines.append(f"{indent}</Presentation>")


def choice_value_tag(norm):
    # <Value> для choiceList/choiceParameters: пустой текст → самозакрывающийся тег (зеркало платформы).
    if not norm["text"]:
        return f'<Value xsi:type="{norm["xsi_type"]}"/>'
    return f'<Value xsi:type="{norm["xsi_type"]}">{esc_xml_text(norm["text"])}</Value>'


def emit_choice_list(lines, el, indent):
    # <ChoiceList> — у RadioButtonField и InputField. Элемент: { value, presentation?/title? }.
    choice_list = el.get('choiceList') or []
    if not choice_list:
        return
    lines.append(f'{indent}<ChoiceList>')
    item_indent = f'{indent}\t'
    for item in choice_list:
        if not isinstance(item, dict):
            continue
        val_raw = item.get('value', item.get('значение'))
        has_pres = any(k in item for k in ('presentation', 'представление', 'title'))
        pres_raw = item.get('presentation', item.get('представление', item.get('title')))

        # valueType: явный xsi:type значения (системное перечисление ent:*, иной не-примитив) —
        # переопределяет авто-детект (normalize_choice_value вывела бы xs:string).
        vt_raw = item.get('valueType')
        if vt_raw == 'nil':
            norm = {'xsi_type': None, 'text': None, 'nil': True}
        elif vt_raw:
            norm = {'xsi_type': str(vt_raw), 'text': '' if val_raw is None else str(val_raw)}
        else:
            norm = normalize_choice_value(val_raw)

        if not has_pres:
            if norm.get('xsi_type') == 'xr:DesignTimeRef':
                tail = norm['text'].split('.')[-1]
                pres_raw = title_from_name(tail)
            else:
                pres_raw = norm.get('text')

        lines.append(f'{item_indent}<xr:Item>')
        val_indent = f'{item_indent}\t'
        lines.append(f'{val_indent}<xr:Presentation/>')
        lines.append(f'{val_indent}<xr:CheckState>0</xr:CheckState>')
        lines.append(f'{val_indent}<xr:Value xsi:type="FormChoiceListDesTimeValue">')
        emit_choice_presentation(lines, pres_raw, f'{val_indent}\t')
        val_tag = '<Value xsi:nil="true"/>' if norm.get('nil') else choice_value_tag(norm)
        lines.append(f'{val_indent}\t{val_tag}')
        lines.append(f'{val_indent}</xr:Value>')
        lines.append(f'{item_indent}</xr:Item>')
    lines.append(f'{indent}</ChoiceList>')


def get_el_prop(obj, names):
    # Читает свойство из dict по списку синонимов (первый найденный, иначе None).
    if not isinstance(obj, dict):
        return None
    for n in names:
        if n in obj:
            return obj[n]
    return None


def to_scalar_literal(s):
    # Литерал shorthand → тип: true/false → bool, целое/дробное → число, иначе строка.
    t = str(s).strip()
    if t.lower() == 'true':
        return True
    if t.lower() == 'false':
        return False
    if re.fullmatch(r'-?\d+', t):
        return int(t)
    if re.fullmatch(r'-?\d+\.\d+', t):
        return float(t)
    return t


def from_choice_param_shorthand(s):
    # "name=value" либо "name=v1, v2, …" (запятые → массив). → {name, value}.
    eq = s.find('=')
    if eq < 0:
        return {'name': s.strip()}
    name = s[:eq].strip()
    rest = s[eq + 1:]
    if ',' in rest:
        return {'name': name, 'value': [to_scalar_literal(p) for p in rest.split(',')]}
    return {'name': name, 'value': to_scalar_literal(rest)}


def from_choice_param_link_shorthand(s):
    # "name=dataPath" либо "name=dataPath:DontChange". → {name, dataPath, valueChange?}.
    eq = s.find('=')
    if eq < 0:
        return {'name': s.strip()}
    o = {'name': s[:eq].strip()}
    rest = s[eq + 1:].strip()
    m = re.fullmatch(r'(.*):(Clear|DontChange|очистить|неизменять)', rest, re.IGNORECASE)
    if m:
        o['dataPath'] = m.group(1).strip()
        o['valueChange'] = m.group(2)
    else:
        o['dataPath'] = rest
    return o


def from_type_link_shorthand(s):
    # "dataPath" либо "dataPath#linkItem". → {dataPath, linkItem}.
    m = re.fullmatch(r'(.*)#(\d+)', str(s))
    if m:
        return {'dataPath': m.group(1).strip(), 'linkItem': int(m.group(2))}
    return {'dataPath': str(s).strip()}


def emit_choice_param_value(lines, value, indent):
    # Внутреннее значение параметра выбора (FormChoiceListDesTimeValue): <Presentation/> + <Value>.
    # Скаляр → один Value; массив → v8:FixedArray из вложенных FormChoiceListDesTimeValue.
    lines.append(f'{indent}<Presentation/>')
    if isinstance(value, (list, tuple)):
        lines.append(f'{indent}<Value xsi:type="v8:FixedArray">')
        for v in value:
            norm = normalize_choice_value(v)
            lines.append(f'{indent}\t<v8:Value xsi:type="FormChoiceListDesTimeValue">')
            lines.append(f'{indent}\t\t<Presentation/>')
            lines.append(f'{indent}\t\t{choice_value_tag(norm)}')
            lines.append(f'{indent}\t</v8:Value>')
        lines.append(f'{indent}</Value>')
    else:
        norm = normalize_choice_value(value)
        lines.append(f'{indent}{choice_value_tag(norm)}')


def emit_choice_parameters(lines, el, indent):
    # <ChoiceParameters> (параметры выбора поля ввода) — [{name, value}]. value через
    # normalize_choice_value; массив значений → FixedArray. Рус. синонимы имя/значение.
    cp = el.get('choiceParameters') or []
    if not cp:
        return
    lines.append(f'{indent}<ChoiceParameters>')
    for item in cp:
        if isinstance(item, str):
            item = from_choice_param_shorthand(item)
        name = get_el_prop(item, ('name', 'имя'))
        has_val = isinstance(item, dict) and ('value' in item or 'значение' in item)
        val = get_el_prop(item, ('value', 'значение'))
        name_s = '' if name is None else str(name)
        lines.append(f'{indent}\t<app:item name="{esc_xml(name_s)}">')
        # Параметр выбора без значения → <app:value xsi:nil="true"/> (платформа, 13 в корпусе);
        # со значением (в т.ч. пустой строкой) → FormChoiceListDesTimeValue.
        if not has_val:
            lines.append(f'{indent}\t\t<app:value xsi:nil="true"/>')
        else:
            lines.append(f'{indent}\t\t<app:value xsi:type="FormChoiceListDesTimeValue">')
            emit_choice_param_value(lines, val, f'{indent}\t\t\t')
            lines.append(f'{indent}\t\t</app:value>')
        lines.append(f'{indent}\t</app:item>')
    lines.append(f'{indent}</ChoiceParameters>')


def emit_choice_parameter_links(lines, el, indent):
    # <ChoiceParameterLinks> (связи параметров выбора) — [{name, dataPath, valueChange?}].
    # valueChange всегда эмитится, дефолт Clear; forgiving Clear/DontChange + рус. синонимы.
    cpl = el.get('choiceParameterLinks') or []
    if not cpl:
        return
    lines.append(f'{indent}<ChoiceParameterLinks>')
    for lk in cpl:
        if isinstance(lk, str):
            lk = from_choice_param_link_shorthand(lk)
        name = get_el_prop(lk, ('name', 'имя'))
        dp = get_el_prop(lk, ('dataPath', 'path', 'путь'))
        vc_raw = get_el_prop(lk, ('valueChange', 'режимИзменения'))
        vc = 'Clear'
        if vc_raw:
            s = str(vc_raw).lower()
            if s in ('clear', 'очистить', 'очистка'):
                vc = 'Clear'
            elif s in ('dontchange', 'неизменять', 'неменять', 'нет'):
                vc = 'DontChange'
            else:
                vc = str(vc_raw)
        name_s = '' if name is None else str(name)
        dp_s = '' if dp is None else str(dp)
        lines.append(f'{indent}\t<xr:Link>')
        lines.append(f'{indent}\t\t<xr:Name>{esc_xml_text(name_s)}</xr:Name>')
        lines.append(f'{indent}\t\t<xr:DataPath xsi:type="xs:string">{esc_xml_text(dp_s)}</xr:DataPath>')
        lines.append(f'{indent}\t\t<xr:ValueChange>{vc}</xr:ValueChange>')
        lines.append(f'{indent}\t</xr:Link>')
    lines.append(f'{indent}</ChoiceParameterLinks>')


def emit_type_link(lines, el, indent):
    # <TypeLink> (связь по типу) — {dataPath, linkItem}. linkItem дефолт 0.
    tl = el.get('typeLink')
    if not tl:
        return
    if isinstance(tl, str):
        tl = from_type_link_shorthand(tl)
    dp = get_el_prop(tl, ('dataPath', 'path', 'путь'))
    li = get_el_prop(tl, ('linkItem', 'элементСвязи'))
    if li is None:
        li = 0
    dp_s = '' if dp is None else str(dp)
    lines.append(f'{indent}<TypeLink>')
    lines.append(f'{indent}\t<xr:DataPath>{esc_xml_text(dp_s)}</xr:DataPath>')
    lines.append(f'{indent}\t<xr:LinkItem>{li}</xr:LinkItem>')
    lines.append(f'{indent}</TypeLink>')


def normalize_radio_button_type(raw):
    if not raw:
        return "Auto"
    s = str(raw).strip().lower()
    if s in ("auto", "авто"):
        return "Auto"
    if s in ("radiobutton", "radiobuttons", "переключатель", "радио"):
        return "RadioButtons"
    if s in ("tumbler", "тумблер"):
        return "Tumbler"
    return str(raw).strip()


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}"


def get_element_name(el, type_key):
    if el.get('name'):
        return str(el['name'])
    return str(el.get(type_key, ''))


# Собрать упорядоченный список событий элемента (имя, обработчик) из DSL.
# Основной формат: el['events'] = { Событие: ИмяОбработчика } (None/"" → авто-имя по конвенции).
# Legacy (принимается ради совместимости): el['on'] (массив) + el['handlers'] (переопределение имён).
def get_event_pairs(el, element_name):
    pairs = []
    events = el.get('events')
    if events:
        for ev_name, val in events.items():
            handler = '' if val is None else str(val)
            if not handler:
                handler = get_handler_name(element_name, ev_name)
            pairs.append((ev_name, handler))
    elif el.get('on'):
        handlers = el.get('handlers') or {}
        for evt in el['on']:
            evt_name = str(evt)
            if handlers.get(evt_name):
                handler = str(handlers[evt_name])
            else:
                handler = get_handler_name(element_name, evt_name)
            pairs.append((evt_name, handler))
    return pairs


# Проверить, подключено ли событие к элементу (в любом из форматов).
def test_element_event(el, event_name):
    events = el.get('events')
    if events and event_name in events:
        return True
    return event_name in (el.get('on') or [])


def emit_events(lines, el, element_name, indent, type_key):
    pairs = get_event_pairs(el, element_name)
    if not pairs:
        return

    # Validate event names
    if type_key and type_key in KNOWN_EVENTS:
        allowed = KNOWN_EVENTS[type_key]
        for ev_name, _ in pairs:
            if allowed and str(ev_name) not in allowed:
                print(f"[WARN] Unknown event '{ev_name}' for {type_key} '{element_name}'. Known: {', '.join(allowed)}")

    lines.append(f"{indent}<Events>")
    for ev_name, handler in pairs:
        lines.append(f'{indent}\t<Event name="{ev_name}">{handler}</Event>')
    lines.append(f"{indent}</Events>")


# Детектор «настоящей» inline-разметки (1С: <link>/<b>/<color>/… и </>). Должен быть
# идентичен form-decompile/form-compile.ps1, иначе гибрид-раундтрип поедет.
_FMT_MARKUP_RE = re.compile(r'</>|<\s*(?:link|b|i|u|s|color|colorStyle|bgColor|bgColorStyle|font|fontSize|fontStyle|img)(?:\s|>)', re.I)


def _has_real_markup(text):
    if text is None:
        return False
    vals = list(text.values()) if isinstance(text, dict) else [text]
    return any(_FMT_MARKUP_RE.search(str(v)) for v in vals)


def resolve_ml_formatted(val):
    # {text, formatted} = явный override; строка/мапа → авто-детект formatted
    if isinstance(val, dict) and 'text' in val:
        return val['text'], bool(val.get('formatted'))
    return val, _has_real_markup(val)


# ExtendedTooltip — это LabelDecoration: own-content (layout/оформление/флаги/hyperlink) ±текст.
# Признак структурированной формы: объект с любым НЕ-текстовым ключом ({text,formatted}/{ru,en} → текст).
COMPANION_STRUCT_KEYS = {
    'width', 'autoMaxWidth', 'maxWidth', 'height', 'autoMaxHeight', 'maxHeight', 'verticalAlign', 'titleHeight',
    'horizontalStretch', 'verticalStretch', 'horizontalAlign', 'groupHorizontalAlign', 'groupVerticalAlign',
    'visible', 'hidden', 'enabled', 'disabled', 'hyperlink', 'events', 'tooltip',
    'textColor', 'backColor', 'borderColor', 'font', 'border', 'цветтекста', 'цветфона', 'цветрамки', 'шрифт', 'рамка',
}


def emit_companion_title(lines, content, indent):
    text, fmt = resolve_ml_formatted(content)
    lines.append(f'{indent}<Title formatted="{"true" if fmt else "false"}">')
    emit_ml_items(lines, f'{indent}\t', text)
    lines.append(f'{indent}</Title>')


def emit_companion(lines, tag, name, indent, content=None):
    cid = new_id()
    has_content = content is not None and not (isinstance(content, str) and content == '')
    if not has_content:
        lines.append(f'{indent}<{tag} name="{name}" id="{cid}"/>')
        return
    inner = f'{indent}\t'
    # DI-Attr от собственного объекта компаньона (не от владельца) — зеркало ps1
    lines.append(f'{indent}<{tag} name="{name}" id="{cid}"{di_attr(content if isinstance(content, dict) else None)}>')
    if isinstance(content, dict) and any(k in content for k in COMPANION_STRUCT_KEYS):
        # own-content ПЕРЕД Title (в корпусе layout-first 582 vs 10).
        emit_common_flags(lines, content, inner)
        if content.get('hyperlink') is True:
            lines.append(f'{inner}<Hyperlink>true</Hyperlink>')
        emit_layout(lines, content, inner)
        emit_appearance(lines, content, inner, 'decoration')
        if 'text' in content:
            emit_companion_title(lines, content, inner)
        # ToolTip компаньона (подсказка самой расширенной подсказки) — после Title (порядок схемы LabelDecoration)
        if content.get('tooltip'):
            emit_mltext(lines, inner, 'ToolTip', content['tooltip'])
        # События компаньона (ExtendedTooltip = LabelDecoration: напр. URLProcessing у hyperlink-подсказки)
        emit_events(lines, content, name, inner, 'label')
    else:
        emit_companion_title(lines, content, inner)
    lines.append(f'{indent}</{tag}>')


def emit_companion_panel(lines, tag, name, indent, panel):
    # Companion-командная-панель (ContextMenu/AutoCommandBar) с контентом: { autofill?, horizontalAlign?, children?[] }
    # или массив = shorthand для { children }. Пусто/нет → self-closing.
    cid = new_id()
    autofill = None
    halign = None
    children = None
    if isinstance(panel, list):
        children = panel
    elif panel is not None:
        if panel.get('autofill') is not None:
            autofill = bool(panel.get('autofill'))
        if panel.get('horizontalAlign'):
            halign = str(panel.get('horizontalAlign'))
        children = panel.get('children')
    has_children = bool(children) and len(children) > 0
    # Платформа пишет <Autofill> только при false; true = дефолт (тег опускается).
    emit_af_false = (autofill is False)
    if not emit_af_false and not has_children and not halign:
        lines.append(f'{indent}<{tag} name="{name}" id="{cid}"/>')
        return
    lines.append(f'{indent}<{tag} name="{name}" id="{cid}"{di_attr(panel if isinstance(panel, dict) else None)}>')
    if halign:
        lines.append(f'{indent}\t<HorizontalAlign>{halign}</HorizontalAlign>')
    if emit_af_false:
        lines.append(f'{indent}\t<Autofill>false</Autofill>')
    if has_children:
        lines.append(f'{indent}\t<ChildItems>')
        for c in children:
            emit_element(lines, c, f'{indent}\t\t', in_cmd_bar=True)
        lines.append(f'{indent}\t</ChildItems>')
    lines.append(f'{indent}</{tag}>')


# Дополнения командной панели таблицы: тип DSL → XML-тег + AdditionSource.Type + суффикс имени.
ADDITION_TYPE_MAP = {
    'searchString':  {'tag': 'SearchStringAddition',  'type': 'SearchStringRepresentation', 'suffix': 'СтрокаПоиска'},
    'viewStatus':    {'tag': 'ViewStatusAddition',    'type': 'ViewStatusRepresentation',   'suffix': 'СостояниеПросмотра'},
    'searchControl': {'tag': 'SearchControlAddition', 'type': 'SearchControl',               'suffix': 'УправлениеПоиском'},
}
ADDITION_KEY_SYNONYMS = {
    'searchString':  ['SearchStringAddition', 'SearchStringRepresentation', 'строкаПоиска', 'отображениеСтрокиПоиска'],
    'viewStatus':    ['ViewStatusAddition', 'ViewStatusRepresentation', 'состояниеПросмотра'],
    'searchControl': ['SearchControlAddition', 'SearchControl', 'управлениеПоиском'],
}
# Имя текущей таблицы — дефолт source для кастомных дополнений в commandBar.
_current_table_name = {'name': None}


def get_hlocation(el):
    # HorizontalLocation: auto (дефолт, опускаем) / left / right; forgiving + рус.
    if not isinstance(el, dict):
        return None
    v = el.get('horizontalLocation')
    if not v:
        return None
    s = str(v).lower()
    if s in ('auto', 'авто'):
        return None
    if s in ('left', 'слева', 'лево'):
        return 'Left'
    if s in ('right', 'справа', 'право'):
        return 'Right'
    if s in ('center', 'центр', 'по центру'):
        return 'Center'
    return str(v)


def emit_addition_body(lines, props, source, src_type, add_name, indent):
    # Тело дополнения: AdditionSource + свойства (как у поля) + companions. props может быть None.
    inner = f'{indent}\t'
    lines.append(f'{inner}<AdditionSource>')
    lines.append(f'{inner}\t<Item>{source}</Item>')
    lines.append(f'{inner}\t<Type>{src_type}</Type>')
    lines.append(f'{inner}</AdditionSource>')
    if props:
        if props.get('title'):
            emit_mltext(lines, inner, 'Title', props['title'])
        emit_common_flags(lines, props, inner)
        if props.get('tooltip'):
            emit_mltext(lines, inner, 'ToolTip', props['tooltip'])
        if props.get('tooltipRepresentation'):
            lines.append(f'{inner}<ToolTipRepresentation>{props["tooltipRepresentation"]}</ToolTipRepresentation>')
        hl = get_hlocation(props)
        if hl:
            lines.append(f'{inner}<HorizontalLocation>{hl}</HorizontalLocation>')
        emit_layout(lines, props, inner)
        emit_appearance(lines, props, inner, 'field')
    emit_companion(lines, 'ContextMenu', f'{add_name}КонтекстноеМеню', inner)
    emit_companion(lines, 'ExtendedTooltip', f'{add_name}РасширеннаяПодсказка', inner)


def emit_addition(lines, el, name, eid, type_key, indent):
    # Кастомное дополнение (тип-элемент в commandBar): source дефолтит в текущую таблицу.
    m = ADDITION_TYPE_MAP[type_key]
    source = el.get('source') or _current_table_name['name'] or ''
    lines.append(f'{indent}<{m["tag"]} name="{name}" id="{eid}"{di_attr(el)}>')
    emit_addition_body(lines, el, source, m['type'], name, indent)
    lines.append(f'{indent}</{m["tag"]}>')


def emit_table_addition(lines, type_key, table_name, indent, override=None):
    # Стандартное табличное дополнение (авто-генерация). override — объект отклонений из карты additions.
    m = ADDITION_TYPE_MAP[type_key]
    add_name = f'{table_name}{m["suffix"]}'
    aid = new_id()
    lines.append(f'{indent}<{m["tag"]} name="{add_name}" id="{aid}">')
    emit_addition_body(lines, override, table_name, m['type'], add_name, indent)
    lines.append(f'{indent}</{m["tag"]}>')


def get_addition_override(additions, type_key):
    # Прочитать override-объект для типа из per-table карты additions (с синонимами).
    if not isinstance(additions, dict):
        return None
    for k in [type_key] + ADDITION_KEY_SYNONYMS[type_key]:
        if k in additions:
            return additions[k]
    return None


# Role-adjustable boolean (xr:Common + 0..N xr:Value name="Role.X").
# Единый механизм платформы: UserVisible (элементы), View/Edit (атрибуты), Use (команды/кнопки).
# Значение DSL: скаляр bool → только <xr:Common>; объект { common, roles:{ Имя: bool } } → +пер-ролевые исключения.
# Имя роли принимаем с/без префикса "Role." (forgiving); на выход всегда с префиксом.
def emit_xr_flag(lines, tag, val, indent):
    if val is None:
        return
    if isinstance(val, bool):
        lines.append(f"{indent}<{tag}>")
        lines.append(f"{indent}\t<xr:Common>{'true' if val else 'false'}</xr:Common>")
        lines.append(f"{indent}</{tag}>")
        return
    # объектная форма { common, roles }
    common = bool(val.get('common')) if val.get('common') is not None else False
    lines.append(f"{indent}<{tag}>")
    lines.append(f"{indent}\t<xr:Common>{'true' if common else 'false'}</xr:Common>")
    roles = val.get('roles')
    if roles:
        for rname, rval in roles.items():
            # Forgiving: имя без префикса, с "Role." или кириллическим "Роль." → нормализуем в "Role.".
            # Роль по GUID (заимствованная/расширение — name="<guid>" без префикса) эмитим как есть.
            rn = re.sub(r'^(Role|Роль)\.', '', rname)
            if not re.match(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}$', rn):
                rn = "Role." + rn
            lines.append(f"{indent}\t<xr:Value name=\"{rn}\">{'true' if rval else 'false'}</xr:Value>")
    lines.append(f"{indent}</{tag}>")


def emit_common_flags(lines, el, indent):
    if el.get('visible') is False or el.get('hidden') is True:
        lines.append(f"{indent}<Visible>false</Visible>")
    if el.get('userVisible') is not None:
        emit_xr_flag(lines, 'UserVisible', el.get('userVisible'), indent)
    if el.get('enabled') is False or el.get('disabled') is True:
        lines.append(f"{indent}<Enabled>false</Enabled>")
    if el.get('readOnly') is True:
        lines.append(f"{indent}<ReadOnly>true</ReadOnly>")


# Общие свойства элемента (любой тип, включая Button/cmdBar): default/skip/drag.
def emit_common_element_props(lines, el, indent):
    if el.get('defaultItem') is True:
        lines.append(f"{indent}<DefaultItem>true</DefaultItem>")
    if 'skipOnInput' in el and el['skipOnInput'] is not None:
        siv = 'true' if el['skipOnInput'] is True else 'false'
        lines.append(f"{indent}<SkipOnInput>{siv}</SkipOnInput>")
    # EnableStartDrag — фактическое значение (платформа эмитит и явный false, напр. SpreadSheet)
    if el.get('enableStartDrag') is not None:
        lines.append(f'{indent}<EnableStartDrag>{"true" if el["enableStartDrag"] else "false"}</EnableStartDrag>')
    if el.get('fileDragMode'):
        lines.append(f"{indent}<FileDragMode>{el['fileDragMode']}</FileDragMode>")
    # Cell-свойства поля в таблице (общие для Input/Label/Picture/CheckBox): захват «как есть»
    for key, tag in (('showInHeader', 'ShowInHeader'), ('showInFooter', 'ShowInFooter'), ('autoCellHeight', 'AutoCellHeight')):
        if el.get(key) is not None:
            lines.append(f'{indent}<{tag}>{"true" if el[key] else "false"}</{tag}>')
    # Динамический заголовок колонки-группы из данных (HeaderDataPath) — перед HeaderHorizontalAlign (порядок XSD)
    if el.get('headerDataPath'):
        lines.append(f"{indent}<HeaderDataPath>{esc_xml_text(str(el['headerDataPath']))}</HeaderDataPath>")
    if el.get('footerHorizontalAlign'):
        lines.append(f"{indent}<FooterHorizontalAlign>{el['footerHorizontalAlign']}</FooterHorizontalAlign>")
    if el.get('headerHorizontalAlign'):
        lines.append(f"{indent}<HeaderHorizontalAlign>{el['headerHorizontalAlign']}</HeaderHorizontalAlign>")
    # Формат заголовка колонки-группы (ML-текст) — после HeaderHorizontalAlign (порядок XSD)
    if el.get('headerFormat'):
        emit_mltext(lines, indent, 'HeaderFormat', el['headerFormat'])


def emit_picture_ref(lines, val, pic_tag, indent):
    """Картинка-ссылка с прозрачностью (HeaderPicture/FooterPicture/ValuesPicture/Page Picture).
    Платформа ВСЕГДА эмитит <xr:LoadTransparent> → пишем всегда (false по умолчанию).
    Значение: скаляр (Ref) ИЛИ объект {src, loadTransparent, transparentPixel}.
    src с префиксом "abs:" → встроенная картинка <xr:Abs>; иначе <xr:Ref>."""
    if not val:
        return
    tpx = None
    if isinstance(val, str):
        src, lt = val, False
    else:
        src = val.get('src')
        lt = val.get('loadTransparent') is True
        tpx = val.get('transparentPixel')
    if not src:
        return
    src_str = str(src)
    lines.append(f"{indent}<{pic_tag}>")
    if src_str.startswith('abs:'):
        lines.append(f"{indent}\t<xr:Abs>{esc_xml_text(src_str[4:])}</xr:Abs>")
    else:
        lines.append(f"{indent}\t<xr:Ref>{esc_xml_text(src_str)}</xr:Ref>")
    lines.append(f'{indent}\t<xr:LoadTransparent>{"true" if lt else "false"}</xr:LoadTransparent>')
    if tpx:
        lines.append(f'{indent}\t<xr:TransparentPixel x="{tpx.get("x")}" y="{tpx.get("y")}"/>')
    lines.append(f"{indent}</{pic_tag}>")


def emit_column_pics(lines, el, indent):
    """Картинки заголовка/подвала колонки поля — по схеме сразу после <EditMode>,
    перед тип-специфичными элементами и layout (порядок XDTO строгий именно здесь)."""
    emit_picture_ref(lines, el.get('headerPicture'), 'HeaderPicture', indent)
    emit_picture_ref(lines, el.get('footerPicture'), 'FooterPicture', indent)


def emit_command_picture(lines, pic, elem_lt, indent):
    """<Picture> кнопки/попапа/команды. Дефолт LoadTransparent=true, отклонение false
    (обратная конвенция относительно header/values-картинок). Прощающий ввод:
    принимает скаляр (Ref) ИЛИ объект {src, loadTransparent} — на случай если модель
    опишет картинку объектно по аналогии с headerPicture. elem_lt — legacy
    элемент-уровневый ключ loadTransparent (если в объекте флаг не задан)."""
    if not pic:
        return
    lt = None
    tpx = None
    if isinstance(pic, str):
        src = pic
    else:
        src = pic.get('src')
        if pic.get('loadTransparent') is not None:
            lt = bool(pic.get('loadTransparent'))
        tpx = pic.get('transparentPixel')
    if not src:
        return
    if lt is None and elem_lt is not None:
        lt = bool(elem_lt)
    src_str = str(src)
    lines.append(f'{indent}<Picture>')
    if src_str.startswith('abs:'):
        lines.append(f'{indent}\t<xr:Abs>{esc_xml_text(src_str[4:])}</xr:Abs>')
    else:
        lines.append(f'{indent}\t<xr:Ref>{esc_xml_text(src_str)}</xr:Ref>')
    lines.append(f'{indent}\t<xr:LoadTransparent>{"false" if lt is False else "true"}</xr:LoadTransparent>')
    if tpx:
        lines.append(f'{indent}\t<xr:TransparentPixel x="{tpx.get("x")}" y="{tpx.get("y")}"/>')
    lines.append(f'{indent}</Picture>')


# --- Оформление элемента: цвета / шрифты / граница (зеркало form-compile.ps1 Emit-Appearance) ---
# Прямые свойства элемента (<TextColor>/<Font>/<Border> + header/footer у полей). Ключи англ.
# camelCase 1:1 с тегами + приём рус. синонимов. Цвет — verbatim-строка (style:/web:/win:/#RRGGBB);
# шрифт — строка-ref/объект-атрибуты; граница — строка-ref/
# объект {width,style}. Порядок тегов — XSD (профиль по базовому типу).
APPEARANCE_SPEC = {
    'titleTextColor':  ('TitleTextColor', 'color'),
    'titleBackColor':  ('TitleBackColor', 'color'),
    'titleFont':       ('TitleFont', 'font'),
    'footerTextColor': ('FooterTextColor', 'color'),
    'footerBackColor': ('FooterBackColor', 'color'),
    'footerFont':      ('FooterFont', 'font'),
    'textColor':       ('TextColor', 'color'),
    'backColor':       ('BackColor', 'color'),
    'borderColor':     ('BorderColor', 'color'),
    'border':          ('Border', 'border'),
    'font':            ('Font', 'font'),
}
APPEARANCE_SYNONYMS = {
    'цветтекста': 'textColor', 'цветфона': 'backColor', 'цветрамки': 'borderColor',
    'цветтекстазаголовка': 'titleTextColor', 'цветфоназаголовка': 'titleBackColor', 'шрифтзаголовка': 'titleFont',
    'цветтекстаподвала': 'footerTextColor', 'цветфонаподвала': 'footerBackColor', 'шрифтподвала': 'footerFont',
    'шрифт': 'font', 'рамка': 'border',
}
# Синонимы ключей-свойств: русские имена свойств 1С (как в Конфигураторе) → канон. англ. ключ.
# Ключи нормализованы (lowercase, без пробелов); сопоставление в emit_element тоже. Англ. ключ
# работает всегда (доп. слой прощающего ввода). Видимость/Доступность НЕ включаем (hidden/disabled инвертирован).
PROP_SYNONYMS = {
    'пометка': 'checked',
    'кнопкавыбора': 'choiceButton', 'кнопкаочистки': 'clearButton', 'кнопкарегулирования': 'spinButton',
    'кнопкавыпадающегосписка': 'dropListButton', 'кнопкасписковоговыбора': 'choiceListButton',
    'кнопкаоткрытия': 'openButton', 'кнопкапоумолчанию': 'defaultButton',
    'быстрыйвыбор': 'quickChoice', 'формавыбора': 'choiceForm', 'историявыборапривводе': 'choiceHistoryOnInput',
    'выборгруппиэлементов': 'choiceFoldersAndItems', 'фиксациявтаблице': 'fixingInTable',
    'путькданнымподвала': 'footerDataPath', 'автоотметканезаполненного': 'markIncomplete',
    'многострочныйрежим': 'multiLine', 'режимпароля': 'passwordMode', 'переноспословам': 'wrap',
    'расположениезаголовка': 'titleLocation', 'пропускатьпривводе': 'skipOnInput',
    'заголовок': 'title', 'ширина': 'width', 'высота': 'height', 'подсказкаввода': 'inputHint',
}
APP_ORDER_FIELD =['titleTextColor', 'titleBackColor', 'titleFont', 'footerTextColor', 'footerBackColor', 'footerFont', 'textColor', 'backColor', 'borderColor', 'border', 'font']
APP_ORDER_DECORATION = ['textColor', 'font', 'backColor', 'borderColor', 'border']
APP_ORDER_BUTTON = ['textColor', 'backColor', 'borderColor', 'font']


def get_appearance_value(el, canonical):
    if not isinstance(el, dict):
        return None
    if canonical in el:
        return el[canonical]
    lowmap = {k.lower(): k for k in el.keys()}
    if canonical.lower() in lowmap:
        return el[lowmap[canonical.lower()]]
    for syn, canon in APPEARANCE_SYNONYMS.items():
        if canon == canonical and syn in lowmap:
            return el[lowmap[syn]]
    return None


def emit_font_tag(lines, tag, val, indent):
    if isinstance(val, str):
        lines.append(f'{indent}<{tag} ref="{esc_xml(val)}" kind="StyleItem"/>')
        return
    attrs = []
    for a in ('ref', 'faceName', 'height', 'bold', 'italic', 'underline', 'strikeout', 'kind', 'scale'):
        if a in val and val[a] is not None:
            v = val[a]
            if isinstance(v, bool):
                v = 'true' if v else 'false'
            attrs.append(f'{a}="{esc_xml(str(v))}"')
    lines.append(f'{indent}<{tag} {" ".join(attrs)}/>')


def emit_border_tag(lines, val, indent):
    if isinstance(val, str):
        lines.append(f'{indent}<Border ref="{esc_xml(val)}"/>')
        return
    if val.get('ref'):
        lines.append(f'{indent}<Border ref="{esc_xml(str(val["ref"]))}"/>')
        return
    width = val['width'] if val.get('width') is not None else 1
    style = str(val['style']) if 'style' in val else None
    lines.append(f'{indent}<Border width="{width}">')
    if style:
        lines.append(f'{indent}\t<v8ui:style xsi:type="v8ui:ControlBorderType">{esc_xml_text(style)}</v8ui:style>')
    lines.append(f'{indent}</Border>')


# ─────────────────────────────────────────────────────────────────────────────
# Planner design-time <Settings xsi:type="pl:Planner"> — зеркало Emit-PlannerSettings (ps1).
PLANNER_NS = 'http://v8.1c.ru/8.3/data/planner'
CHART_NS = 'http://v8.1c.ru/8.2/data/chart'


def _pl_get(o, k, default=None):
    if isinstance(o, dict) and o.get(k) is not None:
        return o[k]
    return default


def _pl_bool(v):
    if isinstance(v, bool):
        return 'true' if v else 'false'
    if str(v) == 'True':
        return 'true'
    if str(v) == 'False':
        return 'false'
    return str(v)


def emit_planner_color(lines, tag, o, key, ind):
    lines.append(f'{ind}<pl:{tag}>{esc_xml_text(str(_pl_get(o, key, "auto")))}</pl:{tag}>')


def emit_planner_text(lines, tag, v, ind):
    if v is None or str(v) == '':
        lines.append(f'{ind}<pl:{tag}/>')
    else:
        lines.append(f'{ind}<pl:{tag}>{esc_xml_text(str(v))}</pl:{tag}>')


_PLANNER_REF_RE = re.compile(
    r'^(Enum|Catalog|Document|ChartOfAccounts|ChartOfCalculationTypes|ChartOfCharacteristicTypes|ExchangePlan|BusinessProcess|Task)\.'
    r'|\.EnumValue\.|EmptyRef$'
    r'|^(Перечисление|Справочник|Документ|ПланСчетов|ПланВидовХарактеристик|ПланВидовРасчета|ПланОбмена|БизнесПроцесс|Задача)\.')


def test_planner_ref(v):
    return bool(_PLANNER_REF_RE.search(str(v)))


def emit_planner_value(lines, v, ind):
    if v is None or str(v) == '':
        lines.append(f'{ind}<pl:value xsi:nil="true"/>')
        return
    t = 'xr:DesignTimeRef' if test_planner_ref(v) else 'xs:string'
    lines.append(f'{ind}<pl:value xsi:type="{t}">{esc_xml_text(str(v))}</pl:value>')


def emit_planner_font(lines, o, ind):
    f = _pl_get(o, 'font')
    if f is None:
        lines.append(f'{ind}<pl:font kind="AutoFont"/>')
        return
    emit_font_tag(lines, 'pl:font', f, ind)


def emit_planner_border(lines, o, ind, key='border'):
    b = _pl_get(o, key)
    bw = _pl_get(b, 'width', 1) if b else 1
    bs = _pl_get(b, 'style', 'Single') if b else 'Single'
    lines.append(f'{ind}<pl:border width="{bw}">')
    lines.append(f'{ind}\t<v8ui:style xsi:type="v8ui:ControlBorderType">{esc_xml_text(str(bs))}</v8ui:style>')
    lines.append(f'{ind}</pl:border>')


def emit_planner_level(lines, lv, cns, ind):
    li = f'{ind}\t'
    lines.append(f'{ind}<level xmlns="{cns}">')
    lines.append(f'{li}<measure>{esc_xml_text(str(_pl_get(lv, "measure", "Hour")))}</measure>')
    lines.append(f'{li}<interval>{_pl_get(lv, "interval", 1)}</interval>')
    lines.append(f'{li}<show>{_pl_bool(_pl_get(lv, "show", True))}</show>')
    line = _pl_get(lv, 'line')
    lw = _pl_get(line, 'width', 1) if line else 1
    lg = _pl_get(line, 'gap', False) if line else False
    lst = _pl_get(line, 'style', 'Solid') if line else 'Solid'
    lines.append(f'{li}<line width="{lw}" gap="{_pl_bool(lg)}">')
    lines.append(f'{li}\t<v8ui:style xsi:type="v8ui:ChartLineType">{esc_xml_text(str(lst))}</v8ui:style>')
    lines.append(f'{li}</line>')
    lines.append(f'{li}<scaleColor>{esc_xml_text(str(_pl_get(lv, "scaleColor", "auto")))}</scaleColor>')
    lines.append(f'{li}<dayFormatRule>{esc_xml_text(str(_pl_get(lv, "dayFormatRule", "MonthDayWeekDay")))}</dayFormatRule>')
    fmt = _pl_get(lv, 'format')
    if fmt is None:
        fmt = {'#': 'DF="HH:mm"', 'ru': 'DF="HH:mm"'}
    lines.append(f'{li}<format>')
    emit_ml_items(lines, f'{li}\t', fmt)
    lines.append(f'{li}</format>')
    labels = _pl_get(lv, 'labels')
    ticks = _pl_get(labels, 'ticks', 0) if labels else 0
    lines.append(f'{li}<labels>')
    lines.append(f'{li}\t<ticks>{ticks}</ticks>')
    lines.append(f'{li}</labels>')
    lines.append(f'{li}<backColor>{esc_xml_text(str(_pl_get(lv, "backColor", "auto")))}</backColor>')
    lines.append(f'{li}<textColor>{esc_xml_text(str(_pl_get(lv, "textColor", "auto")))}</textColor>')
    lines.append(f'{li}<showPereodicalLabels>{_pl_bool(_pl_get(lv, "showPereodicalLabels", True))}</showPereodicalLabels>')
    lines.append(f'{ind}</level>')


def emit_planner_timescale(lines, ts, ind):
    cns = CHART_NS
    ci = f'{ind}\t'
    lines.append(f'{ind}<pl:timeScale>')
    placement = _pl_get(ts, 'placement', 'Left') if ts else 'Left'
    lines.append(f'{ci}<placement xmlns="{cns}">{esc_xml_text(str(placement))}</placement>')
    levels = _pl_get(ts, 'levels', []) if ts else []
    if not levels:
        levels = [None]
    for lv in levels:
        emit_planner_level(lines, lv, cns, ci)
    transp = _pl_get(ts, 'transparent', False) if ts else False
    lines.append(f'{ci}<transparent xmlns="{cns}">{_pl_bool(transp)}</transparent>')
    tbc = _pl_get(ts, 'backColor', 'auto') if ts else 'auto'
    ttc = _pl_get(ts, 'textColor', 'auto') if ts else 'auto'
    tcl = _pl_get(ts, 'currentLevel', 0) if ts else 0
    lines.append(f'{ci}<backColor xmlns="{cns}">{esc_xml_text(str(tbc))}</backColor>')
    lines.append(f'{ci}<textColor xmlns="{cns}">{esc_xml_text(str(ttc))}</textColor>')
    lines.append(f'{ci}<currentLevel xmlns="{cns}">{tcl}</currentLevel>')
    lines.append(f'{ind}</pl:timeScale>')


def emit_planner_item(lines, it, ind):
    lines.append(f'{ind}<pl:item>')
    ii = f'{ind}\t'
    emit_planner_value(lines, _pl_get(it, 'value'), ii)
    emit_planner_text(lines, 'text', _pl_get(it, 'text', ''), ii)
    emit_planner_text(lines, 'tooltip', _pl_get(it, 'tooltip', ''), ii)
    lines.append(f'{ii}<pl:begin>{_pl_get(it, "begin", "0001-01-01T00:00:00")}</pl:begin>')
    lines.append(f'{ii}<pl:end>{_pl_get(it, "end", "0001-01-01T00:00:00")}</pl:end>')
    emit_planner_color(lines, 'borderColor', it, 'borderColor', ii)
    emit_planner_color(lines, 'backColor', it, 'backColor', ii)
    emit_planner_color(lines, 'textColor', it, 'textColor', ii)
    emit_planner_font(lines, it, ii)
    lines.append(f'{ii}<pl:dimensionValues/>')
    lines.append(f'{ii}<pl:replacementDate>{_pl_get(it, "replacementDate", "0001-01-01T00:00:00")}</pl:replacementDate>')
    lines.append(f'{ii}<pl:deleted>{_pl_bool(_pl_get(it, "deleted", False))}</pl:deleted>')
    iid = _pl_get(it, 'id')
    if iid is None:
        import uuid
        iid = str(uuid.uuid4())
    lines.append(f'{ii}<pl:id>{iid}</pl:id>')
    lines.append(f'{ii}<pl:textFormatted>{_pl_bool(_pl_get(it, "textFormatted", False))}</pl:textFormatted>')
    emit_planner_border(lines, it, ii, 'border')
    lines.append(f'{ii}<pl:editMode>{esc_xml_text(str(_pl_get(it, "editMode", "EnableEdit")))}</pl:editMode>')
    lines.append(f'{ind}</pl:item>')


def emit_planner_dim_element(lines, el, ind):
    lines.append(f'{ind}<pl:item>')
    ii = f'{ind}\t'
    emit_planner_value(lines, _pl_get(el, 'value'), ii)
    emit_planner_text(lines, 'text', _pl_get(el, 'text', ''), ii)
    emit_planner_color(lines, 'borderColor', el, 'borderColor', ii)
    emit_planner_color(lines, 'backColor', el, 'backColor', ii)
    emit_planner_color(lines, 'textColor', el, 'textColor', ii)
    emit_planner_font(lines, el, ii)
    for sub in _pl_get(el, 'elements', []):
        emit_planner_dim_element(lines, sub, ii)
    lines.append(f'{ii}<pl:showOnlySubordinatesAreas>{_pl_bool(_pl_get(el, "showOnlySubordinatesAreas", True))}</pl:showOnlySubordinatesAreas>')
    lines.append(f'{ii}<pl:textFormatted>{_pl_bool(_pl_get(el, "textFormatted", False))}</pl:textFormatted>')
    lines.append(f'{ind}</pl:item>')


def emit_planner_dimension(lines, d, ind):
    lines.append(f'{ind}<pl:dimension>')
    di = f'{ind}\t'
    emit_planner_value(lines, _pl_get(d, 'value'), di)
    emit_planner_text(lines, 'text', _pl_get(d, 'text', ''), di)
    emit_planner_color(lines, 'borderColor', d, 'borderColor', di)
    emit_planner_color(lines, 'backColor', d, 'backColor', di)
    emit_planner_color(lines, 'textColor', d, 'textColor', di)
    emit_planner_font(lines, d, di)
    for el in _pl_get(d, 'elements', []):
        emit_planner_dim_element(lines, el, di)
    lines.append(f'{di}<pl:textFormatted>{_pl_bool(_pl_get(d, "textFormatted", False))}</pl:textFormatted>')
    lines.append(f'{ind}</pl:dimension>')


def emit_planner_settings(lines, pl, ind):
    lines.append(f'{ind}<Settings xmlns:pl="{PLANNER_NS}" xsi:type="pl:Planner">')
    si = f'{ind}\t'
    for it in _pl_get(pl, 'items', []):
        emit_planner_item(lines, it, si)
    for d in _pl_get(pl, 'dimensions', []):
        emit_planner_dimension(lines, d, si)
    emit_planner_color(lines, 'borderColor', pl, 'borderColor', si)
    emit_planner_color(lines, 'backColor', pl, 'backColor', si)
    emit_planner_color(lines, 'textColor', pl, 'textColor', si)
    emit_planner_color(lines, 'lineColor', pl, 'lineColor', si)
    emit_planner_font(lines, pl, si)
    lines.append(f'{si}<pl:beginOfRepresentationPeriod>{_pl_get(pl, "beginOfRepresentationPeriod", "0001-01-01T00:00:00")}</pl:beginOfRepresentationPeriod>')
    lines.append(f'{si}<pl:endOfRepresentationPeriod>{_pl_get(pl, "endOfRepresentationPeriod", "0001-01-01T00:00:00")}</pl:endOfRepresentationPeriod>')
    lines.append(f'{si}<pl:alignElementsOfTimeScale>{_pl_bool(_pl_get(pl, "alignElementsOfTimeScale", True))}</pl:alignElementsOfTimeScale>')
    lines.append(f'{si}<pl:displayTimeScaleWrapHeaders>{_pl_bool(_pl_get(pl, "displayTimeScaleWrapHeaders", True))}</pl:displayTimeScaleWrapHeaders>')
    lines.append(f'{si}<pl:displayWrapHeaders>{_pl_bool(_pl_get(pl, "displayWrapHeaders", True))}</pl:displayWrapHeaders>')
    wfmt = _pl_get(pl, 'timeScaleWrapHeadersFormat')
    if wfmt is None:
        wfmt = {'#': 'DLF="DD"', 'ru': 'DLF="DD"'}
    emit_mltext(lines, si, 'pl:timeScaleWrapHeadersFormat', wfmt)
    lines.append(f'{si}<pl:periodicVariantUnit>{esc_xml_text(str(_pl_get(pl, "periodicVariantUnit", "Day")))}</pl:periodicVariantUnit>')
    lines.append(f'{si}<pl:periodicVariantRepetition>{_pl_get(pl, "periodicVariantRepetition", 1)}</pl:periodicVariantRepetition>')
    lines.append(f'{si}<pl:timeScaleWrapBeginIndent>{_pl_get(pl, "timeScaleWrapBeginIndent", 0)}</pl:timeScaleWrapBeginIndent>')
    lines.append(f'{si}<pl:timeScaleWrapEndIndent>{_pl_get(pl, "timeScaleWrapEndIndent", 0)}</pl:timeScaleWrapEndIndent>')
    emit_planner_timescale(lines, _pl_get(pl, 'timeScale'), si)
    period = _pl_get(pl, 'period')
    if period:
        lines.append(f'{si}<pl:period>')
        lines.append(f'{si}\t<pl:begin>{_pl_get(period, "begin", "0001-01-01T00:00:00")}</pl:begin>')
        lines.append(f'{si}\t<pl:end>{_pl_get(period, "end", "0001-01-01T00:00:00")}</pl:end>')
        lines.append(f'{si}</pl:period>')
    lines.append(f'{si}<pl:displayCurrentDate>{_pl_bool(_pl_get(pl, "displayCurrentDate", True))}</pl:displayCurrentDate>')
    lines.append(f'{si}<pl:itemsTimeRepresentation>{esc_xml_text(str(_pl_get(pl, "itemsTimeRepresentation", "BeginTime")))}</pl:itemsTimeRepresentation>')
    lines.append(f'{si}<pl:itemsBehaviorWhenSpaceInsufficient>{esc_xml_text(str(_pl_get(pl, "itemsBehaviorWhenSpaceInsufficient", "CollapseItems")))}</pl:itemsBehaviorWhenSpaceInsufficient>')
    lines.append(f'{si}<pl:autoMinColumnWidth>{_pl_bool(_pl_get(pl, "autoMinColumnWidth", True))}</pl:autoMinColumnWidth>')
    lines.append(f'{si}<pl:autoMinRowHeight>{_pl_bool(_pl_get(pl, "autoMinRowHeight", True))}</pl:autoMinRowHeight>')
    lines.append(f'{si}<pl:minColumnWidth>{_pl_get(pl, "minColumnWidth", 0)}</pl:minColumnWidth>')
    lines.append(f'{si}<pl:minRowHeight>{_pl_get(pl, "minRowHeight", 0)}</pl:minRowHeight>')
    lines.append(f'{si}<pl:fixDimensionsHeader>{esc_xml_text(str(_pl_get(pl, "fixDimensionsHeader", "auto")))}</pl:fixDimensionsHeader>')
    lines.append(f'{si}<pl:fixTimeScaleHeader>{esc_xml_text(str(_pl_get(pl, "fixTimeScaleHeader", "auto")))}</pl:fixTimeScaleHeader>')
    emit_planner_border(lines, pl, si, 'border')
    lines.append(f'{si}<pl:newItemsTextType>{esc_xml_text(str(_pl_get(pl, "newItemsTextType", "String")))}</pl:newItemsTextType>')
    lines.append(f'{ind}</Settings>')


# ─────────────────────────────────────────────────────────────────────────────
# Chart design-time <Settings xsi:type="d4p1:Chart"> — генерик-эмиттер (зеркало
# Build-ChartNode декомпилятора + Emit-ChartNode ps1).
CHART_ML_FIELDS = {'title', 'lbFormat', 'lbpFormat', 'vsFormat', 'dtFormat', 'dataSourceDescription', 'labelFormat', 'text'}
CHART_ATTR_FIELDS = {'gaugeQualityBands'}
CHART_FONT_KEYS = ('ref', 'faceName', 'height', 'bold', 'italic', 'underline', 'strikeout', 'kind', 'scale')


def emit_chart_node(lines, name, val, ind):
    if name in CHART_ML_FIELDS:
        if val is None or str(val) == '':
            lines.append(f'{ind}<d4p1:{name}/>')
            return
        lines.append(f'{ind}<d4p1:{name}>')
        emit_ml_items(lines, f'{ind}\t', val)
        lines.append(f'{ind}</d4p1:{name}>')
        return
    if isinstance(val, list):
        for e in val:
            emit_chart_node(lines, name, e, ind)
        return
    if isinstance(val, dict):
        keys = list(val.keys())
        if name in CHART_ATTR_FIELDS:
            attrs = ' '.join(f'{k}="{esc_xml(_pl_bool(val[k]) if isinstance(val[k], bool) else str(val[k]))}"' for k in keys)
            lines.append(f'{ind}<d4p1:{name} {attrs}/>')
            return
        if 'gap' in val:
            lines.append(f'{ind}<d4p1:{name} width="{val.get("width")}" gap="{_pl_bool(val.get("gap"))}">')
            lines.append(f'{ind}\t<v8ui:style xsi:type="v8ui:ChartLineType">{esc_xml_text(str(val.get("style")))}</v8ui:style>')
            lines.append(f'{ind}</d4p1:{name}>')
            return
        if 'style' in val and 'width' in val:
            lines.append(f'{ind}<d4p1:{name} width="{val.get("width")}">')
            lines.append(f'{ind}\t<v8ui:style xsi:type="v8ui:ControlBorderType">{esc_xml_text(str(val.get("style")))}</v8ui:style>')
            lines.append(f'{ind}</d4p1:{name}>')
            return
        if any(fk in val for fk in CHART_FONT_KEYS):
            attrs = ' '.join(f'{fk}="{esc_xml(_pl_bool(val[fk]) if isinstance(val[fk], bool) else str(val[fk]))}"' for fk in CHART_FONT_KEYS if fk in val)
            lines.append(f'{ind}<d4p1:{name} {attrs}/>')
            return
        if not keys:
            lines.append(f'{ind}<d4p1:{name}/>')
            return
        lines.append(f'{ind}<d4p1:{name}>')
        for k in keys:
            emit_chart_node(lines, k, val[k], f'{ind}\t')
        lines.append(f'{ind}</d4p1:{name}>')
        return
    if val is None or str(val) == '':
        lines.append(f'{ind}<d4p1:{name}/>')
        return
    if isinstance(val, bool):
        lines.append(f'{ind}<d4p1:{name}>{_pl_bool(val)}</d4p1:{name}>')
        return
    lines.append(f'{ind}<d4p1:{name}>{esc_xml_text(str(val))}</d4p1:{name}>')


def emit_chart_settings(lines, chart, ind, ctype='d4p1:Chart'):
    lines.append(f'{ind}<Settings xmlns:d4p1="{CHART_NS}" xsi:type="{ctype}">')
    for k in list(chart.keys()):
        emit_chart_node(lines, k, chart[k], f'{ind}\t')
    lines.append(f'{ind}</Settings>')


def emit_appearance(lines, el, indent, profile='field'):
    if not isinstance(el, dict):
        return
    order = {'decoration': APP_ORDER_DECORATION, 'button': APP_ORDER_BUTTON}.get(profile, APP_ORDER_FIELD)
    for key in order:
        val = get_appearance_value(el, key)
        if val is None or (isinstance(val, str) and val == ''):
            continue
        tag, kind = APPEARANCE_SPEC[key]
        if kind == 'color':
            lines.append(f'{indent}<{tag}>{esc_xml_text(str(val))}</{tag}>')
        elif kind == 'font':
            emit_font_tag(lines, tag, val, indent)
        else:
            emit_border_tag(lines, val, indent)


# Простые скаляры элемента (pass-through, зеркало $script:genericScalars). kind bool/value.
GENERIC_SCALARS = [
    ('VerticalAlign', 'verticalAlign', 'value'),
    ('ThroughAlign', 'throughAlign', 'value'),
    ('EnableContentChange', 'enableContentChange', 'bool'),
    ('PictureSize', 'pictureSize', 'value'),
    ('TitleHeight', 'titleHeight', 'value'),
    ('ChildItemsWidth', 'childItemsWidth', 'value'),
    ('ShowLeftMargin', 'showLeftMargin', 'bool'),
    ('CellHyperlink', 'cellHyperlink', 'bool'),
    ('ViewMode', 'viewMode', 'value'),
    ('VerticalScrollBar', 'verticalScrollBar', 'value'),
    ('RowInputMode', 'rowInputMode', 'value'),
    ('Mask', 'mask', 'value'),
    ('CreateButton', 'createButton', 'bool'),
    ('FixingInTable', 'fixingInTable', 'value'),
    ('VerticalSpacing', 'verticalSpacing', 'value'),
    # Spec-fields (document/gauge) - type-specific enum/bool scalars pass-through
    ('HorizontalScrollBar', 'horizontalScrollBar', 'value'),
    ('ViewScalingMode', 'viewScalingMode', 'value'),
    ('Output', 'output', 'value'),
    ('SelectionShowMode', 'selectionShowMode', 'value'),
    ('PointerType', 'pointerType', 'value'),
    ('DrawingSelectionShowMode', 'drawingSelectionShowMode', 'value'),
    ('WarningOnEditRepresentation', 'warningOnEditRepresentation', 'value'),
    ('MarkingAppearance', 'markingAppearance', 'value'),
    ('Protection', 'protection', 'bool'),
    ('Edit', 'edit', 'bool'),
    ('ShowGrid', 'showGrid', 'bool'),
    ('ShowGroups', 'showGroups', 'bool'),
    ('ShowHeaders', 'showHeaders', 'bool'),
    ('ShowRowAndColumnNames', 'showRowAndColumnNames', 'bool'),
    ('ShowCellNames', 'showCellNames', 'bool'),
    ('ShowPercent', 'showPercent', 'bool'),
    # Report-form контекст: интервал группы / представление кнопки в контекстном меню / детальное представление настройки таблицы
    ('HorizontalSpacing', 'horizontalSpacing', 'value'),
    ('RepresentationInContextMenu', 'representationInContextMenu', 'value'),
    ('SettingsNamedItemDetailedRepresentation', 'settingsNamedItemDetailedRepresentation', 'bool'),
    # Хвост: высота элемента списка (radio) / ширина выпадающего списка (input)
    ('ItemHeight', 'itemHeight', 'value'),
    ('DropListWidth', 'dropListWidth', 'value'),
    # Хвост CI-форм: динамический заголовок (Page/Group) / расширенное ред. (input) / высота таблицы по строкам
    ('TitleDataPath', 'titleDataPath', 'value'),
    ('ExtendedEdit', 'extendedEdit', 'bool'),
    ('MaxRowsCount', 'maxRowsCount', 'value'),
    ('AutoMaxRowsCount', 'autoMaxRowsCount', 'bool'),
    ('HeightControlVariant', 'heightControlVariant', 'value'),
    ('EditTextUpdate', 'editTextUpdate', 'value'),
    # Корпусный хвост: свёртка группы / форма попапа / авто-добавление / выделение отрицательных /
    # нач. позиция списка / высота списка выбора / три состояния / прокрутка страницы при сжатии
    ('ControlRepresentation', 'controlRepresentation', 'value'),
    ('ShapeRepresentation', 'shapeRepresentation', 'value'),
    ('AutoAddIncomplete', 'autoAddIncomplete', 'bool'),
    ('MarkNegatives', 'markNegatives', 'bool'),
    ('InitialListView', 'initialListView', 'value'),
    ('ChoiceListHeight', 'choiceListHeight', 'value'),
    ('ThreeState', 'threeState', 'bool'),
    ('ScrollOnCompress', 'scrollOnCompress', 'bool'),
    # Сочетание клавиш — общее свойство (команда — отдельный путь)
    ('Shortcut', 'shortcut', 'value'),
    # Батч простых скаляров (input/radio/group/picDecoration/button; Table-специфичные — отдельно)
    ('IncompleteChoiceMode', 'incompleteChoiceMode', 'value'),
    ('EqualColumnsWidth', 'equalColumnsWidth', 'bool'),
    ('ChildrenAlign', 'childrenAlign', 'value'),
    ('ImageScale', 'imageScale', 'value'),
    ('Zoomable', 'zoomable', 'bool'),
    ('Shape', 'shape', 'value'),
    ('PictureLocation', 'pictureLocation', 'value'),
    # Равная ширина элементов (check/radio) / высота заголовка пункта (radio)
    ('EqualItemsWidth', 'equalItemsWidth', 'bool'),
    ('ItemTitleHeight', 'itemTitleHeight', 'value'),
    # Спец-режим ввода текста (input, моб.: Email/PhoneNumber/...) — листовой enum-скаляр
    ('SpecialTextInputMode', 'specialTextInputMode', 'value'),
    # Ширина пункта (radio/check) / выбор нескольких значений из выпадающего (input)
    ('ItemWidth', 'itemWidth', 'value'),
    ('ShowCheckBoxesInDropList', 'showCheckBoxesInDropList', 'bool'),
    ('MultipleValueDataPath', 'multipleValueDataPath', 'value'),
    ('MultipleValuePresentDataPath', 'multipleValuePresentDataPath', 'value'),
    # Режим авто-показа кнопок открытия/очистки (input, enum)
    ('AutoShowOpenButtonMode', 'autoShowOpenButtonMode', 'value'),
    ('AutoShowClearButtonMode', 'autoShowClearButtonMode', 'value'),
    # Оформление/картинка множественного выбора (input, редко; цвета — текст-контент)
    ('MultipleValuesTextColor', 'multipleValuesTextColor', 'value'),
    ('MultipleValuesBackColor', 'multipleValuesBackColor', 'value'),
    ('MultipleValuePictureShape', 'multipleValuePictureShape', 'value'),
    ('MultipleValuePictureDataPath', 'multipleValuePictureDataPath', 'value'),
    # Хвост листовых скаляров (по 1): автокоррекция / уникальность команды / пустое множ.значение / гориз.сжатие
    ('AutoCorrectionOnTextInput', 'autoCorrectionOnTextInput', 'value'),
    ('SpellCheckingOnTextInput', 'spellCheckingOnTextInput', 'value'),
    ('CommandUniqueness', 'commandUniqueness', 'bool'),
    ('AllowInputEmptyMultipleValues', 'allowInputEmptyMultipleValues', 'bool'),
    ('BehaviorOnHorizontalCompression', 'behaviorOnHorizontalCompression', 'value'),
]


def emit_generic_scalars(lines, el, indent):
    for tag, key, kind in GENERIC_SCALARS:
        if key not in el or el[key] is None:
            continue
        if kind == 'bool':
            lines.append(f'{indent}<{tag}>{"true" if el[key] else "false"}</{tag}>')
        else:
            v = str(el[key])
            if v == '':
                continue
            lines.append(f'{indent}<{tag}>{esc_xml_text(v)}</{tag}>')


def emit_layout(lines, el, indent, skip_height=False, multi_line_default=False):
    # Общие layout-свойства — применимы ко всем элементам. Порядок согласован
    # с историческим выводом input/label, чтобы не сдвигать существующие снапшоты.
    # skip_height: подавить <Height> (зарезервирован; Table теперь эмитит <Height> generic-ом + свой <HeightInTableRows>).
    # multi_line_default: input без явного autoMaxWidth при multiLine → AutoMaxWidth=false.
    # CommandSet (отключённые команды редактора) — общее свойство поля; в схеме рано (после TitleLocation).
    if el.get('excludedCommands') and len(el['excludedCommands']) > 0:
        lines.append(f'{indent}<CommandSet>')
        for cmd in el['excludedCommands']:
            lines.append(f'{indent}\t<ExcludedCommand>{cmd}</ExcludedCommand>')
        lines.append(f'{indent}</CommandSet>')
    emit_common_element_props(lines, el, indent)
    if 'autoMaxWidth' in el:
        if el.get('autoMaxWidth') is False:
            lines.append(f"{indent}<AutoMaxWidth>false</AutoMaxWidth>")
    elif multi_line_default:
        lines.append(f"{indent}<AutoMaxWidth>false</AutoMaxWidth>")
    if el.get('maxWidth') is not None:
        lines.append(f"{indent}<MaxWidth>{el['maxWidth']}</MaxWidth>")
    if el.get('autoMaxHeight') is False:
        lines.append(f"{indent}<AutoMaxHeight>false</AutoMaxHeight>")
    if el.get('maxHeight') is not None:
        lines.append(f"{indent}<MaxHeight>{el['maxHeight']}</MaxHeight>")
    if el.get('width'):
        lines.append(f"{indent}<Width>{el['width']}</Width>")
    if not skip_height and el.get('height'):
        lines.append(f"{indent}<Height>{el['height']}</Height>")
    if el.get('horizontalStretch') is not None:
        lines.append(f'{indent}<HorizontalStretch>{"true" if el["horizontalStretch"] else "false"}</HorizontalStretch>')
    if el.get('verticalStretch') is not None:
        lines.append(f'{indent}<VerticalStretch>{"true" if el["verticalStretch"] else "false"}</VerticalStretch>')
    if el.get('groupHorizontalAlign'):
        lines.append(f"{indent}<GroupHorizontalAlign>{el['groupHorizontalAlign']}</GroupHorizontalAlign>")
    if el.get('groupVerticalAlign'):
        lines.append(f"{indent}<GroupVerticalAlign>{el['groupVerticalAlign']}</GroupVerticalAlign>")
    if el.get('horizontalAlign'):
        lines.append(f"{indent}<HorizontalAlign>{el['horizontalAlign']}</HorizontalAlign>")
    emit_generic_scalars(lines, el, indent)


def title_from_name(name):
    """СуммаДокумента → 'Сумма документа'. НДСВключен → 'НДС включен'."""
    if not name:
        return ''
    s = re.sub(r'([А-ЯA-Z])([А-ЯA-Z][а-яa-z])', r'\1 \2', name)
    s = re.sub(r'([а-яa-z0-9])([А-ЯA-Z])', r'\1 \2', s)
    parts = s.split(' ')
    if not parts:
        return s
    out = [parts[0]]
    for p in parts[1:]:
        out.append(p if (len(p) > 1 and p.isupper()) else p.lower())
    return ' '.join(out)


def emit_title(lines, el, name, indent, auto=False):
    # Нет ключа title → авто-вывод из имени (помощь модели).
    # Явный title "" (или None) → подавить. Явный непустой → как есть.
    if 'title' in el:
        if el.get('title'):
            emit_mltext(lines, indent, 'Title', el['title'])
    elif auto and name:
        emit_mltext(lines, indent, 'Title', title_from_name(name))
    # ToolTip элемента (всплывающая подсказка) — по схеме сразу после Title.
    if el.get('tooltip'):
        emit_mltext(lines, indent, 'ToolTip', el['tooltip'])
    # ToolTipRepresentation — режим показа подсказки (None/Button/ShowBottom/…), после ToolTip.
    if el.get('tooltipRepresentation'):
        lines.append(f'{indent}<ToolTipRepresentation>{el["tooltipRepresentation"]}</ToolTipRepresentation>')


_TITLE_LOC_MAP = {'none': 'None', 'left': 'Left', 'right': 'Right', 'top': 'Top', 'bottom': 'Bottom', 'auto': 'Auto'}


def map_title_loc(v):
    return _TITLE_LOC_MAP.get(str(v).lower(), str(v))


def emit_title_location(lines, el, indent, smart_default):
    # Нет ключа → умный дефолт (Right/None), эмитится. "" → подавить (дефолт платформы).
    # Значение → эмитить с маппингом регистра.
    if 'titleLocation' in el:
        if el.get('titleLocation'):
            lines.append(f"{indent}<TitleLocation>{map_title_loc(el['titleLocation'])}</TitleLocation>")
    elif smart_default:
        lines.append(f"{indent}<TitleLocation>{smart_default}</TitleLocation>")


# --- Type emitter ---

V8_TYPES = {
    "ValueTable": "v8:ValueTable",
    "ValueTree": "v8:ValueTree",
    "ValueList": "v8:ValueListType",
    "TypeDescription": "v8:TypeDescription",
    "Universal": "v8:Universal",
    "FixedArray": "v8:FixedArray",
    "FixedStructure": "v8:FixedStructure",
}

UI_TYPES = {
    "FormattedString": "v8ui:FormattedString",
    "Picture": "v8ui:Picture",
    "Color": "v8ui:Color",
    "Font": "v8ui:Font",
}

DCS_MAP = {
    "DataCompositionSettings": "dcsset:DataCompositionSettings",
    "DataCompositionSchema": "dcssch:DataCompositionSchema",
    "DataCompositionComparisonType": "dcscor:DataCompositionComparisonType",
}

CFG_REF_PATTERN = re.compile(
    r'^(CatalogRef|CatalogObject|DocumentRef|DocumentObject|EnumRef|'
    r'ChartOfAccountsRef|ChartOfAccountsObject|ChartOfCharacteristicTypesRef|ChartOfCharacteristicTypesObject|'
    r'ChartOfCalculationTypesRef|ChartOfCalculationTypesObject|'
    r'ExchangePlanRef|ExchangePlanObject|BusinessProcessRef|BusinessProcessObject|TaskRef|TaskObject|'
    r'InformationRegisterRecordSet|InformationRegisterRecordManager|'
    r'AccumulationRegisterRecordSet|AccountingRegisterRecordSet|'
    r'ConstantsSet|DataProcessorObject|ReportObject)\.'
)

KNOWN_INVALID_TYPES = {
    'FormDataStructure': 'Runtime type. Use object type without cfg: prefix (e.g. CatalogObject.Контрагенты, DocumentObject.Приход)',
    'FormDataCollection': 'Runtime type. Use ValueTable',
    'FormDataTree': 'Runtime type. Use ValueTree',
    'FormDataTreeItem': 'Runtime type, not valid in XML',
    'FormDataCollectionItem': 'Runtime type, not valid in XML',
    'FormGroup': 'UI element type, not a data type',
    'FormField': 'UI element type, not a data type',
    'FormButton': 'UI element type, not a data type',
    'FormDecoration': 'UI element type, not a data type',
    'FormTable': 'UI element type, not a data type',
}


_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",
    "характеристика": "Characteristic",
    "любаяссылка": "AnyRef",
    "любаяссылкаиб": "AnyIBRef",
    # Платформенные v8-типы (forgiving: англ. без префикса + рус.) → каноничный с префиксом v8:
    "standardperiod": "v8:StandardPeriod",
    "стандартныйпериод": "v8:StandardPeriod",
    "standardbeginningdate": "v8:StandardBeginningDate",
    "стандартнаядатаначала": "v8:StandardBeginningDate",
    "uuid": "v8:UUID",
    "уникальныйидентификатор": "v8:UUID",
    "списокзначений": "ValueList",
}


# Алиас на локальный словарь: тело 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_single_type(lines, type_str, indent):
    type_str = resolve_type_str(type_str)
    # TypeId — тип, заданный глобальным стабильным GUID (<v8:TypeId>, не <v8:Type>). Платформа так
    # сериализует типы, чьё имя в этом контексте недоступно (определяемые/характеристики). GUID
    # глобально стабилен → эмитим verbatim (как роль-по-GUID). Маркер декомпилятора: 'typeid:GUID'.
    m = re.match(r'^typeid:([0-9a-fA-F-]{36})$', type_str)
    if m:
        lines.append(f'{indent}<v8:TypeId>{m.group(1)}</v8:TypeId>')
        return
    # boolean
    if type_str == 'boolean':
        lines.append(f'{indent}<v8:Type>xs:boolean</v8:Type>')
        return

    # string or string(N) or string(N,fixed) (AllowedLength: Variable дефолт / Fixed)
    m = re.match(r'^string(\((\d+)(\s*,\s*(fixed|variable))?\))?$', type_str, re.IGNORECASE)
    if m:
        length = m.group(2) if m.group(2) else '0'
        al = 'Fixed' if (m.group(4) and m.group(4).lower() == 'fixed') else 'Variable'
        lines.append(f'{indent}<v8:Type>xs:string</v8:Type>')
        lines.append(f'{indent}<v8:StringQualifiers>')
        lines.append(f'{indent}\t<v8:Length>{length}</v8:Length>')
        lines.append(f'{indent}\t<v8:AllowedLength>{al}</v8:AllowedLength>')
        lines.append(f'{indent}</v8:StringQualifiers>')
        return

    # decimal(D,F) or decimal(D,F,nonneg)
    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'
        lines.append(f'{indent}<v8:Type>xs:decimal</v8:Type>')
        lines.append(f'{indent}<v8:NumberQualifiers>')
        lines.append(f'{indent}\t<v8:Digits>{digits}</v8:Digits>')
        lines.append(f'{indent}\t<v8:FractionDigits>{fraction}</v8:FractionDigits>')
        lines.append(f'{indent}\t<v8:AllowedSign>{sign}</v8:AllowedSign>')
        lines.append(f'{indent}</v8:NumberQualifiers>')
        return

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

    # V8 types
    if type_str in V8_TYPES:
        lines.append(f'{indent}<v8:Type>{V8_TYPES[type_str]}</v8:Type>')
        return

    # UI types
    if type_str in UI_TYPES:
        lines.append(f'{indent}<v8:Type>{UI_TYPES[type_str]}</v8:Type>')
        return

    # DCS types
    if type_str.startswith('DataComposition'):
        if type_str in DCS_MAP:
            lines.append(f'{indent}<v8:Type>{DCS_MAP[type_str]}</v8:Type>')
            return

    # Голые конфигурационные типы (cfg: без .Имя): дин-список, набор констант, общий объект отчёта.
    # Корпус (acc+erp 8.3.24): DynamicList 5205, ConstantsSet 103, ReportObject 10.
    if type_str in ('DynamicList', 'ConstantsSet', 'ReportObject'):
        lines.append(f'{indent}<v8:Type>cfg:{type_str}</v8:Type>')
        return

    # TypeSet (набор типов) → <v8:TypeSet>: определяемый тип / характеристика (именованные)
    # + «любая ссылка вида» (голый ref-вид без .Имя). Развязка с обычным типом — по наличию точки.
    if re.match(r'^(DefinedType|Characteristic)\.', type_str):
        lines.append(f'{indent}<v8:TypeSet>cfg:{type_str}</v8:TypeSet>')
        return
    if re.match(r'^(AnyRef|AnyIBRef|CatalogRef|DocumentRef|EnumRef|ExchangePlanRef|TaskRef|BusinessProcessRef|ChartOfAccountsRef|ChartOfCharacteristicTypesRef|ChartOfCalculationTypesRef)$', type_str):
        lines.append(f'{indent}<v8:TypeSet>cfg:{type_str}</v8:TypeSet>')
        return

    # cfg: references
    if CFG_REF_PATTERN.match(type_str):
        lines.append(f'{indent}<v8:Type>cfg:{type_str}</v8:Type>')
        return

    # Спец-типы платформы с собственным namespace (объявляется ЛОКАЛЬНО на <v8:Type>).
    # Префикс d5p1 неоднозначен (5 разных URI), поэтому маппинг по полному значению типа.
    # К таким типам привязаны спец-поля: mxl→SpreadSheetDocumentField, fd→FormattedDocumentField,
    # d5p1:TextDocument→TextDocumentField, pdfdoc→PDF, pl→Planner, chart/geo/graphscheme/data-analysis.
    special_type_ns = {
        "mxl:SpreadsheetDocument": "http://v8.1c.ru/8.2/data/spreadsheet",
        "fd:FormattedDocument": "http://v8.1c.ru/8.2/data/formatted-document",
        "d5p1:TextDocument": "http://v8.1c.ru/8.1/data/txtedt",
        "d5p1:Chart": "http://v8.1c.ru/8.2/data/chart",
        "d5p1:GanttChart": "http://v8.1c.ru/8.2/data/chart",
        "d5p1:Dendrogram": "http://v8.1c.ru/8.2/data/chart",
        "d5p1:FlowchartContextType": "http://v8.1c.ru/8.2/data/graphscheme",
        "d5p1:DataAnalysisTimeIntervalUnitType": "http://v8.1c.ru/8.2/data/data-analysis",
        "d5p1:GeographicalSchema": "http://v8.1c.ru/8.2/data/geo",
        "pdfdoc:PDFDocument": "http://v8.1c.ru/8.3/data/pdf",
        "pl:Planner": "http://v8.1c.ru/8.3/data/planner",
    }
    if type_str in special_type_ns:
        pref = type_str.split(':', 1)[0]
        lines.append(f'{indent}<v8:Type xmlns:{pref}="{special_type_ns[type_str]}">{type_str}</v8:Type>')
        return

    # Fallback with validation
    if type_str in KNOWN_INVALID_TYPES:
        raise ValueError(f"Invalid form attribute type '{type_str}': {KNOWN_INVALID_TYPES[type_str]}")
    # Платформенный тип с префиксом (v8:/v8ui:/xs:/dcs*:) — verbatim (напр. v8:UUID, v8:StandardPeriod).
    if re.match(r'^(v8|v8ui|xs|ent|style|sys|web|win|dcs\w*):', type_str):
        lines.append(f'{indent}<v8:Type>{type_str}</v8:Type>')
    elif '.' in type_str:
        lines.append(f'{indent}<v8:Type>cfg:{type_str}</v8:Type>')
    else:
        print(f"WARNING: Unrecognized bare type '{type_str}' — will be emitted without namespace prefix", file=sys.stderr)
        lines.append(f'{indent}<v8:Type>{type_str}</v8:Type>')


def emit_type(lines, type_str, indent, tag="Type", tag_attrs=""):
    # tag/tag_attrs — обёртка (по умолчанию <Type>); для valueType ValueList вызывается с
    # tag="Settings", tag_attrs=' xsi:type="v8:TypeDescription"'.
    if not type_str:
        lines.append(f'{indent}<{tag}{tag_attrs}/>')
        return

    type_string = str(type_str)
    parts = [p.strip() for p in re.split(r'[|+]', type_string)]

    lines.append(f'{indent}<{tag}{tag_attrs}>')
    for part in parts:
        emit_single_type(lines, part, f'{indent}\t')
    lines.append(f'{indent}</{tag}>')


# --- Element emitters ---

def emit_element(lines, el, indent, in_cmd_bar=False):
    # Companion-панели (объект/массив-значение) → commandBar/contextMenu, до тип-синонимов.
    normalize_panel_synonyms(el)

    # Silent synonyms: model often writes XML name or Russian (ПолеПереключателя/RadioButtonField → radio).
    # commandBar/autoCommandBar/КоманднаяПанель → тип-элемент ТОЛЬКО при строковом значении (имя).
    for src, dst in ELEMENT_TYPE_SYNONYMS.items():
        if src in el and dst not in el:
            if src in STR_ONLY_TYPE_SYNONYMS and not isinstance(el[src], str):
                continue
            el[dst] = el.pop(src)

    # Синонимы ключей-свойств (русские имена 1С → канон. англ.). Case/space-insensitive.
    # Канон побеждает: если задан и русский, и англ. ключ — англ. остаётся, русский отбрасываем.
    for p_name in list(el.keys()):
        norm = p_name.replace(' ', '').lower()
        canon = PROP_SYNONYMS.get(norm)
        if canon and p_name != canon:
            val = el.pop(p_name)
            if canon not in el:
                el[canon] = val

    type_key = None
    for key in TYPE_KEYS:
        if el.get(key) is not None:
            type_key = key
            break

    if not type_key:
        print("WARNING: Unknown element type, skipping", file=sys.stderr)
        return

    # Validate known keys (внутренние маркеры на _ пропускаем). Оформление (цвета/шрифты/граница)
    # проверяем против самих структур appearance — канонические ключи + forgiving-синонимы, чтобы
    # allowlist не дрейфовал при добавлении новых.
    for p_name in el.keys():
        if p_name.startswith('_'):
            continue
        if p_name not in KNOWN_KEYS and p_name not in APPEARANCE_SPEC and p_name not in APPEARANCE_SYNONYMS:
            print(f"WARNING: Element '{el.get(type_key, '')}': unknown key '{p_name}' -- ignored. Check SKILL.md for valid keys.", file=sys.stderr)

    name = get_element_name(el, type_key)
    _ensure_unique(name, _seen_element_names, 'element')
    eid = new_id()

    emitters = {
        'group': emit_group,
        'columnGroup': emit_column_group,
        'buttonGroup': emit_button_group,
        'input': emit_input,
        'check': emit_check,
        'radio': emit_radio_button_field,
        '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,
        'popup': emit_popup,
        'searchString':  lambda lines, el, name, eid, indent: emit_addition(lines, el, name, eid, 'searchString', indent),
        'viewStatus':    lambda lines, el, name, eid, indent: emit_addition(lines, el, name, eid, 'viewStatus', indent),
        'searchControl': lambda lines, el, name, eid, indent: emit_addition(lines, el, name, eid, 'searchControl', indent),
        'spreadsheet':   lambda lines, el, name, eid, indent: emit_simple_field(lines, el, name, eid, indent, 'SpreadSheetDocumentField', 'spreadsheet'),
        'html':          lambda lines, el, name, eid, indent: emit_simple_field(lines, el, name, eid, indent, 'HTMLDocumentField', 'html'),
        'textDoc':       lambda lines, el, name, eid, indent: emit_simple_field(lines, el, name, eid, indent, 'TextDocumentField', 'textDoc'),
        'formattedDoc':  lambda lines, el, name, eid, indent: emit_simple_field(lines, el, name, eid, indent, 'FormattedDocumentField', 'formattedDoc'),
        'progressBar':   lambda lines, el, name, eid, indent: emit_simple_field(lines, el, name, eid, indent, 'ProgressBarField', 'progressBar'),
        'trackBar':      lambda lines, el, name, eid, indent: emit_simple_field(lines, el, name, eid, indent, 'TrackBarField', 'trackBar'),
        'chart':           lambda lines, el, name, eid, indent: emit_simple_field(lines, el, name, eid, indent, 'ChartField', 'chart'),
        'graphicalSchema': lambda lines, el, name, eid, indent: emit_simple_field(lines, el, name, eid, indent, 'GraphicalSchemaField', 'graphicalSchema'),
        'planner':         lambda lines, el, name, eid, indent: emit_simple_field(lines, el, name, eid, indent, 'PlannerField', 'planner'),
        'periodField':     lambda lines, el, name, eid, indent: emit_simple_field(lines, el, name, eid, indent, 'PeriodField', 'periodField'),
        'dendrogram':      lambda lines, el, name, eid, indent: emit_simple_field(lines, el, name, eid, indent, 'DendrogramField', 'dendrogram'),
        'ganttChart':      emit_gantt_chart,
    }

    emitter = emitters.get(type_key)
    if emitter:
        if type_key == 'button':
            emitter(lines, el, name, eid, indent, in_cmd_bar=in_cmd_bar)
        else:
            emitter(lines, el, name, eid, indent)


def _warn_unrecognized(key, raw, valid, owner):
    # drop-on-miss enum: значение не распознано → тег не эмитится. Громко, чтобы автор увидел потерю.
    print(f"[WARN] Unrecognized {key} '{raw}' on '{owner}'. Valid values: {', '.join(valid)}. Value ignored.")


def emit_group(lines, el, name, eid, indent):
    lines.append(f'{indent}<UsualGroup name="{name}" id="{eid}"{di_attr(el)}>')
    inner = f'{indent}\t'

    emit_title(lines, el, name, inner)

    # Group orientation
    # Group orientation (направление). Legacy: group:'collapsible' = Vertical + behavior collapsible.
    group_val = str(el.get('group', '')).lower()
    orientation_map = {
        'horizontal': 'Horizontal',
        'vertical': 'Vertical',
        'alwayshorizontal': 'AlwaysHorizontal',
        'alwaysvertical': 'AlwaysVertical',
        'horizontalifpossible': 'HorizontalIfPossible',
        'collapsible': 'Vertical',
    }
    orientation = orientation_map.get(group_val)
    if orientation:
        lines.append(f'{inner}<Group>{orientation}</Group>')
    elif group_val:
        _warn_unrecognized('group orientation', el.get('group'), ('vertical', 'horizontalIfPossible', 'alwaysHorizontal'), name)

    # Behavior: ключ behavior (usual/collapsible/popup) → <Behavior>; отсутствие = Авто (не эмитим).
    behavior_val = str(el['behavior']).lower() if el.get('behavior') else ('collapsible' if group_val == 'collapsible' else None)
    bmap = {'usual': 'Usual', 'collapsible': 'Collapsible', 'popup': 'PopUp'}
    if behavior_val and behavior_val in bmap:
        lines.append(f'{inner}<Behavior>{bmap[behavior_val]}</Behavior>')
    elif el.get('behavior') and behavior_val not in bmap:
        _warn_unrecognized('behavior', el.get('behavior'), ('collapsible', 'popup'), name)
    # Collapsed — у Collapsible и PopUp (не привязано к одному behavior)
    if el.get('collapsed') is True:
        lines.append(f'{inner}<Collapsed>true</Collapsed>')

    # Representation
    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']))
        lines.append(f'{inner}<Representation>{repr_val}</Representation>')

    # Использование текущей строки группы (после Representation, порядок XSD)
    if el.get('currentRowUse'):
        lines.append(f'{inner}<CurrentRowUse>{el["currentRowUse"]}</CurrentRowUse>')

    # ShowTitle
    if el.get('showTitle') is not None:
        lines.append(f'{inner}<ShowTitle>{"true" if el["showTitle"] else "false"}</ShowTitle>')
    # Заголовок свёрнутого представления (collapsible/popup) — мультиязычный текст
    if el.get('collapsedTitle'):
        emit_mltext(lines, inner, 'CollapsedRepresentationTitle', el['collapsedTitle'])

    # United
    if el.get('united') is False:
        lines.append(f'{inner}<United>false</United>')

    # Формат значения пути к данным заголовка (<Format>; парный к titleDataPath группы)
    if el.get('format'):
        emit_mltext(lines, inner, 'Format', el['format'])
    if el.get('editFormat'):
        emit_mltext(lines, inner, 'EditFormat', el['editFormat'])

    emit_common_flags(lines, el, inner)
    emit_layout(lines, el, inner)

    # Оформление (цвета/шрифты/граница) — перед компаньоном
    emit_appearance(lines, el, inner, 'field')

    # Companion: ExtendedTooltip
    emit_companion(lines, 'ExtendedTooltip', f'{name}\u0420\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u043d\u0430\u044f\u041f\u043e\u0434\u0441\u043a\u0430\u0437\u043a\u0430', inner, el.get('extendedTooltip'))

    # Children
    if el.get('children') and len(el['children']) > 0:
        lines.append(f'{inner}<ChildItems>')
        for child in el['children']:
            emit_element(lines, child, f'{inner}\t')
        lines.append(f'{inner}</ChildItems>')

    lines.append(f'{indent}</UsualGroup>')


def emit_column_group(lines, el, name, eid, indent):
    lines.append(f'{indent}<ColumnGroup name="{name}" id="{eid}"{di_attr(el)}>')
    inner = f'{indent}\t'

    emit_title(lines, el, name, inner)

    group_val = str(el.get('columnGroup', '')).lower()
    orientation_map = {
        'horizontal': 'Horizontal',
        'vertical': 'Vertical',
        'incell': 'InCell',
    }
    orientation = orientation_map.get(group_val)
    if orientation:
        lines.append(f'{inner}<Group>{orientation}</Group>')
    elif group_val:
        _warn_unrecognized('columnGroup orientation', el.get('columnGroup'), ('vertical', 'horizontal', 'inCell'), name)

    if el.get('showTitle') is not None:
        lines.append(f'{inner}<ShowTitle>{"true" if el["showTitle"] else "false"}</ShowTitle>')
    # showInHeader эмитится общим emit_common_element_props (через emit_layout)

    emit_common_flags(lines, el, inner)
    emit_layout(lines, el, inner)

    # Картинка заголовка колонки-группы (после ShowInHeader/Layout, перед оформлением — порядок XSD)
    emit_column_pics(lines, el, inner)

    # Оформление (цвета/шрифты/граница) — перед компаньоном
    emit_appearance(lines, el, inner, 'field')

    emit_companion(lines, 'ExtendedTooltip', f'{name}РасширеннаяПодсказка', inner, el.get('extendedTooltip'))

    if el.get('children') and len(el['children']) > 0:
        lines.append(f'{inner}<ChildItems>')
        for child in el['children']:
            emit_element(lines, child, f'{inner}\t')
        lines.append(f'{inner}</ChildItems>')

    lines.append(f'{indent}</ColumnGroup>')


def emit_input(lines, el, name, eid, indent):
    lines.append(f'{indent}<InputField name="{name}" id="{eid}"{di_attr(el)}>')
    inner = f'{indent}\t'

    if el.get('path'):
        lines.append(f'{inner}<DataPath>{el["path"]}</DataPath>')

    emit_title(lines, el, name, inner, auto=not el.get('path'))
    emit_common_flags(lines, 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']))
        lines.append(f'{inner}<TitleLocation>{loc}</TitleLocation>')

    if el.get('multiLine') is not None:
        lines.append(f'{inner}<MultiLine>{"true" if el["multiLine"] else "false"}</MultiLine>')
    if el.get('passwordMode') is not None:
        lines.append(f'{inner}<PasswordMode>{"true" if el["passwordMode"] else "false"}</PasswordMode>')
    # ChoiceButton — захват «как есть» (платформа эмитит явное значение; ref-поля выводят сама,
    # декомпилятор фиксирует факт. значение). Нет ключа → не эмитим (не додумываем по событию).
    if el.get('choiceButton') is not None:
        lines.append(f'{inner}<ChoiceButton>{"true" if el["choiceButton"] else "false"}</ChoiceButton>')
    # Кнопки поля ввода — захват «как есть» (платформа эмитит явное значение, в т.ч. false)
    if el.get('clearButton') is not None:
        lines.append(f'{inner}<ClearButton>{"true" if el["clearButton"] else "false"}</ClearButton>')
    if el.get('spinButton') is not None:
        lines.append(f'{inner}<SpinButton>{"true" if el["spinButton"] else "false"}</SpinButton>')
    if el.get('dropListButton') is not None:
        lines.append(f'{inner}<DropListButton>{"true" if el["dropListButton"] else "false"}</DropListButton>')
    if el.get('choiceListButton') is not None:
        lines.append(f'{inner}<ChoiceListButton>{"true" if el["choiceListButton"] else "false"}</ChoiceListButton>')
    if el.get('markIncomplete') is not None:
        lines.append(f'{inner}<AutoMarkIncomplete>{"true" if el["markIncomplete"] else "false"}</AutoMarkIncomplete>')
    if el.get('editMode'):
        lines.append(f'{inner}<EditMode>{el["editMode"]}</EditMode>')
    emit_column_pics(lines, el, inner)
    if el.get('textEdit') is False:
        lines.append(f'{inner}<TextEdit>false</TextEdit>')
    # InputField-специфичные скаляры (захват «как есть»: платформа эмитит явное не-дефолтное значение)
    for key, tag in (('wrap', 'Wrap'), ('openButton', 'OpenButton'), ('listChoiceMode', 'ListChoiceMode'),
                     ('extendedEditMultipleValues', 'ExtendedEditMultipleValues'), ('chooseType', 'ChooseType'),
                     ('quickChoice', 'QuickChoice'), ('autoChoiceIncomplete', 'AutoChoiceIncomplete')):
        if el.get(key) is not None:
            lines.append(f'{inner}<{tag}>{"true" if el[key] else "false"}</{tag}>')
    # Ограничение доступных типов (поле на составном типе): домен типов + явный набор.
    # availableTypes — формат типа реквизита (§type); emit_type сам разбирает мультитип "a | b".
    if el.get('typeDomainEnabled') is not None:
        lines.append(f'{inner}<TypeDomainEnabled>{"true" if el["typeDomainEnabled"] else "false"}</TypeDomainEnabled>')
    if el.get('availableTypes'):
        emit_type(lines, el['availableTypes'], inner, tag='AvailableTypes')
    # InputField-специфичные value-скаляры
    for key, tag in (('choiceForm', 'ChoiceForm'), ('choiceHistoryOnInput', 'ChoiceHistoryOnInput'),
                     ('choiceFoldersAndItems', 'ChoiceFoldersAndItems'), ('footerDataPath', 'FooterDataPath')):
        if el.get(key):
            lines.append(f'{inner}<{tag}>{esc_xml_text(str(el[key]))}</{tag}>')
    # MinValue/MaxValue — типизированное. JSON-число → xs:decimal, строка → xs:string (тип сохранён декомпилятором).
    for key, tag in (('minValue', 'MinValue'), ('maxValue', 'MaxValue')):
        if el.get(key) is not None:
            mvt = 'xs:string' if isinstance(el[key], str) else 'xs:decimal'
            lines.append(f'{inner}<{tag} xsi:type="{mvt}">{esc_xml_text(str(el[key]))}</{tag}>')
    if el.get('choiceButtonRepresentation'):
        lines.append(f'{inner}<ChoiceButtonRepresentation>{el["choiceButtonRepresentation"]}</ChoiceButtonRepresentation>')
    emit_picture_ref(lines, el.get('choiceButtonPicture'), 'ChoiceButtonPicture', inner)
    emit_layout(lines, el, inner, multi_line_default=(el.get('multiLine') is True))

    if el.get('inputHint'):
        emit_mltext(lines, inner, 'InputHint', el['inputHint'])
    if el.get('warningOnEdit') is not None:
        emit_mltext(lines, inner, 'WarningOnEdit', el['warningOnEdit'])
    if el.get('footerText') is not None:
        emit_mltext(lines, inner, 'FooterText', el['footerText'])

    # Формат / формат редактирования (LocalStringType — строка или {ru,en})
    if el.get('format'):
        emit_mltext(lines, inner, 'Format', el['format'])
    if el.get('editFormat'):
        emit_mltext(lines, inner, 'EditFormat', el['editFormat'])

    emit_choice_list(lines, el, inner)

    # Связи по типу / связи параметров выбора / параметры выбора
    emit_type_link(lines, el, inner)
    emit_choice_parameter_links(lines, el, inner)
    emit_choice_parameters(lines, el, inner)

    # Оформление (цвета/шрифты/граница) — перед компаньонами
    emit_appearance(lines, el, inner, 'field')

    # Companions
    emit_companion_panel(lines, 'ContextMenu', f'{name}\u041a\u043e\u043d\u0442\u0435\u043a\u0441\u0442\u043d\u043e\u0435\u041c\u0435\u043d\u044e', inner, el.get('contextMenu'))
    emit_companion(lines, 'ExtendedTooltip', f'{name}\u0420\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u043d\u0430\u044f\u041f\u043e\u0434\u0441\u043a\u0430\u0437\u043a\u0430', inner, el.get('extendedTooltip'))

    emit_events(lines, el, name, inner, 'input')

    lines.append(f'{indent}</InputField>')


def emit_check(lines, el, name, eid, indent):
    lines.append(f'{indent}<CheckBoxField name="{name}" id="{eid}"{di_attr(el)}>')
    inner = f'{indent}\t'

    if el.get('path'):
        lines.append(f'{inner}<DataPath>{el["path"]}</DataPath>')

    emit_title(lines, el, name, inner, auto=not el.get('path'))
    emit_common_flags(lines, el, inner)

    if el.get('editMode'):
        lines.append(f'{inner}<EditMode>{el["editMode"]}</EditMode>')
    emit_column_pics(lines, el, inner)
    # CheckBoxType: нет ключа → умный дефолт Auto; "" → подавить; значение → маппинг
    _cbt_map = {'auto': 'Auto', 'checkbox': 'CheckBox', 'switcher': 'Switcher', 'tumbler': 'Tumbler'}
    if 'checkBoxType' in el:
        if el.get('checkBoxType'):
            lines.append(f'{inner}<CheckBoxType>{_cbt_map.get(str(el["checkBoxType"]).lower(), el["checkBoxType"])}</CheckBoxType>')
    else:
        lines.append(f'{inner}<CheckBoxType>Auto</CheckBoxType>')

    emit_title_location(lines, el, inner, 'Right')

    emit_layout(lines, el, inner)

    if el.get('warningOnEdit') is not None:
        emit_mltext(lines, inner, 'WarningOnEdit', el['warningOnEdit'])
    # FooterDataPath / FooterText — общие cell-свойства колонки (как у input/labelField)
    if el.get('footerDataPath'):
        lines.append(f'{inner}<FooterDataPath>{esc_xml_text(str(el["footerDataPath"]))}</FooterDataPath>')
    if el.get('footerText') is not None:
        emit_mltext(lines, inner, 'FooterText', el['footerText'])

    # Формат / формат редактирования (LocalStringType — строка или {ru,en})
    if el.get('format'):
        emit_mltext(lines, inner, 'Format', el['format'])
    if el.get('editFormat'):
        emit_mltext(lines, inner, 'EditFormat', el['editFormat'])

    # Оформление (цвета/шрифты/граница) — перед компаньонами
    emit_appearance(lines, el, inner, 'field')

    # Companions
    emit_companion_panel(lines, 'ContextMenu', f'{name}\u041a\u043e\u043d\u0442\u0435\u043a\u0441\u0442\u043d\u043e\u0435\u041c\u0435\u043d\u044e', inner, el.get('contextMenu'))
    emit_companion(lines, 'ExtendedTooltip', f'{name}\u0420\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u043d\u0430\u044f\u041f\u043e\u0434\u0441\u043a\u0430\u0437\u043a\u0430', inner, el.get('extendedTooltip'))

    emit_events(lines, el, name, inner, 'check')

    lines.append(f'{indent}</CheckBoxField>')


def emit_radio_button_field(lines, el, name, eid, indent):
    lines.append(f'{indent}<RadioButtonField name="{name}" id="{eid}"{di_attr(el)}>')
    inner = f'{indent}\t'

    if el.get('path'):
        lines.append(f'{inner}<DataPath>{el["path"]}</DataPath>')

    emit_title(lines, el, name, inner, auto=not el.get('path'))
    emit_common_flags(lines, el, inner)

    if el.get('editMode'):
        lines.append(f'{inner}<EditMode>{el["editMode"]}</EditMode>')
    emit_title_location(lines, el, inner, 'None')

    rbt = normalize_radio_button_type(el.get('radioButtonType'))
    lines.append(f'{inner}<RadioButtonType>{rbt}</RadioButtonType>')

    if el.get('columnsCount') is not None:
        lines.append(f'{inner}<ColumnsCount>{el["columnsCount"]}</ColumnsCount>')

    emit_choice_list(lines, el, inner)

    emit_layout(lines, el, inner)

    if el.get('warningOnEdit') is not None:
        emit_mltext(lines, inner, 'WarningOnEdit', el['warningOnEdit'])

    # Оформление (цвета/шрифты/граница) — перед компаньонами
    emit_appearance(lines, el, inner, 'field')

    emit_companion_panel(lines, 'ContextMenu', f'{name}КонтекстноеМеню', inner, el.get('contextMenu'))
    emit_companion(lines, 'ExtendedTooltip', f'{name}РасширеннаяПодсказка', inner, el.get('extendedTooltip'))

    emit_events(lines, el, name, inner, 'radio')

    lines.append(f'{indent}</RadioButtonField>')


# Заголовок декорации (Label/Picture): formatted-aware <Title> через единую ML-text форму
# (reuse resolve_ml_formatted, как у extendedTooltip). Sibling-ключ formatted — back-compat override.
def emit_decoration_title(lines, el, name, indent, auto=False):
    has_key = 'title' in el
    title_val = el['title'] if has_key else (title_from_name(name) if (auto and name) else None)
    if title_val:
        text, fmt = resolve_ml_formatted(title_val)
        if 'formatted' in el:
            fmt = bool(el['formatted'])
        lines.append(f'{indent}<Title formatted="{"true" if fmt else "false"}">')
        emit_ml_items(lines, f'{indent}\t', text)
        lines.append(f'{indent}</Title>')
    if el.get('tooltip'):
        emit_mltext(lines, indent, 'ToolTip', el['tooltip'])
    if el.get('tooltipRepresentation'):
        lines.append(f'{indent}<ToolTipRepresentation>{el["tooltipRepresentation"]}</ToolTipRepresentation>')


def emit_label(lines, el, name, eid, indent):
    lines.append(f'{indent}<LabelDecoration name="{name}" id="{eid}"{di_attr(el)}>')
    inner = f'{indent}\t'

    # Порядок как у платформы: own-content (флаги/hyperlink/layout/оформление) ПЕРЕД Title
    # (корпус layout-first 16970 vs 44 — заодно убирает шум атрибуции харнесса на многострочном Title).
    emit_common_flags(lines, el, inner)
    if el.get('hyperlink') is True:
        lines.append(f'{inner}<Hyperlink>true</Hyperlink>')
    emit_layout(lines, el, inner)
    emit_appearance(lines, el, inner, 'decoration')

    emit_decoration_title(lines, el, name, inner, auto=True)

    # Companions
    emit_companion_panel(lines, 'ContextMenu', f'{name}\u041a\u043e\u043d\u0442\u0435\u043a\u0441\u0442\u043d\u043e\u0435\u041c\u0435\u043d\u044e', inner, el.get('contextMenu'))
    emit_companion(lines, 'ExtendedTooltip', f'{name}\u0420\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u043d\u0430\u044f\u041f\u043e\u0434\u0441\u043a\u0430\u0437\u043a\u0430', inner, el.get('extendedTooltip'))

    emit_events(lines, el, name, inner, 'label')

    lines.append(f'{indent}</LabelDecoration>')


def emit_label_field(lines, el, name, eid, indent):
    lines.append(f'{indent}<LabelField name="{name}" id="{eid}"{di_attr(el)}>')
    inner = f'{indent}\t'

    if el.get('path'):
        lines.append(f'{inner}<DataPath>{el["path"]}</DataPath>')

    emit_title(lines, el, name, inner, auto=not el.get('path'))
    emit_common_flags(lines, el, inner)

    if el.get('titleLocation'):
        lines.append(f'{inner}<TitleLocation>{map_title_loc(el["titleLocation"])}</TitleLocation>')
    if el.get('editMode'):
        lines.append(f'{inner}<EditMode>{el["editMode"]}</EditMode>')
    # FooterDataPath — путь данных подвала колонки (общий cell-prop, как у input); после EditMode
    if el.get('footerDataPath'):
        lines.append(f'{inner}<FooterDataPath>{esc_xml_text(str(el["footerDataPath"]))}</FooterDataPath>')
    # PasswordMode на LabelField — платформа эмитит явный false (редко); факт. значение
    if el.get('passwordMode') is not None:
        lines.append(f'{inner}<PasswordMode>{"true" if el["passwordMode"] else "false"}</PasswordMode>')
    emit_column_pics(lines, el, inner)
    # ВНИМАНИЕ: у LabelField платформенный тег <Hiperlink> (опечатка 1С), не <Hyperlink>.
    if el.get('hyperlink') is True:
        lines.append(f'{inner}<Hiperlink>true</Hiperlink>')
    emit_layout(lines, el, inner)

    if el.get('warningOnEdit') is not None:
        emit_mltext(lines, inner, 'WarningOnEdit', el['warningOnEdit'])
    if el.get('footerText') is not None:
        emit_mltext(lines, inner, 'FooterText', el['footerText'])

    # Формат / формат редактирования (LocalStringType — строка или {ru,en})
    if el.get('format'):
        emit_mltext(lines, inner, 'Format', el['format'])
    if el.get('editFormat'):
        emit_mltext(lines, inner, 'EditFormat', el['editFormat'])

    # Оформление (цвета/шрифты/граница + header/footer) — перед компаньонами
    emit_appearance(lines, el, inner, 'field')

    # Companions
    emit_companion_panel(lines, 'ContextMenu', f'{name}\u041a\u043e\u043d\u0442\u0435\u043a\u0441\u0442\u043d\u043e\u0435\u041c\u0435\u043d\u044e', inner, el.get('contextMenu'))
    emit_companion(lines, 'ExtendedTooltip', f'{name}\u0420\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u043d\u0430\u044f\u041f\u043e\u0434\u0441\u043a\u0430\u0437\u043a\u0430', inner, el.get('extendedTooltip'))

    emit_events(lines, el, name, inner, 'labelField')

    lines.append(f'{indent}</LabelField>')


# Блок свойств таблицы, привязанной к динамическому списку (Group A defaults + B/C).
def emit_dynlist_table_block(lines, el, indent):
    # (useAlternationRowColor — общее свойство таблицы, эмитится в emit_table)
    # Group A (гарант. блок): дефолт + override
    ar = 'true' if el.get('autoRefresh') is True else 'false'
    lines.append(f'{indent}<AutoRefresh>{ar}</AutoRefresh>')
    arp = el['autoRefreshPeriod'] if el.get('autoRefreshPeriod') is not None else 60
    lines.append(f'{indent}<AutoRefreshPeriod>{arp}</AutoRefreshPeriod>')
    lines.append(f'{indent}<Period>')
    lines.append(f'{indent}\t<v8:variant xsi:type="v8:StandardPeriodVariant">Custom</v8:variant>')
    lines.append(f'{indent}\t<v8:startDate>0001-01-01T00:00:00</v8:startDate>')
    lines.append(f'{indent}\t<v8:endDate>0001-01-01T00:00:00</v8:endDate>')
    lines.append(f'{indent}</Period>')
    cfi = el.get('choiceFoldersAndItems') or 'Items'
    lines.append(f'{indent}<ChoiceFoldersAndItems>{cfi}</ChoiceFoldersAndItems>')
    rcr = 'true' if el.get('restoreCurrentRow') is True else 'false'
    lines.append(f'{indent}<RestoreCurrentRow>{rcr}</RestoreCurrentRow>')
    lines.append(f'{indent}<TopLevelParent xsi:nil="true"/>')
    sr = 'false' if el.get('showRoot') is False else 'true'
    lines.append(f'{indent}<ShowRoot>{sr}</ShowRoot>')
    arc = 'true' if el.get('allowRootChoice') is True else 'false'
    lines.append(f'{indent}<AllowRootChoice>{arc}</AllowRootChoice>')
    uodc = el.get('updateOnDataChange') or 'Auto'
    lines.append(f'{indent}<UpdateOnDataChange>{uodc}</UpdateOnDataChange>')
    if el.get('userSettingsGroup'):
        lines.append(f'{indent}<UserSettingsGroup>{el["userSettingsGroup"]}</UserSettingsGroup>')
    agcru = 'false' if el.get('allowGettingCurrentRowURL') is False else 'true'
    lines.append(f'{indent}<AllowGettingCurrentRowURL>{agcru}</AllowGettingCurrentRowURL>')


def emit_table(lines, el, name, eid, indent):
    _current_table_name['name'] = name   # дефолт source для кастомных дополнений в commandBar
    lines.append(f'{indent}<Table name="{name}" id="{eid}"{di_attr(el)}>')
    inner = f'{indent}\t'

    if el.get('path'):
        lines.append(f'{inner}<DataPath>{el["path"]}</DataPath>')

    emit_title(lines, el, name, inner, auto=not el.get('path'))
    emit_common_flags(lines, el, inner)

    if el.get('representation'):
        lines.append(f'{inner}<Representation>{el["representation"]}</Representation>')
    if el.get('titleLocation'):
        lines.append(f'{inner}<TitleLocation>{map_title_loc(el["titleLocation"])}</TitleLocation>')
    # ChangeRowSet/Order — явное значение (в т.ч. false: платформа пишет его на ValueTable)
    if 'changeRowSet' in el and el['changeRowSet'] is not None:
        lines.append(f'{inner}<ChangeRowSet>{"true" if el["changeRowSet"] is True else "false"}</ChangeRowSet>')
    if 'changeRowOrder' in el and el['changeRowOrder'] is not None:
        lines.append(f'{inner}<ChangeRowOrder>{"true" if el["changeRowOrder"] is True else "false"}</ChangeRowOrder>')
    if el.get('autoInsertNewRow') is True:
        lines.append(f'{inner}<AutoInsertNewRow>true</AutoInsertNewRow>')
    # RowFilter — nil-плейсхолдер (ключ присутствует → эмитим)
    if 'rowFilter' in el:
        lines.append(f'{inner}<RowFilter xsi:nil="true"/>')
    # Высота в строках (<HeightInTableRows>) — отдельное свойство от <Height> (высота элемента,
    # эмитится generic-ом emit_layout ниже). Таблица может нести оба (237 в корпусе).
    if el.get('heightInTableRows'):
        lines.append(f'{inner}<HeightInTableRows>{el["heightInTableRows"]}</HeightInTableRows>')
    if el.get('header') is False:
        lines.append(f'{inner}<Header>false</Header>')
    if el.get('footer') is True:
        lines.append(f'{inner}<Footer>true</Footer>')

    if el.get('commandBarLocation'):
        lines.append(f'{inner}<CommandBarLocation>{el["commandBarLocation"]}</CommandBarLocation>')
    if el.get('searchStringLocation'):
        lines.append(f'{inner}<SearchStringLocation>{el["searchStringLocation"]}</SearchStringLocation>')

    if el.get('choiceMode') is True:
        lines.append(f'{inner}<ChoiceMode>true</ChoiceMode>')
    # Скаляры таблицы (захват «как есть»). Autofill — СВОЁ свойство таблицы (≠ AutoCommandBar autofill = tableAutofill).
    if el.get('autofill') is not None:
        lines.append(f'{inner}<Autofill>{"true" if el["autofill"] else "false"}</Autofill>')
    if el.get('multipleChoice') is True:
        lines.append(f'{inner}<MultipleChoice>true</MultipleChoice>')
    if el.get('searchOnInput'):
        lines.append(f'{inner}<SearchOnInput>{el["searchOnInput"]}</SearchOnInput>')
    if el.get('markIncomplete') is not None:
        lines.append(f'{inner}<AutoMarkIncomplete>{"true" if el["markIncomplete"] else "false"}</AutoMarkIncomplete>')
    # Высота шапки/подвала в строках (pass-through; 1С толерантна к порядку детей Table)
    if el.get('headerHeight') is not None:
        lines.append(f'{inner}<HeaderHeight>{el["headerHeight"]}</HeaderHeight>')
    if el.get('footerHeight') is not None:
        lines.append(f'{inner}<FooterHeight>{el["footerHeight"]}</FooterHeight>')
    if el.get('useAlternationRowColor') is True:
        lines.append(f'{inner}<UseAlternationRowColor>true</UseAlternationRowColor>')
    if el.get('selectionMode'):
        lines.append(f'{inner}<SelectionMode>{el["selectionMode"]}</SelectionMode>')
    if el.get('rowSelectionMode'):
        lines.append(f'{inner}<RowSelectionMode>{el["rowSelectionMode"]}</RowSelectionMode>')
    if el.get('verticalLines') is False:
        lines.append(f'{inner}<VerticalLines>false</VerticalLines>')
    if el.get('horizontalLines') is False:
        lines.append(f'{inner}<HorizontalLines>false</HorizontalLines>')
    if el.get('initialTreeView'):
        lines.append(f'{inner}<InitialTreeView>{el["initialTreeView"]}</InitialTreeView>')
    if el.get('enableDrag') is not None:
        lines.append(f'{inner}<EnableDrag>{"true" if el["enableDrag"] else "false"}</EnableDrag>')
    if el.get('rowPictureDataPath'):
        lines.append(f'{inner}<RowPictureDataPath>{el["rowPictureDataPath"]}</RowPictureDataPath>')
    # RowsPicture — та же конвенция, что ValuesPicture (дефолт LoadTransparent=false; abs/TransparentPixel)
    emit_picture_ref(lines, el.get('rowsPicture'), 'RowsPicture', inner)
    # Использование текущей строки таблицы (pass-through; в корпусе соседствует с блоком дин-списка)
    if el.get('currentRowUse'):
        lines.append(f'{inner}<CurrentRowUse>{el["currentRowUse"]}</CurrentRowUse>')
    # Запрос обновления дин-списка (pass-through; в корпусе всегда PullFromTop)
    if el.get('refreshRequest'):
        lines.append(f'{inner}<RefreshRequest>{el["refreshRequest"]}</RefreshRequest>')
    # Блок свойств дин-список-таблицы (помечена эвристикой)
    if el.get('_dynList'):
        emit_dynlist_table_block(lines, el, inner)
    if el.get('viewStatusLocation'):
        lines.append(f'{inner}<ViewStatusLocation>{el["viewStatusLocation"]}</ViewStatusLocation>')
    if el.get('searchControlLocation'):
        lines.append(f'{inner}<SearchControlLocation>{el["searchControlLocation"]}</SearchControlLocation>')
    emit_layout(lines, el, inner)

    # CommandSet таблицы эмитится через emit_layout (общий механизм поля)

    # Оформление (цвета/граница таблицы) — перед компаньонами
    emit_appearance(lines, el, inner, 'field')

    # Companions
    emit_companion_panel(lines, 'ContextMenu', f'{name}\u041a\u043e\u043d\u0442\u0435\u043a\u0441\u0442\u043d\u043e\u0435\u041c\u0435\u043d\u044e', inner, el.get('contextMenu'))
    # AutoCommandBar — with optional Autofill control
    if el.get('commandBar') is not None:
        emit_companion_panel(lines, 'AutoCommandBar', f'{name}\u041a\u043e\u043c\u0430\u043d\u0434\u043d\u0430\u044f\u041f\u0430\u043d\u0435\u043b\u044c', inner, el.get('commandBar'))
    elif el.get('tableAutofill') is not None:
        acb_id = new_id()
        acb_name = f'{name}\u041a\u043e\u043c\u0430\u043d\u0434\u043d\u0430\u044f\u041f\u0430\u043d\u0435\u043b\u044c'
        af_val = 'true' if el['tableAutofill'] else 'false'
        lines.append(f'{inner}<AutoCommandBar name="{acb_name}" id="{acb_id}">')
        lines.append(f'{inner}\t<Autofill>{af_val}</Autofill>')
        lines.append(f'{inner}</AutoCommandBar>')
    else:
        emit_companion(lines, 'AutoCommandBar', f'{name}\u041a\u043e\u043c\u0430\u043d\u0434\u043d\u0430\u044f\u041f\u0430\u043d\u0435\u043b\u044c', inner)
    emit_companion(lines, 'ExtendedTooltip', f'{name}\u0420\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u043d\u0430\u044f\u041f\u043e\u0434\u0441\u043a\u0430\u0437\u043a\u0430', inner, el.get('extendedTooltip'))
    adds = el.get('additions')
    emit_table_addition(lines, 'searchString',  name, inner, get_addition_override(adds, 'searchString'))
    emit_table_addition(lines, 'viewStatus',    name, inner, get_addition_override(adds, 'viewStatus'))
    emit_table_addition(lines, 'searchControl', name, inner, get_addition_override(adds, 'searchControl'))

    # Columns
    if el.get('columns') and len(el['columns']) > 0:
        lines.append(f'{inner}<ChildItems>')
        for col in el['columns']:
            emit_element(lines, col, f'{inner}\t')
        lines.append(f'{inner}</ChildItems>')

    emit_events(lines, el, name, inner, 'table')

    lines.append(f'{indent}</Table>')


def emit_pages(lines, el, name, eid, indent):
    lines.append(f'{indent}<Pages name="{name}" id="{eid}"{di_attr(el)}>')
    inner = f'{indent}\t'

    emit_title(lines, el, name, inner)

    if el.get('pagesRepresentation'):
        lines.append(f'{inner}<PagesRepresentation>{el["pagesRepresentation"]}</PagesRepresentation>')
    # \u0418\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435 \u0442\u0435\u043a\u0443\u0449\u0435\u0439 \u0441\u0442\u0440\u043e\u043a\u0438 (\u043f\u043e\u0441\u043b\u0435 PagesRepresentation, \u043f\u043e\u0440\u044f\u0434\u043e\u043a XSD)
    if el.get('currentRowUse'):
        lines.append(f'{inner}<CurrentRowUse>{el["currentRowUse"]}</CurrentRowUse>')

    emit_common_flags(lines, el, inner)
    emit_layout(lines, el, inner)

    # \u041e\u0444\u043e\u0440\u043c\u043b\u0435\u043d\u0438\u0435 (\u0446\u0432\u0435\u0442\u0430/\u0448\u0440\u0438\u0444\u0442\u044b/\u0433\u0440\u0430\u043d\u0438\u0446\u0430) \u0437\u0430\u0433\u043e\u043b\u043e\u0432\u043a\u0430 \u0433\u0440\u0443\u043f\u043f\u044b \u0441\u0442\u0440\u0430\u043d\u0438\u0446 \u2014 TitleFont/TitleTextColor/\u2026 (\u043a\u0430\u043a \u0443 Page)
    emit_appearance(lines, el, inner, 'field')

    # Companion
    emit_companion(lines, 'ExtendedTooltip', f'{name}\u0420\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u043d\u0430\u044f\u041f\u043e\u0434\u0441\u043a\u0430\u0437\u043a\u0430', inner, el.get('extendedTooltip'))

    emit_events(lines, el, name, inner, 'pages')

    # Children (pages)
    if el.get('children') and len(el['children']) > 0:
        lines.append(f'{inner}<ChildItems>')
        for child in el['children']:
            emit_element(lines, child, f'{inner}\t')
        lines.append(f'{inner}</ChildItems>')

    lines.append(f'{indent}</Pages>')


def emit_page(lines, el, name, eid, indent):
    lines.append(f'{indent}<Page name="{name}" id="{eid}"{di_attr(el)}>')
    inner = f'{indent}\t'

    emit_title(lines, el, name, inner, auto=True)
    emit_common_flags(lines, el, inner)

    # Картинка страницы (иконка вкладки): после Title/флагов, перед Group (порядок XSD).
    # Конвенция как у ValuesPicture (дефолт LoadTransparent=false): скаляр-Ref/'abs:X' или объект.
    emit_picture_ref(lines, el.get('picture'), 'Picture', inner)

    if el.get('group'):
        orientation_map = {
            'horizontal': 'Horizontal',
            'vertical': 'Vertical',
            'alwayshorizontal': 'AlwaysHorizontal',
            'alwaysvertical': 'AlwaysVertical',
            'horizontalifpossible': 'HorizontalIfPossible',
        }
        orientation = orientation_map.get(str(el['group']).lower())
        if orientation:
            lines.append(f'{inner}<Group>{orientation}</Group>')
        else:
            _warn_unrecognized('page group orientation', el['group'], ('vertical', 'horizontalIfPossible', 'alwaysHorizontal'), name)
    if el.get('showTitle') is not None:
        lines.append(f'{inner}<ShowTitle>{"true" if el["showTitle"] else "false"}</ShowTitle>')
    # Формат значения пути к данным заголовка (<Format>; парный к titleDataPath страницы)
    if el.get('format'):
        emit_mltext(lines, inner, 'Format', el['format'])
    if el.get('editFormat'):
        emit_mltext(lines, inner, 'EditFormat', el['editFormat'])
    emit_layout(lines, el, inner)

    # \u041e\u0444\u043e\u0440\u043c\u043b\u0435\u043d\u0438\u0435 \u0441\u0442\u0440\u0430\u043d\u0438\u0446\u044b (BackColor / TitleTextColor / TitleFont) \u2014 \u043f\u043e\u0441\u043b\u0435 ShowTitle, \u043f\u0435\u0440\u0435\u0434 \u043a\u043e\u043c\u043f\u0430\u043d\u044c\u043e\u043d\u043e\u043c
    emit_appearance(lines, el, inner, 'field')

    # Companion
    emit_companion(lines, 'ExtendedTooltip', f'{name}\u0420\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u043d\u0430\u044f\u041f\u043e\u0434\u0441\u043a\u0430\u0437\u043a\u0430', inner, el.get('extendedTooltip'))

    # Children
    if el.get('children') and len(el['children']) > 0:
        lines.append(f'{inner}<ChildItems>')
        for child in el['children']:
            emit_element(lines, child, f'{inner}\t')
        lines.append(f'{inner}</ChildItems>')

    lines.append(f'{indent}</Page>')


def emit_button(lines, el, name, eid, indent, in_cmd_bar=False):
    lines.append(f'{indent}<Button name="{name}" id="{eid}"{di_attr(el)}>')
    inner = f'{indent}\t'
    # (общие свойства — через emit_layout ниже; отдельный вызов был бы двойной эмиссией)

    # Type — context-aware. Inside command bars (cmdBar/autoCmdBar/popup) only
    # CommandBarButton/CommandBarHyperlink are valid; UsualButton/Hyperlink would be ignored.
    # Forgiving resolver: any "ordinary button" hint resolves to UsualButton/CommandBarButton,
    # any "hyperlink" hint resolves to Hyperlink/CommandBarHyperlink — depending on context.
    btn_type = None
    if el.get('type'):
        raw = str(el['type'])
        if in_cmd_bar:
            cmd_bar_map = {
                'usual': 'CommandBarButton',
                'UsualButton': 'CommandBarButton',
                'commandBar': 'CommandBarButton',
                'CommandBarButton': 'CommandBarButton',
                'hyperlink': 'CommandBarHyperlink',
                'Hyperlink': 'CommandBarHyperlink',
                'CommandBarHyperlink': 'CommandBarHyperlink',
            }
            btn_type = cmd_bar_map.get(raw, raw)
        else:
            normal_map = {
                'usual': 'UsualButton',
                'UsualButton': 'UsualButton',
                'commandBar': 'UsualButton',
                'CommandBarButton': 'UsualButton',
                'hyperlink': 'Hyperlink',
                'Hyperlink': 'Hyperlink',
                'CommandBarHyperlink': 'Hyperlink',
            }
            btn_type = normal_map.get(raw, raw)
    elif in_cmd_bar:
        btn_type = 'CommandBarButton'
    if btn_type:
        lines.append(f'{inner}<Type>{btn_type}</Type>')

    # CommandName
    if el.get('command'):
        lines.append(f'{inner}<CommandName>Form.Command.{el["command"]}</CommandName>')
    # commandName — глобальная команда «как есть» (CommonCommand.X, Catalog.X.Command.Y …), без обёртки Form.
    if el.get('commandName') and not el.get('command'):
        lines.append(f'{inner}<CommandName>{el["commandName"]}</CommandName>')
    if el.get('stdCommand'):
        sc = str(el['stdCommand'])
        m = re.match(r'^(.+)\.(.+)$', sc)
        if m:
            lines.append(f'{inner}<CommandName>Form.Item.{m.group(1)}.StandardCommand.{m.group(2)}</CommandName>')
        else:
            lines.append(f'{inner}<CommandName>Form.StandardCommand.{sc}</CommandName>')
    # Parameter команды (после CommandName): строка → xr:MDObjectRef (объект метаданных);
    # объект {type} → v8:TypeDescription (грамматика типа). Forgiving-синоним 'параметр'.
    btn_param = el.get('parameter')
    if btn_param is None:
        btn_param = el.get('параметр')
    if btn_param is not None:
        if isinstance(btn_param, dict) and btn_param.get('type'):
            emit_type(lines, str(btn_param['type']), inner, tag='Parameter', tag_attrs=' xsi:type="v8:TypeDescription"')
        else:
            lines.append(f'{inner}<Parameter xsi:type="xr:MDObjectRef">{esc_xml_text(str(btn_param))}</Parameter>')
    # DataPath — привязка команды кнопки к контексту (Объект.Ref, Items.X.CurrentData.Поле)
    if el.get('path'):
        lines.append(f'{inner}<DataPath>{el["path"]}</DataPath>')

    emit_title(lines, el, name, inner, auto=not (el.get('command') or el.get('commandName') or el.get('stdCommand')))
    emit_common_flags(lines, el, inner)

    if el.get('defaultButton') is True:
        lines.append(f'{inner}<DefaultButton>true</DefaultButton>')
    # Check (пометка toggle-кнопки командной панели) — платформа эмитит только true.
    # Ключ 'checked' (не 'check': 'check' — тип-ключ CheckBoxField, был бы конфликт диспетчера типов)
    if el.get('checked') is True:
        lines.append(f'{inner}<Check>true</Check>')

    # Picture
    emit_command_picture(lines, el.get('picture'), el.get('loadTransparent'), inner)

    if el.get('representation'):
        lines.append(f'{inner}<Representation>{el["representation"]}</Representation>')

    if el.get('locationInCommandBar'):
        lines.append(f'{inner}<LocationInCommandBar>{el["locationInCommandBar"]}</LocationInCommandBar>')
    emit_layout(lines, el, inner)

    # Оформление (цвета/шрифт/граница) — перед компаньоном (профиль кнопки)
    emit_appearance(lines, el, inner, 'button')

    # Companion
    emit_companion(lines, 'ExtendedTooltip', f'{name}\u0420\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u043d\u0430\u044f\u041f\u043e\u0434\u0441\u043a\u0430\u0437\u043a\u0430', inner, el.get('extendedTooltip'))

    emit_events(lines, el, name, inner, 'button')

    lines.append(f'{indent}</Button>')


def emit_picture_decoration(lines, el, name, eid, indent):
    lines.append(f'{indent}<PictureDecoration name="{name}" id="{eid}"{di_attr(el)}>')
    inner = f'{indent}\t'

    emit_decoration_title(lines, el, name, inner)
    # Текст при невыбранной картинке (NonselectedPictureText) — после Title (порядок корпуса)
    if el.get('nonselectedPictureText') is not None:
        emit_mltext(lines, inner, 'NonselectedPictureText', el['nonselectedPictureText'])
    emit_common_flags(lines, el, inner)

    # Источник картинки — ТОЛЬКО src (ключ 'picture' = тип/имя элемента, не источник).
    # Префикс "abs:" → встроенная картинка <xr:Abs>; иначе именованная/стилевая <xr:Ref>.
    if el.get('src'):
        src_str = str(el['src'])
        lt = 'true' if el.get('loadTransparent') is True else 'false'
        lines.append(f'{inner}<Picture>')
        if src_str.startswith('abs:'):
            lines.append(f'{inner}\t<xr:Abs>{esc_xml_text(src_str[4:])}</xr:Abs>')
        else:
            lines.append(f'{inner}\t<xr:Ref>{esc_xml_text(src_str)}</xr:Ref>')
        lines.append(f'{inner}\t<xr:LoadTransparent>{lt}</xr:LoadTransparent>')
        tpx = el.get('transparentPixel')
        if tpx:
            lines.append(f'{inner}\t<xr:TransparentPixel x="{tpx.get("x")}" y="{tpx.get("y")}"/>')
        lines.append(f'{inner}</Picture>')

    if el.get('hyperlink') is True:
        lines.append(f'{inner}<Hyperlink>true</Hyperlink>')
    emit_layout(lines, el, inner)
    # EnableDrag — фактическое значение (декорация-картинка перетаскиваема; декомпилятор ловит generic-ом)
    if el.get('enableDrag') is not None:
        lines.append(f'{inner}<EnableDrag>{"true" if el["enableDrag"] else "false"}</EnableDrag>')

    # Оформление (цвета/шрифт/граница) — профиль декорации (1С толерантна к порядку appearance)
    emit_appearance(lines, el, inner, 'decoration')

    # Companions
    emit_companion_panel(lines, 'ContextMenu', f'{name}\u041a\u043e\u043d\u0442\u0435\u043a\u0441\u0442\u043d\u043e\u0435\u041c\u0435\u043d\u044e', inner, el.get('contextMenu'))
    emit_companion(lines, 'ExtendedTooltip', f'{name}\u0420\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u043d\u0430\u044f\u041f\u043e\u0434\u0441\u043a\u0430\u0437\u043a\u0430', inner, el.get('extendedTooltip'))

    emit_events(lines, el, name, inner, 'picture')

    lines.append(f'{indent}</PictureDecoration>')


def emit_picture_field(lines, el, name, eid, indent):
    lines.append(f'{indent}<PictureField name="{name}" id="{eid}"{di_attr(el)}>')
    inner = f'{indent}\t'

    if el.get('path'):
        lines.append(f'{inner}<DataPath>{el["path"]}</DataPath>')

    emit_title(lines, el, name, inner)
    emit_common_flags(lines, el, inner)

    if el.get('editMode'):
        lines.append(f'{inner}<EditMode>{el["editMode"]}</EditMode>')
    emit_column_pics(lines, el, inner)
    if el.get('titleLocation'):
        lines.append(f'{inner}<TitleLocation>{map_title_loc(el["titleLocation"])}</TitleLocation>')
    if el.get('hyperlink') is True:
        lines.append(f'{inner}<Hyperlink>true</Hyperlink>')

    emit_layout(lines, el, inner)
    # EnableDrag — фактическое значение (поле картинки перетаскиваемо; декомпилятор ловит generic-ом)
    if el.get('enableDrag') is not None:
        lines.append(f'{inner}<EnableDrag>{"true" if el["enableDrag"] else "false"}</EnableDrag>')

    # FooterDataPath / FooterText — общие cell-свойства колонки (как у input/labelField)
    if el.get('footerDataPath'):
        lines.append(f'{inner}<FooterDataPath>{esc_xml_text(str(el["footerDataPath"]))}</FooterDataPath>')
    if el.get('footerText') is not None:
        emit_mltext(lines, inner, 'FooterText', el['footerText'])

    # ValuesPicture — picture (collection) used to render the field's value.
    # Required for a Boolean-bound PictureField to actually show an icon.
    # Скаляр (Ref) или объект {src, loadTransparent}; LoadTransparent эмитится всегда.
    emit_picture_ref(lines, el.get('valuesPicture'), 'ValuesPicture', inner)
    if el.get('nonselectedPictureText') is not None:
        emit_mltext(lines, inner, 'NonselectedPictureText', el['nonselectedPictureText'])

    # Оформление (цвета/шрифты/граница) — перед компаньонами
    emit_appearance(lines, el, inner, 'field')

    # Companions
    emit_companion_panel(lines, 'ContextMenu', f'{name}\u041a\u043e\u043d\u0442\u0435\u043a\u0441\u0442\u043d\u043e\u0435\u041c\u0435\u043d\u044e', inner, el.get('contextMenu'))
    emit_companion(lines, 'ExtendedTooltip', f'{name}\u0420\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u043d\u0430\u044f\u041f\u043e\u0434\u0441\u043a\u0430\u0437\u043a\u0430', inner, el.get('extendedTooltip'))

    emit_events(lines, el, name, inner, 'picField')

    lines.append(f'{indent}</PictureField>')


def emit_simple_field(lines, el, name, eid, indent, xml_tag, type_key):
    # Спец-поля "документ/датчик" (SpreadSheet/HTML/Text/Formatted/ProgressBar/TrackBar):
    # единый скелет поля. Типоспец. enum/bool скаляры — через generic (emit_layout);
    # числовые скаляры датчиков (min/max/шаги) — без xsi:type; enableDrag — фактическое значение.
    lines.append(f'{indent}<{xml_tag} name="{name}" id="{eid}"{di_attr(el)}>')
    inner = f'{indent}\t'

    if el.get('path'):
        lines.append(f'{inner}<DataPath>{el["path"]}</DataPath>')
    emit_title(lines, el, name, inner, auto=not el.get('path'))
    emit_common_flags(lines, el, inner)
    if el.get('titleLocation'):
        lines.append(f'{inner}<TitleLocation>{map_title_loc(el["titleLocation"])}</TitleLocation>')
    if el.get('editMode'):
        lines.append(f'{inner}<EditMode>{el["editMode"]}</EditMode>')

    emit_layout(lines, el, inner)

    # EnableDrag — фактическое значение (SpreadSheet; платформа эмитит явный false). enableStartDrag — через emit_layout.
    if el.get('enableDrag') is not None:
        lines.append(f'{inner}<EnableDrag>{"true" if el["enableDrag"] else "false"}</EnableDrag>')

    # Датчики (ProgressBar/TrackBar) — числовые скаляры (без xsi:type)
    for key, tag in (('minValue', 'MinValue'), ('maxValue', 'MaxValue'), ('largeStep', 'LargeStep'), ('markingStep', 'MarkingStep'), ('step', 'Step')):
        if el.get(key) is not None:
            lines.append(f'{inner}<{tag}>{el[key]}</{tag}>')

    # Оформление (цвета/шрифты/граница) — перед компаньонами
    emit_appearance(lines, el, inner, 'field')

    # Companions
    emit_companion_panel(lines, 'ContextMenu', f'{name}КонтекстноеМеню', inner, el.get('contextMenu'))
    emit_companion(lines, 'ExtendedTooltip', f'{name}РасширеннаяПодсказка', inner, el.get('extendedTooltip'))

    emit_events(lines, el, name, inner, type_key)

    lines.append(f'{indent}</{xml_tag}>')


def emit_gantt_chart(lines, el, name, eid, indent):
    # GanttChartField — скелет поля + вложенная <Table> (полноценная таблица, через emit_element).
    lines.append(f'{indent}<GanttChartField name="{name}" id="{eid}"{di_attr(el)}>')
    inner = f'{indent}\t'
    if el.get('path'):
        lines.append(f'{inner}<DataPath>{el["path"]}</DataPath>')
    emit_title(lines, el, name, inner, auto=not el.get('path'))
    emit_common_flags(lines, el, inner)
    if el.get('titleLocation'):
        lines.append(f'{inner}<TitleLocation>{map_title_loc(el["titleLocation"])}</TitleLocation>')
    emit_layout(lines, el, inner)
    emit_appearance(lines, el, inner, 'field')
    emit_companion_panel(lines, 'ContextMenu', f'{name}КонтекстноеМеню', inner, el.get('contextMenu'))
    emit_companion(lines, 'ExtendedTooltip', f'{name}РасширеннаяПодсказка', inner, el.get('extendedTooltip'))
    # Вложенная таблица диаграммы Ганта (стандартный Table — переиспользуем emit_element)
    if el.get('ganttTable'):
        emit_element(lines, el['ganttTable'], inner)
    emit_events(lines, el, name, inner, 'ganttChart')
    lines.append(f'{indent}</GanttChartField>')


def emit_calendar(lines, el, name, eid, indent):
    lines.append(f'{indent}<CalendarField name="{name}" id="{eid}"{di_attr(el)}>')
    inner = f'{indent}\t'

    if el.get('path'):
        lines.append(f'{inner}<DataPath>{el["path"]}</DataPath>')

    emit_title(lines, el, name, inner, auto=not el.get('path'))
    emit_common_flags(lines, el, inner)

    if el.get('titleLocation'):
        loc_map = {'none': 'None', 'left': 'Left', 'right': 'Right', 'top': 'Top', 'bottom': 'Bottom', 'auto': 'Auto'}
        loc = loc_map.get(str(el['titleLocation']), str(el['titleLocation']))
        lines.append(f'{inner}<TitleLocation>{loc}</TitleLocation>')

    emit_layout(lines, el, inner)

    # Календарно-специфичные свойства (порядок схемы: после layout, до companions)
    if el.get('selectionMode'):
        lines.append(f'{inner}<SelectionMode>{el["selectionMode"]}</SelectionMode>')
    if el.get('showCurrentDate') is not None:
        lines.append(f'{inner}<ShowCurrentDate>{"true" if el["showCurrentDate"] else "false"}</ShowCurrentDate>')
    if el.get('widthInMonths') is not None:
        lines.append(f'{inner}<WidthInMonths>{el["widthInMonths"]}</WidthInMonths>')
    if el.get('heightInMonths') is not None:
        lines.append(f'{inner}<HeightInMonths>{el["heightInMonths"]}</HeightInMonths>')
    if el.get('showMonthsPanel') is not None:
        lines.append(f'{inner}<ShowMonthsPanel>{"true" if el["showMonthsPanel"] else "false"}</ShowMonthsPanel>')

    # Оформление (цвета/шрифты/граница) — перед компаньонами
    emit_appearance(lines, el, inner, 'field')

    # Companions
    emit_companion_panel(lines, 'ContextMenu', f'{name}\u041a\u043e\u043d\u0442\u0435\u043a\u0441\u0442\u043d\u043e\u0435\u041c\u0435\u043d\u044e', inner, el.get('contextMenu'))
    emit_companion(lines, 'ExtendedTooltip', f'{name}\u0420\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u043d\u0430\u044f\u041f\u043e\u0434\u0441\u043a\u0430\u0437\u043a\u0430', inner, el.get('extendedTooltip'))

    emit_events(lines, el, name, inner, 'calendar')

    lines.append(f'{indent}</CalendarField>')


def emit_command_bar(lines, el, name, eid, indent):
    lines.append(f'{indent}<CommandBar name="{name}" id="{eid}"{di_attr(el)}>')
    inner = f'{indent}\t'

    emit_title(lines, el, name, inner)

    if el.get('commandSource'):
        lines.append(f'{inner}<CommandSource>{el["commandSource"]}</CommandSource>')

    if el.get('autofill') is True:
        lines.append(f'{inner}<Autofill>true</Autofill>')

    # CommandBar хранит HorizontalLocation фактически (включая Auto); ≠ дополнениям (Auto=скип)
    if el.get('horizontalLocation'):
        _hlv = {'auto': 'Auto', 'left': 'Left', 'right': 'Right', 'center': 'Center'}.get(str(el['horizontalLocation']).lower(), str(el['horizontalLocation']))
        lines.append(f'{inner}<HorizontalLocation>{_hlv}</HorizontalLocation>')

    emit_common_flags(lines, el, inner)
    emit_layout(lines, el, inner)
    emit_companion(lines, 'ExtendedTooltip', f'{name}РасширеннаяПодсказка', inner, el.get('extendedTooltip'))

    # Children
    if el.get('children') and len(el['children']) > 0:
        lines.append(f'{inner}<ChildItems>')
        for child in el['children']:
            emit_element(lines, child, f'{inner}\t', in_cmd_bar=True)
        lines.append(f'{inner}</ChildItems>')

    lines.append(f'{indent}</CommandBar>')


def emit_popup(lines, el, name, eid, indent):
    lines.append(f'{indent}<Popup name="{name}" id="{eid}"{di_attr(el)}>')
    inner = f'{indent}\t'

    emit_title(lines, el, name, inner, auto=True)
    emit_common_flags(lines, el, inner)

    # Источник команд попапа (после Title/ToolTip, перед компаньоном) — как у ButtonGroup/CommandBar
    if el.get('commandSource'):
        lines.append(f'{inner}<CommandSource>{el["commandSource"]}</CommandSource>')

    emit_command_picture(lines, el.get('picture'), el.get('loadTransparent'), inner)

    if el.get('representation'):
        lines.append(f'{inner}<Representation>{el["representation"]}</Representation>')
    emit_layout(lines, el, inner)

    # Оформление попапа (TitleTextColor / TitleFont) — перед компаньоном
    emit_appearance(lines, el, inner, 'field')

    emit_companion(lines, 'ExtendedTooltip', f'{name}РасширеннаяПодсказка', inner, el.get('extendedTooltip'))

    # Children
    if el.get('children') and len(el['children']) > 0:
        lines.append(f'{inner}<ChildItems>')
        for child in el['children']:
            emit_element(lines, child, f'{inner}\t', in_cmd_bar=True)
        lines.append(f'{inner}</ChildItems>')

    lines.append(f'{indent}</Popup>')


def emit_button_group(lines, el, name, eid, indent):
    lines.append(f'{indent}<ButtonGroup name="{name}" id="{eid}"{di_attr(el)}>')
    inner = f'{indent}\t'

    emit_title(lines, el, name, inner)

    if el.get('commandSource'):
        lines.append(f'{inner}<CommandSource>{el["commandSource"]}</CommandSource>')

    if el.get('representation'):
        lines.append(f'{inner}<Representation>{el["representation"]}</Representation>')

    emit_common_flags(lines, el, inner)
    emit_layout(lines, el, inner)

    # Companion: ExtendedTooltip
    emit_companion(lines, 'ExtendedTooltip', f'{name}РасширеннаяПодсказка', inner, el.get('extendedTooltip'))

    # Children (кнопки в контексте командной панели)
    if el.get('children') and len(el['children']) > 0:
        lines.append(f'{inner}<ChildItems>')
        for child in el['children']:
            emit_element(lines, child, f'{inner}\t', in_cmd_bar=True)
        lines.append(f'{inner}</ChildItems>')

    lines.append(f'{indent}</ButtonGroup>')


# --- Attribute emitter ---

def emit_functional_options(lines, fo, indent):
    # <FunctionalOptions><Item>FunctionalOption.X</Item>…> — у Attribute/Command/Column.
    # Forgiving: "X"/"FunctionalOption.X" → FunctionalOption.X; GUID (расширение) — как есть.
    if not fo:
        return
    lines.append(f'{indent}<FunctionalOptions>')
    for opt in fo:
        v = str(opt)
        if re.match(r'^[0-9a-fA-F]{8}-[0-9a-fA-F-]{27,}$', v):
            pass
        elif v.startswith('FunctionalOption.'):
            pass
        else:
            v = f'FunctionalOption.{v}'
        lines.append(f'{indent}\t<Item>{v}</Item>')
    lines.append(f'{indent}</FunctionalOptions>')


def emit_attr_column(lines, col, indent):
    # Колонка реквизита (ValueTable/Tree или AdditionalColumns): name/Title/Type/FunctionalOptions.
    col_id = new_id()
    lines.append(f'{indent}<Column name="{col["name"]}" id="{col_id}">')
    if col.get('title'):
        emit_mltext(lines, f'{indent}\t', 'Title', col['title'])
    emit_type(lines, str(col.get('type', '')), f'{indent}\t')
    # Проверка заполнения колонки → <FillCheck> (как у реквизита; bool true→ShowError / строка verbatim)
    cfc = col.get('fillCheck') if col.get('fillCheck') is not None else col.get('fillChecking')
    if cfc is not None:
        cfcv = ('ShowError' if cfc else None) if isinstance(cfc, bool) else str(cfc)
        if cfcv:
            lines.append(f'{indent}\t<FillCheck>{cfcv}</FillCheck>')
    emit_functional_options(lines, col.get('functionalOptions'), f'{indent}\t')
    # Ролевой доступ колонки (View/Edit) — xr-флаг, как у самого реквизита
    if col.get('view') is not None:
        emit_xr_flag(lines, 'View', col['view'], f'{indent}\t')
    if col.get('edit') is not None:
        emit_xr_flag(lines, 'Edit', col['edit'], f'{indent}\t')
    lines.append(f'{indent}</Column>')


# --- Schema-параметры динамического списка (DataCompositionSchemaParameter) ---
# Зеркало form-compile.ps1 (Emit-DLParameters). Та же сущность, что параметры СКД, но в
# форме: обёртка <Parameter> + дети dcssch:. DSL переиспользует грамматику параметров СКД.
# Контекстные дефолты: useRestriction эмитим ВСЕГДА, дефолт true (в СКД false); title — авто
# из имени; пустое value — всегда xsi:nil (даже при известном типе). Канон. порядок детей
# (по корпусу): name, title, valueType, value, useRestriction, expression, availableValue*,
# valueListAllowed, availableAsField, inputParameters, denyIncompleteValues, use.

def emit_dl_mltext(lines, indent, tag, text):
    # ML-текст с xsi:type="v8:LocalStringType" (в dcssch:* обязателен; emit_mltext его не ставит).
    lines.append(f'{indent}<{tag} xsi:type="v8:LocalStringType">')
    emit_ml_items(lines, f'{indent}\t', text)
    lines.append(f'{indent}</{tag}>')


def split_dl_valuelist_csv(s):
    result = []
    if s is None:
        return result
    items = []
    buf = []
    in_quote = None
    for ch in s:
        if in_quote:
            buf.append(ch)
            if ch == in_quote:
                in_quote = None
        elif ch in ("'", '"'):
            in_quote = ch
            buf.append(ch)
        elif ch == ',':
            items.append(''.join(buf)); buf = []
        else:
            buf.append(ch)
    if buf:
        items.append(''.join(buf))
    for raw in items:
        t = raw.strip()
        if len(t) >= 2 and ((t[0] == "'" and t[-1] == "'") or (t[0] == '"' and t[-1] == '"')):
            t = t[1:-1]
        if t != '':
            result.append(t)
    return result


def parse_dl_param_shorthand(s):
    result = {'name': '', 'type': '', 'value': None, 'title': None}
    if '@valueList' in s:
        result['valueListAllowed'] = True
        s = re.sub(r'\s*@valueList', '', s)
    if '@hidden' in s:
        result['hidden'] = True
        s = re.sub(r'\s*@hidden', '', s)
    m = re.search(r'\[([^\]]*)\]', s)
    if m:
        result['title'] = m.group(1).strip()
        s = re.sub(r'\s*\[[^\]]*\]\s*', ' ', s).strip()
    # Тип может быть СОСТАВНЫМ (A | B | C — с пробелами); значение — после '=' (тип '=' не содержит).
    m = re.match(r'^([^:]+):\s*([^=]+?)(\s*=\s*(.*))?$', s)
    if m:
        result['name'] = m.group(1).strip()
        type_raw = m.group(2).strip()
        if re.search(r'[|+]', type_raw):
            result['type'] = ' | '.join(resolve_type_str(p.strip()) for p in re.split(r'\s*[|+]\s*', type_raw))
        else:
            result['type'] = resolve_type_str(type_raw)
        if m.group(4):
            rhs = m.group(4).strip()
            items = split_dl_valuelist_csv(rhs)
            if len(items) >= 2:
                result['value'] = items
                result['valueListAllowed'] = True
            elif len(items) == 1:
                result['value'] = items[0]
            else:
                result['value'] = rhs
    else:
        result['name'] = s.strip()
    return result


def is_dl_empty_value(v):
    if v is None:
        return True
    sv = str(v).strip()
    return sv == '' or sv == '_' or sv.lower() == 'null'


def emit_dl_value(lines, type_str, val, indent, value_list_allowed=False):
    if is_dl_empty_value(val):
        # Дин-список: пустое значение платформа ВСЕГДА пишет как xsi:nil (даже при известном типе).
        if value_list_allowed:
            return
        lines.append(f'{indent}<dcssch:value xsi:nil="true"/>')
        return
    if isinstance(val, bool):
        val_str = 'true' if val else 'false'
    else:
        val_str = str(val)
    t = type_str or ''
    if re.match(r'^(date|dateTime|time)', t):
        lines.append(f'{indent}<dcssch:value xsi:type="xs:dateTime">{esc_xml_text(val_str)}</dcssch:value>')
    elif t == 'boolean':
        lines.append(f'{indent}<dcssch:value xsi:type="xs:boolean">{esc_xml_text(val_str)}</dcssch:value>')
    elif t == 'v8:Type':
        ns_attr = _value_type_ns_attr('v8:Type', val_str)
        lines.append(f'{indent}<dcssch:value{ns_attr} xsi:type="v8:Type">{esc_xml_text(val_str)}</dcssch:value>')
    elif re.match(r'^ent:', t):
        # системное перечисление (ent:X) — value несёт тот же xsi:type
        lines.append(f'{indent}<dcssch:value xsi:type="{t}">{esc_xml_text(val_str)}</dcssch:value>')
    elif re.match(r'^decimal', t):
        lines.append(f'{indent}<dcssch:value xsi:type="xs:decimal">{esc_xml_text(val_str)}</dcssch:value>')
    elif re.match(r'^string', t):
        lines.append(f'{indent}<dcssch:value xsi:type="xs:string">{esc_xml_text(val_str)}</dcssch:value>')
    elif re.match(r'^(CatalogRef|DocumentRef|EnumRef|ChartOfAccountsRef|ChartOfCharacteristicTypesRef|ChartOfCalculationTypesRef|BusinessProcessRef|TaskRef|ExchangePlanRef)\.', t):
        lines.append(f'{indent}<dcssch:value xsi:type="dcscor:DesignTimeValue">{esc_xml_text(val_str)}</dcssch:value>')
    else:
        if re.match(r'^\d{4}-\d{2}-\d{2}T', val_str):
            lines.append(f'{indent}<dcssch:value xsi:type="xs:dateTime">{esc_xml_text(val_str)}</dcssch:value>')
        elif val_str in ('true', 'false'):
            lines.append(f'{indent}<dcssch:value xsi:type="xs:boolean">{esc_xml_text(val_str)}</dcssch:value>')
        elif re.match(r'^(ПланСчетов|Справочник|Перечисление|Документ|ПланВидовХарактеристик|ПланВидовРасчета|БизнесПроцесс|Задача|РегистрСведений|ПланОбмена)\.', val_str) or re.match(r'^(ChartOfAccounts|Catalog|Enum|Document|ChartOfCharacteristicTypes|ChartOfCalculationTypes|BusinessProcess|Task|InformationRegister|ExchangePlan)\.', val_str):
            lines.append(f'{indent}<dcssch:value xsi:type="dcscor:DesignTimeValue">{esc_xml_text(val_str)}</dcssch:value>')
        else:
            lines.append(f'{indent}<dcssch:value xsi:type="xs:string">{esc_xml_text(val_str)}</dcssch:value>')


def emit_dl_value_type(lines, type_str, indent):
    if not type_str:
        return
    lines.append(f'{indent}<dcssch:valueType>')
    for part in re.split(r'\s*[|+]\s*', str(type_str)):
        emit_single_type(lines, part.strip(), f'{indent}\t')
    lines.append(f'{indent}</dcssch:valueType>')


def emit_dl_available_value(lines, av, type_str, indent):
    lines.append(f'{indent}<dcssch:availableValue>')
    av_val = av.get('value') if isinstance(av, dict) else None
    emit_dl_value(lines, type_str, av_val, f'{indent}\t', False)
    pres = (av.get('presentation') or av.get('title')) if isinstance(av, dict) else None
    if pres:
        emit_dl_mltext(lines, f'{indent}\t', 'dcssch:presentation', pres)
    lines.append(f'{indent}</dcssch:availableValue>')


def emit_dl_input_parameters(lines, ip, indent):
    if ip is None:
        return
    items = ip if isinstance(ip, list) else [ip]
    if len(items) == 0:
        return
    lines.append(f'{indent}<dcssch:inputParameters>')
    for item in items:
        lines.append(f'{indent}\t<dcscor:item>')
        if 'use' in item and item.get('use') is not None and not item.get('use'):
            lines.append(f'{indent}\t\t<dcscor:use>false</dcscor:use>')
        lines.append(f'{indent}\t\t<dcscor:parameter>{esc_xml_text(str(item.get("parameter", "")))}</dcscor:parameter>')
        if 'choiceParameters' in item:
            cp_items = item.get('choiceParameters') or []
            if len(cp_items) == 0:
                lines.append(f'{indent}\t\t<dcscor:value xsi:type="dcscor:ChoiceParameters"/>')
            else:
                lines.append(f'{indent}\t\t<dcscor:value xsi:type="dcscor:ChoiceParameters">')
                for cp in cp_items:
                    lines.append(f'{indent}\t\t\t<dcscor:item>')
                    lines.append(f'{indent}\t\t\t\t<dcscor:choiceParameter>{esc_xml_text(str(cp.get("name", "")))}</dcscor:choiceParameter>')
                    for v in (cp.get('values') or []):
                        if isinstance(v, bool):
                            lines.append(f'{indent}\t\t\t\t<dcscor:value xsi:type="xs:boolean">{"true" if v else "false"}</dcscor:value>')
                        elif isinstance(v, (int, float)):
                            lines.append(f'{indent}\t\t\t\t<dcscor:value xsi:type="xs:decimal">{v}</dcscor:value>')
                        else:
                            lines.append(f'{indent}\t\t\t\t<dcscor:value xsi:type="dcscor:DesignTimeValue">{esc_xml_text(str(v))}</dcscor:value>')
                    lines.append(f'{indent}\t\t\t</dcscor:item>')
                lines.append(f'{indent}\t\t</dcscor:value>')
        elif 'choiceParameterLinks' in item:
            cpl_items = item.get('choiceParameterLinks') or []
            if len(cpl_items) == 0:
                lines.append(f'{indent}\t\t<dcscor:value xsi:type="dcscor:ChoiceParameterLinks"/>')
            else:
                lines.append(f'{indent}\t\t<dcscor:value xsi:type="dcscor:ChoiceParameterLinks">')
                for cpl in cpl_items:
                    lines.append(f'{indent}\t\t\t<dcscor:item>')
                    lines.append(f'{indent}\t\t\t\t<dcscor:choiceParameter>{esc_xml_text(str(cpl.get("name", "")))}</dcscor:choiceParameter>')
                    lines.append(f'{indent}\t\t\t\t<dcscor:value>{esc_xml_text(str(cpl.get("value", "")))}</dcscor:value>')
                    mode = str(cpl.get('mode') or 'Auto')
                    lines.append(f'{indent}\t\t\t\t<dcscor:mode xmlns:d8p1="http://v8.1c.ru/8.1/data/enterprise" xsi:type="d8p1:LinkedValueChangeMode">{mode}</dcscor:mode>')
                    lines.append(f'{indent}\t\t\t</dcscor:item>')
                lines.append(f'{indent}\t\t</dcscor:value>')
        elif 'typeLink' in item:
            # Связь по типу (dcscor:TypeLink) — field + linkItem (структурное значение параметра).
            tl = item.get('typeLink') or {}
            lines.append(f'{indent}\t\t<dcscor:value xsi:type="dcscor:TypeLink">')
            if tl.get('field') is not None:
                lines.append(f'{indent}\t\t\t<dcscor:field>{esc_xml_text(str(tl.get("field")))}</dcscor:field>')
            if tl.get('linkItem') is not None:
                lines.append(f'{indent}\t\t\t<dcscor:linkItem>{esc_xml_text(str(tl.get("linkItem")))}</dcscor:linkItem>')
            lines.append(f'{indent}\t\t</dcscor:value>')
        elif 'value' in item:
            val = item.get('value')
            if isinstance(val, bool):
                lines.append(f'{indent}\t\t<dcscor:value xsi:type="xs:boolean">{"true" if val else "false"}</dcscor:value>')
            elif isinstance(val, (int, float)):
                lines.append(f'{indent}\t\t<dcscor:value xsi:type="xs:decimal">{val}</dcscor:value>')
            elif isinstance(val, dict):
                emit_dl_mltext(lines, f'{indent}\t\t', 'dcscor:value', val)
            else:
                lines.append(f'{indent}\t\t<dcscor:value xsi:type="xs:string">{esc_xml_text(str(val))}</dcscor:value>')
        lines.append(f'{indent}\t</dcscor:item>')
    lines.append(f'{indent}</dcssch:inputParameters>')


# ── dataParameters (значения параметров запроса в настройках компоновки) — порт из skd ──
def _test_empty_value(v):
    if v is None:
        return True
    s = str(v).strip()
    return s == '' or s == '_' or s.lower() == 'null'


def emit_empty_value(lines, type_str, indent, tag_prefix='', value_list_allowed=False):
    if value_list_allowed:
        return
    t = type_str or ''
    t_bare = t[3:] if t.startswith('xs:') else t
    pf = tag_prefix
    if t == '':
        lines.append(f'{indent}<{pf}value xsi:nil="true"/>')
    elif t == 'StandardPeriod':
        lines.append(f'{indent}<{pf}value xsi:type="v8:StandardPeriod">')
        lines.append(f'{indent}\t<v8:variant xsi:type="v8:StandardPeriodVariant">Custom</v8:variant>')
        lines.append(f'{indent}\t<v8:startDate>0001-01-01T00:00:00</v8:startDate>')
        lines.append(f'{indent}\t<v8:endDate>0001-01-01T00:00:00</v8:endDate>')
        lines.append(f'{indent}</{pf}value>')
    elif re.match(r'^string', t_bare):
        lines.append(f'{indent}<{pf}value xsi:type="xs:string"/>')
    elif re.match(r'^(date|time)', t_bare):
        lines.append(f'{indent}<{pf}value xsi:type="xs:dateTime">0001-01-01T00:00:00</{pf}value>')
    elif re.match(r'^decimal', t_bare):
        lines.append(f'{indent}<{pf}value xsi:type="xs:decimal">0</{pf}value>')
    elif t_bare == 'boolean':
        lines.append(f'{indent}<{pf}value xsi:type="xs:boolean">false</{pf}value>')
    else:
        lines.append(f'{indent}<{pf}value xsi:nil="true"/>')


_DP_PERIOD_VARIANTS = {"Custom","Today","ThisWeek","ThisTenDays","ThisMonth","ThisQuarter","ThisHalfYear","ThisYear","FromBeginningOfThisWeek","FromBeginningOfThisTenDays","FromBeginningOfThisMonth","FromBeginningOfThisQuarter","FromBeginningOfThisHalfYear","FromBeginningOfThisYear","LastWeek","LastTenDays","LastMonth","LastQuarter","LastHalfYear","LastYear","NextDay","NextWeek","NextTenDays","NextMonth","NextQuarter","NextHalfYear","NextYear","TillEndOfThisWeek","TillEndOfThisTenDays","TillEndOfThisMonth","TillEndOfThisQuarter","TillEndOfThisHalfYear","TillEndOfThisYear"}


def parse_data_param_shorthand(s):
    result = {'parameter': '', 'value': None, 'use': True, 'userSettingID': None, 'viewMode': None}
    if '@user' in s:
        result['userSettingID'] = 'auto'; s = re.sub(r'\s*@user', '', s)
    if '@off' in s:
        result['use'] = False; s = re.sub(r'\s*@off', '', s)
    if '@quickAccess' in s:
        result['viewMode'] = 'QuickAccess'; s = re.sub(r'\s*@quickAccess', '', s)
    if '@normal' in s:
        result['viewMode'] = 'Normal'; s = re.sub(r'\s*@normal', '', s)
    s = s.strip()
    m = re.match(r'^([^=]+)=\s*(.+)$', s)
    if m:
        result['parameter'] = m.group(1).strip()
        val_str = m.group(2).strip()
        if val_str in _DP_PERIOD_VARIANTS:
            result['value'] = {'variant': val_str}
        elif re.match(r'^\d{4}-\d{2}-\d{2}T', val_str):
            result['value'] = val_str
        elif val_str in ('true', 'false'):
            result['value'] = (val_str == 'true')
        else:
            result['value'] = val_str
    else:
        result['parameter'] = s
    return result


def emit_data_parameters(lines, items, indent, block_view_mode=None):
    if not items or len(items) == 0:
        return
    lines.append(f'{indent}<dcsset:dataParameters>')
    for dp in items:
        if isinstance(dp, str):
            parsed = parse_data_param_shorthand(dp)
            dp = {'parameter': parsed['parameter']}
            if parsed['value'] is not None:
                dp['value'] = parsed['value']
            if parsed['use'] is False:
                dp['use'] = False
            if parsed['userSettingID']:
                dp['userSettingID'] = parsed['userSettingID']
            if parsed['viewMode']:
                dp['viewMode'] = parsed['viewMode']
        lines.append(f'{indent}\t<dcscor:item xsi:type="dcsset:SettingsParameterValue">')
        if dp.get('use') is False:
            lines.append(f'{indent}\t\t<dcscor:use>false</dcscor:use>')
        lines.append(f'{indent}\t\t<dcscor:parameter>{esc_xml_text(str(dp.get("parameter", "")))}</dcscor:parameter>')
        vtype = str(dp.get('valueType') or '')
        val = dp.get('value')
        if isinstance(val, list):
            # Список значений параметра (valueListAllowed) — отдельный <dcscor:value> на каждое.
            avtype = str(dp.get('valueType', ''))
            for v in val:
                v_str = ('true' if v else 'false') if isinstance(v, bool) else str(v)
                if re.match(r'^[a-zA-Z]+:', avtype):
                    lines.append(f'{indent}\t\t<dcscor:value xsi:type="{avtype}">{esc_xml_text(v_str)}</dcscor:value>')
                elif re.match(r'^(ПланСчетов|Справочник|Перечисление|Документ|ПланВидовХарактеристик|ПланВидовРасчета|БизнесПроцесс|Задача|РегистрСведений|ПланОбмена)\.', v_str) or re.match(r'^(ChartOfAccounts|Catalog|Enum|Document|ChartOfCharacteristicTypes|ChartOfCalculationTypes|BusinessProcess|Task|InformationRegister|ExchangePlan)\.', v_str):
                    lines.append(f'{indent}\t\t<dcscor:value xsi:type="dcscor:DesignTimeValue">{esc_xml_text(v_str)}</dcscor:value>')
                else:
                    lines.append(f'{indent}\t\t<dcscor:value xsi:type="xs:string">{esc_xml_text(v_str)}</dcscor:value>')
        elif dp.get('nilValue') is True:
            lines.append(f'{indent}\t\t<dcscor:value xsi:nil="true"/>')
        elif _test_empty_value(val) and vtype:
            emit_empty_value(lines, vtype, f'{indent}\t\t', tag_prefix='dcscor:', value_list_allowed=False)
        elif _test_empty_value(val):
            pass  # нет значения → не эмитим value-узел (form дин-список: use=false плейсхолдер)
        elif val is not None:
            if isinstance(val, dict) and val.get('variant'):
                variant = str(val.get('variant'))
                has_date = 'date' in val
                has_sd = 'startDate' in val
                is_sbd = has_date or (not has_sd and variant.startswith('BeginningOf'))
                if is_sbd:
                    lines.append(f'{indent}\t\t<dcscor:value xsi:type="v8:StandardBeginningDate">')
                    lines.append(f'{indent}\t\t\t<v8:variant xsi:type="v8:StandardBeginningDateVariant">{esc_xml_text(variant)}</v8:variant>')
                    if variant == 'Custom':
                        d = str(val.get('date') or '0001-01-01T00:00:00')
                        lines.append(f'{indent}\t\t\t<v8:date>{esc_xml_text(d)}</v8:date>')
                    lines.append(f'{indent}\t\t</dcscor:value>')
                else:
                    lines.append(f'{indent}\t\t<dcscor:value xsi:type="v8:StandardPeriod">')
                    lines.append(f'{indent}\t\t\t<v8:variant xsi:type="v8:StandardPeriodVariant">{esc_xml_text(variant)}</v8:variant>')
                    if variant == 'Custom':
                        sd = str(val.get('startDate') or '0001-01-01T00:00:00')
                        ed = str(val.get('endDate') or '0001-01-01T00:00:00')
                        lines.append(f'{indent}\t\t\t<v8:startDate>{esc_xml_text(sd)}</v8:startDate>')
                        lines.append(f'{indent}\t\t\t<v8:endDate>{esc_xml_text(ed)}</v8:endDate>')
                    lines.append(f'{indent}\t\t</dcscor:value>')
            elif re.match(r'^[a-zA-Z]+:', vtype):
                v_str = str(val).lower() if isinstance(val, bool) else str(val)
                lines.append(f'{indent}\t\t<dcscor:value xsi:type="{vtype}">{esc_xml_text(v_str)}</dcscor:value>')
            elif vtype == 'boolean' or isinstance(val, bool):
                lines.append(f'{indent}\t\t<dcscor:value xsi:type="xs:boolean">{esc_xml_text(str(val).lower())}</dcscor:value>')
            elif re.match(r'^date', vtype) or re.match(r'^\d{4}-\d{2}-\d{2}T', str(val)):
                lines.append(f'{indent}\t\t<dcscor:value xsi:type="xs:dateTime">{esc_xml_text(str(val))}</dcscor:value>')
            elif re.match(r'^decimal', vtype):
                lines.append(f'{indent}\t\t<dcscor:value xsi:type="xs:decimal">{esc_xml_text(str(val))}</dcscor:value>')
            elif re.match(r'^string', vtype):
                lines.append(f'{indent}\t\t<dcscor:value xsi:type="xs:string">{esc_xml_text(str(val))}</dcscor:value>')
            elif re.match(r'^(ПланСчетов|Справочник|Перечисление|Документ|ПланВидовХарактеристик|ПланВидовРасчета|БизнесПроцесс|Задача|РегистрСведений|ПланОбмена)\.', str(val)) or re.match(r'^(ChartOfAccounts|Catalog|Enum|Document|ChartOfCharacteristicTypes|ChartOfCalculationTypes|BusinessProcess|Task|InformationRegister|ExchangePlan)\.', str(val)):
                lines.append(f'{indent}\t\t<dcscor:value xsi:type="dcscor:DesignTimeValue">{esc_xml_text(str(val))}</dcscor:value>')
            else:
                lines.append(f'{indent}\t\t<dcscor:value xsi:type="xs:string">{esc_xml_text(str(val))}</dcscor:value>')
        if dp.get('viewMode'):
            lines.append(f'{indent}\t\t<dcsset:viewMode>{esc_xml_text(str(dp["viewMode"]))}</dcsset:viewMode>')
        if dp.get('userSettingID'):
            uid = new_uuid() if str(dp['userSettingID']) == 'auto' else str(dp['userSettingID'])
            lines.append(f'{indent}\t\t<dcsset:userSettingID>{esc_xml_text(uid)}</dcsset:userSettingID>')
        if dp.get('userSettingPresentation'):
            emit_us_presentation(lines, f'{indent}\t\t', 'dcsset:userSettingPresentation', dp['userSettingPresentation'])
        lines.append(f'{indent}\t</dcscor:item>')
    if block_view_mode is not None:
        lines.append(f'{indent}\t<dcsset:viewMode>{esc_xml_text(str(block_view_mode))}</dcsset:viewMode>')
    lines.append(f'{indent}</dcsset:dataParameters>')


def emit_dl_parameter(lines, p, parsed, indent):
    is_obj = not isinstance(p, str)
    lines.append(f'{indent}<Parameter>')
    ci = f'{indent}\t'
    lines.append(f'{ci}<dcssch:name>{esc_xml_text(parsed["name"])}</dcssch:name>')
    # Title: явный override (shorthand [..] / объект title/presentation) или авто из имени.
    title = None
    if parsed.get('title'):
        title = parsed['title']
    elif is_obj and p.get('title'):
        title = p['title']
    elif is_obj and p.get('presentation'):
        title = p['presentation']
    if title is None or (isinstance(title, str) and title == ''):
        title = title_from_name(parsed['name'])
    emit_dl_mltext(lines, ci, 'dcssch:title', title)
    # valueType
    if parsed.get('type'):
        emit_dl_value_type(lines, parsed['type'], ci)
    # value (дефолт nil; при valueListAllowed пустое — опускаем)
    vla = bool(parsed.get('valueListAllowed'))
    pv = parsed.get('value')
    if isinstance(pv, list):
        for v in pv:
            emit_dl_value(lines, parsed.get('type', ''), v, ci, False)
    elif parsed.get('value_explicit') and pv is not None and str(pv) == '' and (str(parsed.get('type', '')) == '' or re.match(r'^string', str(parsed.get('type', '')))):
        # Явный пустой СТРОКОВЫЙ параметр (value:"" от декомпилятора) → типизированный пустой
        # <dcssch:value xsi:type="xs:string"/>, НЕ nil. Решается ФОРМОЙ value (""→typed-empty,
        # null/отсутствие→nil), независимо от valueListAllowed; декомпилятор различает ""/null.
        # Корпус: 26 xs:string typed-empty.
        lines.append(f'{ci}<dcssch:value xsi:type="xs:string"/>')
    elif vla and is_dl_empty_value(pv) and parsed.get('value_explicit'):
        # valueListAllowed + явный пустой (value:null от декомпилятора) → платформа пишет nil
        lines.append(f'{ci}<dcssch:value xsi:nil="true"/>')
    else:
        emit_dl_value(lines, parsed.get('type', ''), pv, ci, vla)
    # useRestriction — ВСЕГДА; дефолт true; false только при явном useRestriction:false.
    ur = True
    if is_obj and 'useRestriction' in p:
        ur = bool(p['useRestriction'])
    lines.append(f'{ci}<dcssch:useRestriction>{"true" if ur else "false"}</dcssch:useRestriction>')
    # expression
    expr = str(p['expression']) if (is_obj and p.get('expression')) else None
    if expr:
        lines.append(f'{ci}<dcssch:expression>{esc_xml_text(expr)}</dcssch:expression>')
    # availableValues
    if is_obj and p.get('availableValues'):
        for av in p['availableValues']:
            emit_dl_available_value(lines, av, parsed.get('type', ''), ci)
    # valueListAllowed
    if vla:
        lines.append(f'{ci}<dcssch:valueListAllowed>true</dcssch:valueListAllowed>')
    # availableAsField=false (hidden или явный)
    aaf = None
    if parsed.get('hidden') is True:
        aaf = False
    if is_obj and 'availableAsField' in p:
        aaf = bool(p['availableAsField'])
    if aaf is False:
        lines.append(f'{ci}<dcssch:availableAsField>false</dcssch:availableAsField>')
    # inputParameters
    if is_obj and p.get('inputParameters'):
        emit_dl_input_parameters(lines, p['inputParameters'], ci)
    # denyIncompleteValues
    if is_obj and p.get('denyIncompleteValues') is True:
        lines.append(f'{ci}<dcssch:denyIncompleteValues>true</dcssch:denyIncompleteValues>')
    # use
    if is_obj and p.get('use'):
        lines.append(f'{ci}<dcssch:use>{esc_xml_text(str(p["use"]))}</dcssch:use>')
    lines.append(f'{indent}</Parameter>')


def emit_dl_parameters(lines, params, indent):
    if not params:
        return
    for p in params:
        if isinstance(p, str):
            parsed = parse_dl_param_shorthand(p)
        else:
            resolved_type = ''
            if p.get('type'):
                if isinstance(p['type'], list):
                    resolved_type = ' | '.join(resolve_type_str(str(x)) for x in p['type'])
                else:
                    resolved_type = resolve_type_str(str(p['type']))
            elif p.get('valueType'):
                resolved_type = resolve_type_str(str(p['valueType']))
            parsed = {'name': str(p.get('name', '')), 'type': resolved_type,
                      'value': p.get('value') if 'value' in p else None,
                      'value_explicit': ('value' in p), 'title': None}
            if p.get('valueListAllowed') is True:
                parsed['valueListAllowed'] = True
            if p.get('hidden') is True:
                parsed['hidden'] = True
        emit_dl_parameter(lines, p, parsed, indent)


def emit_attributes(lines, attrs, indent, conditional_appearance=None):
    has_ca = bool(conditional_appearance) and len(conditional_appearance) > 0
    # Платформа ВСЕГДА эмитит <Attributes> (100% корпуса; 162 формы — пустой <Attributes/>).
    if (not attrs or len(attrs) == 0) and not has_ca:
        lines.append(f'{indent}<Attributes/>')
        return
    if not attrs or len(attrs) == 0:
        # Нет реквизитов, но есть условное оформление (последний child <Attributes>)
        lines.append(f'{indent}<Attributes>')
        emit_conditional_appearance(lines, conditional_appearance, f'{indent}\t', wrap_tag='ConditionalAppearance')
        lines.append(f'{indent}</Attributes>')
        return

    lines.append(f'{indent}<Attributes>')
    seen_attrs = set()
    for attr in attrs:
        attr_id = new_id()
        attr_name = str(attr['name'])
        _ensure_unique(attr_name, seen_attrs, 'attribute')

        lines.append(f'{indent}\t<Attribute name="{attr_name}" id="{attr_id}">')
        inner = f'{indent}\t\t'

        # Title атрибута (зеркало emit_title): нет ключа → авто-вывод из имени (кроме main);
        # title "" → подавить; непустой → эмитить как есть.
        if 'title' in attr:
            if attr.get('title'):
                emit_mltext(lines, inner, 'Title', attr['title'])
        elif attr.get('main') is not True:
            emit_mltext(lines, inner, 'Title', title_from_name(attr_name))

        # Type
        if attr.get('type'):
            emit_type(lines, str(attr['type']), inner)
        else:
            lines.append(f'{inner}<Type/>')
        # valueType: ОписаниеТипов значений ValueList → <Settings xsi:type="v8:TypeDescription">
        # (та же грамматика типа, включая составной "A | B"). Forgiving-синонимы.
        # Три состояния: нет ключа → нет Settings; "" → пустой <Settings…/>; тип → с типом.
        vt_spec = None
        has_vt = False
        for k in ('valueType', 'typeDescription', 'описаниеТипов', 'типЗначений'):
            if k in attr:
                vt_spec = attr[k]
                has_vt = True
                break
        if has_vt:
            emit_type(lines, '' if vt_spec is None else str(vt_spec), inner, tag="Settings", tag_attrs=' xsi:type="v8:TypeDescription"')
        # Planner design-time <Settings xsi:type="pl:Planner"> (встроенный конфиг планировщика).
        if attr.get('planner') is not None:
            emit_planner_settings(lines, attr['planner'], inner)
        # Chart/GanttChart design-time <Settings> (тип выводится из типа реквизита).
        if attr.get('chart') is not None:
            ctype = 'd4p1:GanttChart' if 'GanttChart' in str(attr.get('type', '')) else 'd4p1:Chart'
            emit_chart_settings(lines, attr['chart'], inner, ctype)

        if attr.get('main') is True:
            lines.append(f'{inner}<MainAttribute>true</MainAttribute>')
        # Доступ по ролям: просмотр/редактирование (порядок схемы: View → Edit, после MainAttribute)
        if attr.get('view') is not None:
            emit_xr_flag(lines, 'View', attr.get('view'), inner)
        if attr.get('edit') is not None:
            emit_xr_flag(lines, 'Edit', attr.get('edit'), inner)
        main_saved = False
        if attr.get('main') is True and attr.get('type'):
            t = str(attr['type'])
            main_saved = bool(re.match(r'^(CatalogObject|DocumentObject|ChartOfAccountsObject|ChartOfCalculationTypesObject|ChartOfCharacteristicTypesObject|ExchangePlanObject|BusinessProcessObject|TaskObject)\.', t)) or ('RecordManager.' in t)
        # Явный ключ savedData побеждает (в т.ч. False → суппресс авто-вывода main_saved); нет ключа → авто.
        emit_saved = (attr['savedData'] is True) if 'savedData' in attr else main_saved
        if emit_saved:
            lines.append(f'{inner}<SavedData>true</SavedData>')
        # Save: сохранение значения реквизита в пользовательских настройках. true → <Field>имя</Field>;
        # строка/массив → под-поля с авто-префиксом "имя." (путь с точкой / UUID / =имя — как есть).
        # Нет ключа или false → не эмитим.
        if 'save' in attr and attr['save'] is not None:
            save_fields = []
            sv = attr['save']
            if isinstance(sv, bool):
                if sv:
                    save_fields.append(attr_name)
            else:
                for e in (sv if isinstance(sv, (list, tuple)) else [sv]):
                    fld = str(e)
                    if not fld:
                        continue
                    if fld != attr_name and '.' not in fld and not re.match(r'^\d+/\d+', fld):
                        fld = f'{attr_name}.{fld}'
                    if fld not in save_fields:
                        save_fields.append(fld)
            if save_fields:
                lines.append(f'{inner}<Save>')
                for f in save_fields:
                    lines.append(f'{inner}\t<Field>{esc_xml_text(f)}</Field>')
                lines.append(f'{inner}</Save>')
        # Проверка заполнения → <FillCheck> (реальный тег; <FillChecking> в схеме нет).
        # bool true → ShowError; строка → verbatim. Синоним fillChecking.
        fc_raw = attr['fillCheck'] if 'fillCheck' in attr else attr.get('fillChecking')
        if fc_raw:
            fcv = 'ShowError' if isinstance(fc_raw, bool) else str(fc_raw)
            lines.append(f'{inner}<FillCheck>{fcv}</FillCheck>')

        # UseAlways: поля, всегда читаемые. Две формы DSL сливаются:
        #  attr.useAlways[] (короткие имена) + columns с useAlways:true → <Field>ИмяРеквизита.Поле</Field>.
        ua_fields = []
        for e in (attr.get('useAlways') or []):
            fld = str(e)
            # Префикс "ИмяРеквизита." добавляем к коротким именам. Поля дин-списка с маркером "~"
            # (query-поля, ~13% корпуса) — префикс ставится ПОСЛЕ "~": ~Остановлен → ~Список.Остановлен.
            # Полная форма (~Список.Остановлен / Список.Остановлен) — verbatim (forgiving ввод).
            if fld.startswith('~'):
                bare = fld[1:]
                if not re.match(r'^' + re.escape(attr_name) + r'\.', bare):
                    bare = f'{attr_name}.{bare}'
                fld = f'~{bare}'
            elif not re.match(r'^' + re.escape(attr_name) + r'\.', fld) and not re.match(r'^\d+/\d+', fld):
                # UUID-ссылка (1/0:GUID) — НЕ префиксуем (платформа хранит её без "имя.")
                fld = f'{attr_name}.{fld}'
            if fld not in ua_fields:
                ua_fields.append(fld)
        for col in (attr.get('columns') or []):
            if col.get('useAlways') is True:
                fld = f'{attr_name}.{col["name"]}'
                if fld not in ua_fields:
                    ua_fields.append(fld)
        if ua_fields:
            lines.append(f'{inner}<UseAlways>')
            for f in ua_fields:
                lines.append(f'{inner}\t<Field>{f}</Field>')
            lines.append(f'{inner}</UseAlways>')

        emit_functional_options(lines, attr.get('functionalOptions'), inner)

        # Columns: прямые <Column> + <AdditionalColumns table="X"> (доп. колонки табличных частей объекта).
        # Прямые сначала, затем AdditionalColumns-группы. Для дин-списка (settings) прямые НЕ эмитим.
        has_direct_cols = bool(attr.get('columns')) and len(attr['columns']) > 0 and not attr.get('settings')
        has_add_cols = bool(attr.get('additionalColumns')) and len(attr['additionalColumns']) > 0
        if has_direct_cols or has_add_cols:
            lines.append(f'{inner}<Columns>')
            if has_direct_cols:
                seen_cols = set()  # колонки уникальны в пределах своего реквизита
                for col in attr['columns']:
                    _ensure_unique(str(col['name']), seen_cols, f"column of '{attr_name}'")
                    emit_attr_column(lines, col, f'{inner}\t')
            if has_add_cols:
                for ac in attr['additionalColumns']:
                    # Пустой список колонок задаётся ЯВНО (`"columns": []`) — это законная форма,
                    # платформа так пишет таблицу, у которой доп. колонок нет. А вот отсутствие ключа
                    # — недосказанность автора: «доп. колонки есть», а какие, не указано. PS-порт на
                    # этом падал с «Не удается индексировать в массив NULL» (@($null).Count = 1).
                    if ac.get('columns') is None:
                        print(f"additionalColumns group for table '{ac['table']}': key 'columns' is missing "
                              "— list the columns, or pass an empty array for a table without extra columns",
                              file=sys.stderr)
                        sys.exit(1)
                    ac_cols = ac['columns']
                    if not ac_cols:
                        # Явно пустая группа → self-closing (как платформа)
                        lines.append(f'{inner}\t<AdditionalColumns table="{ac["table"]}"/>')
                        continue
                    lines.append(f'{inner}\t<AdditionalColumns table="{ac["table"]}">')
                    seen_ac_cols = set()  # уникальность в пределах группы AdditionalColumns
                    for col in ac_cols:
                        _ensure_unique(str(col['name']), seen_ac_cols, f"column of '{attr_name}'")
                        emit_attr_column(lines, col, f'{inner}\t\t')
                    lines.append(f'{inner}\t</AdditionalColumns>')
            lines.append(f'{inner}</Columns>')

        # Settings (динамический список)
        if attr.get('settings'):
            s = attr['settings']
            lines.append(f'{inner}<Settings xsi:type="DynamicList">')
            si = f'{inner}\t'
            # Порядок платформы: AutoFillAvailableFields, ManualQuery, DynamicDataRead, QueryText, Field*, MainTable, ListSettings
            # AutoFillAvailableFields — дефолт true; эмитим только при заданном ключе (отклонение).
            if s.get('autoFillAvailableFields') is not None:
                lines.append(f'{si}<AutoFillAvailableFields>{"true" if s["autoFillAvailableFields"] else "false"}</AutoFillAvailableFields>')
            # Порядок платформы: ManualQuery, DynamicDataRead, QueryText, Field*, MainTable, ListSettings
            has_query = bool(s.get('query') and str(s['query']).strip())
            # Явный ключ manualQuery (в т.ч. False) ПОБЕЖДАЕТ эвристику has_query (платформа изредка
            # хранит QueryText при ManualQuery=false — декомпилятор фиксирует отклонение).
            if s.get('manualQuery') is not None:
                mq = 'true' if s['manualQuery'] else 'false'
            else:
                mq = 'true' if has_query else 'false'
            lines.append(f'{si}<ManualQuery>{mq}</ManualQuery>')
            # DynamicDataRead: дефолт true; false только при явном отключении
            ddr = 'false' if s.get('dynamicDataRead') is False else 'true'
            lines.append(f'{si}<DynamicDataRead>{ddr}</DynamicDataRead>')
            if has_query:
                qtext = resolve_query_value(str(s['query']), QUERY_BASE_DIR)
                lines.append(f'{si}<QueryText>{esc_xml_text(qtext)}</QueryText>')
            # Явные поля набора (редко): override title/dataPath
            if s.get('fields'):
                for fld in s['fields']:
                    # Тип поля набора: DataSetFieldField (дефолт) vs DataSetFieldNestedDataSet
                    # (поле-вложенный набор = реквизит табличной части; маркер nested).
                    # folder = папка-группировка полей (DataSetFieldFolder, без <field>); nested = вложенный набор.
                    is_folder = bool(fld.get('folder'))
                    ftype = 'DataSetFieldNestedDataSet' if fld.get('nested') else ('DataSetFieldFolder' if is_folder else 'DataSetFieldField')
                    lines.append(f'{si}<Field xsi:type="dcssch:{ftype}">')
                    # dataPath: явный (включая "" → self-closing) побеждает; иначе fallback на field.
                    if fld.get('dataPath') is not None:
                        dp = str(fld.get('dataPath'))
                    elif is_folder:
                        dp = ''
                    else:
                        dp = str(fld.get('field', ''))
                    if dp == '':
                        lines.append(f'{si}\t<dcssch:dataPath/>')
                    else:
                        lines.append(f'{si}\t<dcssch:dataPath>{esc_xml_text(dp)}</dcssch:dataPath>')
                    if not is_folder:
                        lines.append(f'{si}\t<dcssch:field>{esc_xml_text(str(fld.get("field", "")))}</dcssch:field>')
                    if fld.get('title'):
                        lines.append(f'{si}\t<dcssch:title xsi:type="v8:LocalStringType">')
                        emit_ml_items(lines, f'{si}\t\t', fld['title'])
                        lines.append(f'{si}\t</dcssch:title>')
                    # Ограничения использования поля — после title, перед presentationExpression
                    emit_restrict_block(lines, 'useRestriction', fld.get('useRestriction'), f'{si}\t')
                    emit_restrict_block(lines, 'attributeUseRestriction', fld.get('attributeUseRestriction'), f'{si}\t')
                    # presentationExpression поля — перед valueType (порядок исходника)
                    if fld.get('presentationExpression'):
                        lines.append(f'{si}\t<dcssch:presentationExpression>{esc_xml_text(str(fld["presentationExpression"]))}</dcssch:presentationExpression>')
                    # valueType поля набора (тип значения; вычисляемые/кастомные поля)
                    if fld.get('valueType'):
                        emit_dl_value_type(lines, fld['valueType'], f'{si}\t')
                    # appearance поля (формат/оформление) — после valueType (порядок исходника)
                    if fld.get('appearance'):
                        lines.append(f'{si}\t<dcssch:appearance>')
                        for ak, av in fld['appearance'].items():
                            emit_appearance_value(lines, ak, av, f'{si}\t\t')
                        lines.append(f'{si}\t</dcssch:appearance>')
                    # inputParameters поля (связь по параметрам выбора) — в конце
                    if fld.get('inputParameters'):
                        emit_dl_input_parameters(lines, fld['inputParameters'], f'{si}\t')
                    lines.append(f'{si}</Field>')
            # Вычисляемые поля DataSet (<CalculatedField>) — после Field*, до Parameter*.
            emit_calc_fields(lines, s.get('calculatedFields'), si)
            # Schema-параметры дин-списка (DataCompositionSchemaParameter) — после Field*, до MainTable.
            emit_dl_parameters(lines, s.get('parameters'), si)
            # Ключ набора (query-based список без MainTable): KeyType (RowNumber/FieldValue/RowKey)
            # + KeyField* — после Parameter*, до MainTable. Захват/эмит факт. значений.
            if s.get('keyType'):
                lines.append(f'{si}<KeyType>{esc_xml_text(str(s["keyType"]))}</KeyType>')
            if s.get('keyFields'):
                for kf in s['keyFields']:
                    lines.append(f'{si}<KeyField>{esc_xml_text(str(kf))}</KeyField>')
            if s.get('mainTable'):
                lines.append(f'{si}<MainTable>{normalize_meta_type_ref(str(s["mainTable"]))}</MainTable>')
            # GetInvisibleFieldPresentations — после MainTable (дефолт true; эмитим только при заданном ключе = отклонении false).
            if s.get('getInvisibleFieldPresentations') is not None:
                lines.append(f'{si}<GetInvisibleFieldPresentations>{"true" if s["getInvisibleFieldPresentations"] else "false"}</GetInvisibleFieldPresentations>')
            # AutoSaveUserSettings — после MainTable (дефолт true; эмитим только при заданном ключе = отклонении).
            if s.get('autoSaveUserSettings') is not None:
                lines.append(f'{si}<AutoSaveUserSettings>{"true" if s["autoSaveUserSettings"] else "false"}</AutoSaveUserSettings>')
            # ListSettings: filter/order/conditionalAppearance (skd-грамматика) + каноничные блок-GUID.
            # Нет items → контейнеры всё равно эмитятся (blockMeta) = каноничный пустой скелет платформы.
            lsi = f'{si}\t'
            lines.append(f'{si}<ListSettings>')
            ls_open_idx = len(lines) - 1  # для self-closing, если внутри ничего не эмитнётся
            ls_shape = s.get('listSettings')
            if ls_shape is not None:
                # Частичная/минимальная форма скелета — эмитим ТОЛЬКО указанные части с их блок-метой.
                for tag, pv in ls_shape.items():
                    # Значение дескриптора: строка-код "vu" ИЛИ объект {meta, presentation}
                    # (контейнер несёт собственный userSettingPresentation — подпись настройки).
                    if isinstance(pv, dict):
                        meta = str(pv.get('meta', '')); bpres = pv.get('presentation')
                    else:
                        meta = str(pv); bpres = None
                    bvm = 'Normal' if 'v' in meta else None
                    if tag == 'filter':
                        bus = CANON_FILTER_ID if 'u' in meta else None
                        emit_filter(lines, s.get('filter'), lsi, block_view_mode=bvm, block_user_setting_id=bus, block_user_setting_presentation=bpres)
                    elif tag == 'order':
                        bus = CANON_ORDER_ID if 'u' in meta else None
                        emit_order(lines, s.get('order'), lsi, block_view_mode=bvm, block_user_setting_id=bus, block_user_setting_presentation=bpres)
                    elif tag == 'conditionalAppearance':
                        bus = CANON_CA_ID if 'u' in meta else None
                        emit_conditional_appearance(lines, s.get('conditionalAppearance'), lsi, block_view_mode=bvm, block_user_setting_id=bus, block_user_setting_presentation=bpres)
                    elif tag == 'itemsViewMode':
                        lines.append(f'{lsi}<dcsset:itemsViewMode>Normal</dcsset:itemsViewMode>')
                    elif tag == 'itemsUserSettingID':
                        lines.append(f'{lsi}<dcsset:itemsUserSettingID>{CANON_ITEMS_ID}</dcsset:itemsUserSettingID>')
                    elif tag == 'itemsUserSettingPresentation':
                        emit_us_presentation(lines, lsi, 'dcsset:itemsUserSettingPresentation', pv)
                    elif tag == 'dataParameters':
                        emit_data_parameters(lines, s.get('dataParameters'), lsi)
                    elif tag == 'structure':
                        emit_list_grouping(lines, get_list_grouping_value(s), lsi)
            else:
                # Полный каноничный скелет (умолчание, ~93% форм) — без изменений.
                emit_filter(lines, s.get('filter'), lsi, block_view_mode='Normal', block_user_setting_id=CANON_FILTER_ID)
                # dataParameters — после filter, до order (XSD-порядок ListSettings)
                if 'dataParameters' in s:
                    emit_data_parameters(lines, s.get('dataParameters'), lsi)
                emit_order(lines, s.get('order'), lsi, block_view_mode='Normal', block_user_setting_id=CANON_ORDER_ID)
                emit_conditional_appearance(lines, s.get('conditionalAppearance'), lsi, block_view_mode='Normal', block_user_setting_id=CANON_CA_ID)
                # Группировка строк списка (авторинг без round-trip дескриптора) — после CA, до itemsViewMode
                emit_list_grouping(lines, get_list_grouping_value(s), lsi)
                lines.append(f'{lsi}<dcsset:itemsViewMode>Normal</dcsset:itemsViewMode>')
                lines.append(f'{lsi}<dcsset:itemsUserSettingID>{CANON_ITEMS_ID}</dcsset:itemsUserSettingID>')
            if len(lines) - 1 == ls_open_idx:
                # Пустой дескриптор listSettings:{} (оригинал = <ListSettings/>) → зеркалим self-closing.
                lines[ls_open_idx] = f'{si}<ListSettings/>'
            else:
                lines.append(f'{si}</ListSettings>')
            lines.append(f'{inner}</Settings>')

        lines.append(f'{indent}\t</Attribute>')
    # Условное оформление формы — последний child <Attributes> (та же DCS-грамматика, что settings CA)
    emit_conditional_appearance(lines, conditional_appearance, f'{indent}\t', wrap_tag='ConditionalAppearance')
    lines.append(f'{indent}</Attributes>')


# --- Parameter emitter ---

def emit_parameters(lines, params, indent):
    if not params or len(params) == 0:
        return

    lines.append(f'{indent}<Parameters>')
    seen_params = set()
    for param in params:
        _ensure_unique(str(param['name']), seen_params, 'parameter')
        lines.append(f'{indent}\t<Parameter name="{param["name"]}">')
        inner = f'{indent}\t\t'

        emit_type(lines, str(param.get('type', '')), inner)

        if param.get('key') is True:
            lines.append(f'{inner}<KeyParameter>true</KeyParameter>')

        lines.append(f'{indent}\t</Parameter>')
    lines.append(f'{indent}</Parameters>')


# --- Command emitter ---

def emit_commands(lines, cmds, indent):
    if not cmds or len(cmds) == 0:
        return

    lines.append(f'{indent}<Commands>')
    seen_cmds = set()
    for cmd in cmds:
        cmd_id = new_id()
        _ensure_unique(str(cmd['name']), seen_cmds, 'command')
        lines.append(f'{indent}\t<Command name="{cmd["name"]}" id="{cmd_id}">')
        inner = f'{indent}\t\t'

        # Заголовок команды (зеркало emit_title): ключ есть+непустой → эмитим; ключ есть+"" → суппресс
        # (в оригинале <Title> нет — не додумывать); ключ отсутствует → авто-вывод из имени.
        if 'title' in cmd:
            if cmd['title']:
                emit_mltext(lines, inner, 'Title', cmd['title'])
        else:
            cmd_title = title_from_name(str(cmd['name']))
            if cmd_title:
                emit_mltext(lines, inner, 'Title', cmd_title)

        if cmd.get('tooltip'):
            emit_mltext(lines, inner, 'ToolTip', cmd['tooltip'])

        # Доступность команды по ролям (после ToolTip, до Action)
        if cmd.get('use') is not None:
            emit_xr_flag(lines, 'Use', cmd.get('use'), inner)

        if cmd.get('action'):
            lines.append(f'{inner}<Action>{cmd["action"]}</Action>')

        if cmd.get('modifiesSavedData') is True:
            lines.append(f'{inner}<ModifiesSavedData>true</ModifiesSavedData>')

        emit_functional_options(lines, cmd.get('functionalOptions'), inner)

        if cmd.get('currentRowUse'):
            lines.append(f'{inner}<CurrentRowUse>{cmd["currentRowUse"]}</CurrentRowUse>')

        # Используемая таблица — имя элемента-таблицы (xsi:type обязателен).
        # Forgiving-ключи: table / associatedTableElementId (XML-тег) / ИспользуемаяТаблица (рус., регистр-незав.)
        _cmd_norm = {k.replace(' ', '').lower(): v for k, v in cmd.items()}
        cmd_table = (_cmd_norm.get('table') or _cmd_norm.get('associatedtableelementid')
                     or _cmd_norm.get('используемаятаблица'))
        if cmd_table:
            lines.append(f'{inner}<AssociatedTableElementId xsi:type="xs:string">{esc_xml_text(str(cmd_table))}</AssociatedTableElementId>')

        if cmd.get('shortcut'):
            lines.append(f'{inner}<Shortcut>{cmd["shortcut"]}</Shortcut>')

        emit_command_picture(lines, cmd.get('picture'), cmd.get('loadTransparent'), inner)

        if cmd.get('representation'):
            lines.append(f'{inner}<Representation>{cmd["representation"]}</Representation>')

        lines.append(f'{indent}\t</Command>')
    lines.append(f'{indent}</Commands>')


# Командный интерфейс формы (<CommandInterface>): панели CommandBar + NavigationPanel.
# Элемент: строка (голый command, Type=Auto) или dict. Порядок тегов:
# Command, Type(деф. Auto), Attribute, CommandGroup, Index, DefaultVisible, Visible(xr-flag).
def _resolve_command_group_key(key, panel_tag):
    """Ключ-группа древовидной формы → CommandGroup (зависит от панели); иначе verbatim."""
    k = re.sub(r'\s', '', str(key)).lower()
    if panel_tag == 'NavigationPanel':
        m = {'important': 'FormNavigationPanelImportant', 'важное': 'FormNavigationPanelImportant',
             'goto': 'FormNavigationPanelGoTo', 'перейти': 'FormNavigationPanelGoTo',
             'seealso': 'FormNavigationPanelSeeAlso', 'смтакже': 'FormNavigationPanelSeeAlso'}
    else:
        m = {'important': 'FormCommandBarImportant', 'важное': 'FormCommandBarImportant',
             'createbasedon': 'FormCommandBarCreateBasedOn', 'создатьнаосновании': 'FormCommandBarCreateBasedOn'}
    return m.get(k, key)


def emit_command_interface(lines, ci, indent):
    if not ci:
        return
    inner = f'{indent}\t'
    panels = [
        ('CommandBar', ('commandBar', 'команднаяПанель', 'КоманднаяПанель')),
        ('NavigationPanel', ('navigationPanel', 'панельНавигации', 'ПанельНавигации')),
    ]
    present = []
    for tag, syns in panels:
        items = None
        for syn in syns:
            if isinstance(ci, dict) and syn in ci:
                items = ci[syn]
                break
        if items is not None:
            present.append((tag, items))
    if not present:
        return
    lines.append(f'{indent}<CommandInterface>')
    for tag, items in present:
        lines.append(f'{inner}<{tag}>')
        # Нормализация: плоский список пар (элемент, group-из-дерева). dict → древовидная форма.
        flat = []
        if isinstance(items, dict):
            for gkey, gitems in items.items():
                grp_tree = _resolve_command_group_key(gkey, tag)
                for it in gitems:
                    flat.append((it, grp_tree))
        else:
            for it in items:
                flat.append((it, None))
        for item, tree_group in flat:
            if isinstance(item, str):
                cmd, typ, attr, grp, idx, dv, vis = item, 'Auto', None, None, None, None, None
            else:
                cmd = get_el_prop(item, ('command', 'команда'))
                typ = get_el_prop(item, ('type', 'тип')) or 'Auto'
                attr = get_el_prop(item, ('attribute', 'реквизит'))
                grp = get_el_prop(item, ('group', 'группа', 'группаКоманд'))
                idx = get_el_prop(item, ('index', 'индекс'))
                dv = get_el_prop(item, ('defaultVisible', 'видимость', 'видимостьПоУмолчанию'))
                vis = get_el_prop(item, ('visible', 'видимостьПоРолям', 'настройкаВидимости'))
            # group из дерева побеждает (если задан и непустой); явный group элемента — фолбэк
            if tree_group:
                grp = tree_group
            lines.append(f'{inner}\t<Item>')
            lines.append(f'{inner}\t\t<Command>{esc_xml_text(str(cmd))}</Command>')
            lines.append(f'{inner}\t\t<Type>{typ}</Type>')
            if attr:
                lines.append(f'{inner}\t\t<Attribute>{esc_xml_text(str(attr))}</Attribute>')
            if grp:
                lines.append(f'{inner}\t\t<CommandGroup>{esc_xml_text(str(grp))}</CommandGroup>')
            if idx is not None:
                lines.append(f'{inner}\t\t<Index>{idx}</Index>')
            if dv is not None:
                lines.append(f'{inner}\t\t<DefaultVisible>{"true" if dv else "false"}</DefaultVisible>')
            if vis is not None:
                emit_xr_flag(lines, 'Visible', vis, f'{inner}\t\t')
            lines.append(f'{inner}\t</Item>')
        lines.append(f'{inner}</{tag}>')
    lines.append(f'{indent}</CommandInterface>')


# --- Properties emitter ---

PROP_MAP = {
    "autoTitle": "AutoTitle",
    "windowOpeningMode": "WindowOpeningMode",
    "commandBarLocation": "CommandBarLocation",
    "saveDataInSettings": "SaveDataInSettings",
    "autoSaveDataInSettings": "AutoSaveDataInSettings",
    "autoTime": "AutoTime",
    "usePostingMode": "UsePostingMode",
    "repostOnWrite": "RepostOnWrite",
    "autoURL": "AutoURL",
    "autoFillCheck": "AutoFillCheck",
    "customizable": "Customizable",
    "enterKeyBehavior": "EnterKeyBehavior",
    "verticalScroll": "VerticalScroll",
    "scalingMode": "ScalingMode",
    "useForFoldersAndItems": "UseForFoldersAndItems",
    "reportResult": "ReportResult",
    "detailsData": "DetailsData",
    "reportFormType": "ReportFormType",
    "autoShowState": "AutoShowState",
    "width": "Width",
    "height": "Height",
    "group": "Group",
}


def emit_properties(lines, props, indent):
    if not props:
        return

    for p_name, p_value in props.items():
        xml_name = PROP_MAP.get(p_name)
        if not xml_name:
            # Auto PascalCase
            xml_name = p_name[0].upper() + p_name[1:]

        # Пустая строка = суппресс-маркер (напр. autoTitle:"" — не эмитить и не додумывать)
        if isinstance(p_value, str) and p_value == '':
            continue
        # Convert boolean to lowercase
        if isinstance(p_value, bool):
            val = 'true' if p_value else 'false'
        else:
            val = str(p_value)
        lines.append(f'{indent}<{xml_name}>{val}</{xml_name}>')



def detect_format_version(d):
    while d:
        # Автономная внешняя обработка/отчёт: своего Configuration.xml у неё нет, версию несёт
        # корень самой обработки. Без этого форма и макет внутри обработки 2.21 писались бы 2.17.
        ext_path = d + ".xml"
        if os.path.isfile(ext_path):
            with open(ext_path, "r", encoding="utf-8-sig") as f:
                ext_head = f.read(2000)
            if re.search(r'<(ExternalDataProcessor|ExternalReport)[ >]', ext_head):
                m = re.search(r'<MetaDataObject[^>]+version="(\d+\.\d+)"', ext_head)
                if m:
                    return m.group(1)
        cfg_path = os.path.join(d, "Configuration.xml")
        if os.path.isfile(cfg_path):
            with open(cfg_path, "r", encoding="utf-8-sig") as f:
                head = f.read(2000)
            m = re.search(r'<MetaDataObject[^>]+version="(\d+\.\d+)"', head)
            if m:
                return m.group(1)
        parent = os.path.dirname(d)
        if parent == d:
            break
        d = parent
    return "2.17"


def format_rank(ver):
    """"2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
    m = re.match(r'^(\d+)\.(\d+)$', ver or '')
    return int(m.group(1)) * 100 + int(m.group(2)) if m else 0


def _normalize_elements(defn):
    """Convert dict-style elements from --from-object generators to list-style expected by compiler.
    Generator format:  elements = {"ИмяЭлемента": {"element": "input", "path": "..."}, ...}
    Compiler format:   elements = [{"input": "ИмяЭлемента", "path": "..."}, ...]
    Also handles nested 'elements' in groups and 'columns' in tables recursively.
    """
    def convert_elements(els):
        if isinstance(els, list):
            # Already list format — but may have nested dicts inside groups
            result = []
            for el in els:
                if isinstance(el, dict):
                    el = dict(el)  # copy
                    if 'elements' in el and isinstance(el['elements'], dict):
                        el['elements'] = convert_elements(el['elements'])
                    if 'columns' in el and isinstance(el['columns'], dict):
                        el['columns'] = convert_columns(el['columns'])
                result.append(el)
            return result
        if isinstance(els, dict):
            result = []
            for name, props in els.items():
                if not isinstance(props, dict):
                    continue
                new_el = {}
                el_type = props.get('element', 'input')
                # Map element type to the key name used in JSON DSL
                type_map = {
                    'input': 'input', 'check': 'check', 'labelField': 'labelField',
                    'table': 'table', 'group': 'group', 'pages': 'pages',
                    'page': 'page', 'label': 'label', 'button': 'button',
                    'checkBox': 'check', 'radioButton': 'radioButton',
                    'pictureField': 'pictureField',
                }
                mapped_type = type_map.get(el_type, el_type)
                new_el[mapped_type] = name
                for k, v in props.items():
                    if k == 'element':
                        continue
                    if k == 'elements' and isinstance(v, dict):
                        new_el['elements'] = convert_elements(v)
                    elif k == 'columns' and isinstance(v, dict):
                        new_el['columns'] = convert_columns(v)
                    elif k == 'groupType':
                        # groupType → group property in DSL
                        new_el['group'] = v
                    elif k == 'showTitle':
                        new_el['showTitle'] = v
                    elif k == 'representation':
                        new_el['representation'] = v
                    elif k == 'autoCommandBar':
                        new_el['autoCommandBar'] = v
                    elif k == 'commandBarLocation':
                        new_el['commandBarLocation'] = v
                    else:
                        new_el[k] = v
                result.append(new_el)
            return result
        return els

    def convert_columns(cols):
        if isinstance(cols, list):
            return cols
        if isinstance(cols, dict):
            result = []
            for name, props in cols.items():
                if not isinstance(props, dict):
                    continue
                new_col = {}
                el_type = props.get('element', 'input')
                type_map = {
                    'input': 'input', 'check': 'check', 'labelField': 'labelField',
                    'checkBox': 'check',
                }
                mapped_type = type_map.get(el_type, el_type)
                new_col[mapped_type] = name
                for k, v in props.items():
                    if k == 'element':
                        continue
                    new_col[k] = v
                result.append(new_col)
            return result
        return cols

    if 'elements' in defn:
        defn['elements'] = convert_elements(defn['elements'])
    return defn


def main():
    sys.stdout.reconfigure(encoding="utf-8")
    sys.stderr.reconfigure(encoding="utf-8")
    global _next_id

    parser = argparse.ArgumentParser(description='Compile 1C managed form from JSON or object metadata', allow_abbrev=False)
    parser.add_argument('-JsonPath', type=str, default=None)
    parser.add_argument('-OutputPath', type=str, required=True)
    parser.add_argument('-FromObject', action='store_true', default=False)
    parser.add_argument('-ObjectPath', type=str, default=None)
    parser.add_argument('-Purpose', type=str, default=None)
    parser.add_argument('-Preset', type=str, default='erp-standard')
    parser.add_argument('-EmitDsl', type=str, default=None)
    args = ci_parse_args(parser)

    # Form name -> purpose mapping
    _FORM_NAME_TO_PURPOSE = {
        '\u0424\u043e\u0440\u043c\u0430\u0414\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430': 'Item',       # ФормаДокумента
        '\u0424\u043e\u0440\u043c\u0430\u042d\u043b\u0435\u043c\u0435\u043d\u0442\u0430': 'Item',              # ФормаЭлемента
        '\u0424\u043e\u0440\u043c\u0430\u0421\u043f\u0438\u0441\u043a\u0430': 'List',                          # ФормаСписка
        '\u0424\u043e\u0440\u043c\u0430\u0412\u044b\u0431\u043e\u0440\u0430': 'Choice',                        # ФормаВыбора
        '\u0424\u043e\u0440\u043c\u0430\u0413\u0440\u0443\u043f\u043f\u044b': 'Folder',                        # ФормаГруппы
        '\u0424\u043e\u0440\u043c\u0430\u0417\u0430\u043f\u0438\u0441\u0438': 'Record',                       # ФормаЗаписи
        '\u0424\u043e\u0440\u043c\u0430\u0421\u0447\u0435\u0442\u0430': 'Item',                               # ФормаСчета
        '\u0424\u043e\u0440\u043c\u0430\u0423\u0437\u043b\u0430': 'Item',                                     # ФормаУзла
    }

    # Mutual exclusion validation
    if args.FromObject and args.JsonPath:
        print("Cannot use both -JsonPath and -FromObject. Choose one mode.", file=sys.stderr)
        sys.exit(1)
    if not args.FromObject and not args.JsonPath:
        print("Either -JsonPath or -FromObject is required.", file=sys.stderr)
        sys.exit(1)

    # Normalize OutputPath in from-object mode: append /Ext/Form.xml if missing
    if args.FromObject:
        out_norm = args.OutputPath.rstrip('/\\')
        if not re.search(r'[/\\]Ext[/\\]Form\.xml$', out_norm):
            if re.search(r'[/\\]Ext$', out_norm):
                args.OutputPath = out_norm + '/Form.xml'
            else:
                args.OutputPath = out_norm + '/Ext/Form.xml'
            print(f"[resolved] OutputPath -> {args.OutputPath}")

    # --- Detect XML format version ---
    out_path_resolved = args.OutputPath if os.path.isabs(args.OutputPath) else os.path.join(os.getcwd(), args.OutputPath)
    assert_edit_allowed(out_path_resolved, "editable")
    format_version = detect_format_version(os.path.dirname(out_path_resolved))

    # Объявления пространств имён — одной переменной: место эмиссии её только подставляет.
    # Правки шапки (как xmlns:pal в формате 2.21) делаются здесь, в одном месте.
    form_ns_decl = (
        'xmlns="http://v8.1c.ru/8.3/xcf/logform"'
        ' xmlns:app="http://v8.1c.ru/8.2/managed-application/core"'
        ' xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config"'
        ' xmlns:dcscor="http://v8.1c.ru/8.1/data-composition-system/core"'
        ' xmlns:dcssch="http://v8.1c.ru/8.1/data-composition-system/schema"'
        ' xmlns:dcsset="http://v8.1c.ru/8.1/data-composition-system/settings"'
        ' xmlns:ent="http://v8.1c.ru/8.1/data/enterprise"'
        ' xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform"'
        ' xmlns:style="http://v8.1c.ru/8.1/data/ui/style"'
        ' xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system"'
        ' xmlns:v8="http://v8.1c.ru/8.1/data/core"'
        ' xmlns:v8ui="http://v8.1c.ru/8.1/data/ui"'
        ' xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web"'
        ' xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows"'
        ' xmlns:xr="http://v8.1c.ru/8.3/xcf/readable"'
        ' xmlns:xs="http://www.w3.org/2001/XMLSchema"'
        ' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
    )

    # 2.21 (8.5) добавила в шапку пространство палитры — ради <Color> у значений перечисления.
    # Вставляем НА МЕСТО (после lf, перед style): платформа держит объявления по алфавиту,
    # дописать в конец нельзя.
    if format_rank(format_version) >= 221:
        form_ns_decl = form_ns_decl.replace(
            ' xmlns:style=',
            ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style=')

    # --- 0. From-object mode ---
    if args.FromObject:
        # Resolve object path and purpose from OutputPath convention:
        # .../TypePlural/ObjectName/Forms/FormName/Ext/Form.xml
        out_abs = out_path_resolved
        parts = re.split(r'[/\\]', out_abs)
        forms_idx = -1
        for i in range(len(parts) - 1, -1, -1):
            if parts[i] == 'Forms':
                forms_idx = i
                break

        resolved_object_path = None
        resolved_purpose = None

        if forms_idx >= 2:
            form_name = parts[forms_idx + 1]
            object_name = parts[forms_idx - 1]
            type_plural_and_above = os.sep.join(parts[:forms_idx - 1])

            if form_name in _FORM_NAME_TO_PURPOSE:
                resolved_purpose = _FORM_NAME_TO_PURPOSE[form_name]

            candidate = os.path.join(type_plural_and_above, f'{object_name}.xml')
            if os.path.exists(candidate):
                resolved_object_path = candidate

        # Apply: explicit -ObjectPath / -Purpose override resolved
        from_obj_path = None
        if args.ObjectPath:
            from_obj_path = args.ObjectPath if os.path.isabs(args.ObjectPath) else os.path.join(os.getcwd(), args.ObjectPath)
            if not from_obj_path.endswith('.xml'):
                from_obj_path += '.xml'
        elif resolved_object_path:
            from_obj_path = resolved_object_path
            print(f"[resolved] ObjectPath -> {from_obj_path}")
        else:
            print("Cannot derive object path from OutputPath. Use -ObjectPath explicitly.", file=sys.stderr)
            sys.exit(1)

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

        purpose = args.Purpose or resolved_purpose or 'Item'
        if resolved_purpose and not args.Purpose:
            print(f"[resolved] Purpose -> {purpose}")

        meta = parse_object_meta(from_obj_path)
        print(f"[from-object] Type={meta['Type']}, Name={meta['Name']}, Attrs={len(meta['Attributes'])}, TS={len(meta['TabularSections'])}")

        preset_data = load_preset(args.Preset, os.path.dirname(os.path.abspath(__file__)), out_path_resolved)

        supported = {
            'Document': ['Item', 'List', 'Choice'],
            'Catalog': ['Item', 'Folder', 'List', 'Choice'],
            'InformationRegister': ['Record', 'List'],
            'AccumulationRegister': ['List'],
            'ChartOfCharacteristicTypes': ['Item', 'Folder', 'List', 'Choice'],
            'ExchangePlan': ['Item', 'List', 'Choice'],
            'ChartOfAccounts': ['Item', 'Folder', 'List', 'Choice'],
        }
        if meta['Type'] not in supported:
            print(f"Object type '{meta['Type']}' not supported. Supported: Document, Catalog, InformationRegister, AccumulationRegister, ChartOfCharacteristicTypes, ExchangePlan, ChartOfAccounts.", file=sys.stderr)
            sys.exit(1)
        if purpose not in supported[meta['Type']]:
            print(f"Purpose '{purpose}' not valid for {meta['Type']}. Valid: {', '.join(supported[meta['Type']])}", file=sys.stderr)
            sys.exit(1)

        dsl_dispatch = {
            'Document': generate_document_dsl,
            'Catalog': generate_catalog_dsl,
            'InformationRegister': generate_information_register_dsl,
            'AccumulationRegister': generate_accumulation_register_dsl,
            'ChartOfCharacteristicTypes': generate_chart_of_characteristic_types_dsl,
            'ExchangePlan': generate_exchange_plan_dsl,
            'ChartOfAccounts': generate_chart_of_accounts_dsl,
        }
        dsl = dsl_dispatch[meta['Type']](meta, preset_data, purpose)

        if args.EmitDsl:
            dsl_path = args.EmitDsl if os.path.isabs(args.EmitDsl) else os.path.join(os.getcwd(), args.EmitDsl)
            os.makedirs(os.path.dirname(dsl_path) or '.', exist_ok=True)
            with open(dsl_path, 'w', encoding='utf-8') as f:
                json.dump(dsl, f, ensure_ascii=False, indent=2)
            print(f"[from-object] DSL saved: {dsl_path}")

        defn = ci_json(json.loads(json.dumps(dsl)))  # normalize OrderedDict to regular dict
        # Convert dict-style elements (from generators) to list-style (expected by compiler)
        defn = _normalize_elements(defn)
    else:
        # --- 1. Load and validate JSON ---
        json_path = args.JsonPath
        if not os.path.exists(json_path):
            print(f"File not found: {json_path}", file=sys.stderr)
            sys.exit(1)

        defn = ci_json(parse_json_input(read_json_file(json_path), json_path))
        global QUERY_BASE_DIR
        QUERY_BASE_DIR = os.path.dirname(os.path.abspath(json_path))

    # --- 1b. Pre-pass: synonyms, main attribute inference, heuristics, autoCmdBar extraction ---
    def _normalize_synonyms(el):
        if not isinstance(el, dict):
            return
        # Companion-панели (объект/массив-значение) → commandBar/contextMenu
        normalize_panel_synonyms(el)
        # Тип-синонимы: commandBar/autoCommandBar → элемент-тип ТОЛЬКО при строковом значении
        synonyms = {'commandBar': 'cmdBar', 'autoCommandBar': 'autoCmdBar', 'extTooltip': 'extendedTooltip'}
        for src, dst in synonyms.items():
            if src in el and dst not in el:
                if src in STR_ONLY_TYPE_SYNONYMS and not isinstance(el[src], str):
                    continue
                el[dst] = el.pop(src)
        # Рекурсия в детей панелей (commandBar/contextMenu)
        for pk in ('commandBar', 'contextMenu'):
            pv = el.get(pk)
            kids = pv if isinstance(pv, list) else (pv.get('children') if isinstance(pv, dict) else None)
            if isinstance(kids, list):
                for child in kids:
                    _normalize_synonyms(child)
        if isinstance(el.get('children'), list):
            for child in el['children']:
                _normalize_synonyms(child)
        if isinstance(el.get('columns'), list):
            for child in el['columns']:
                _normalize_synonyms(child)

    def _has_cmd_bar_recursive(el):
        if not isinstance(el, dict):
            return False
        if el.get('cmdBar') is not None:
            return True
        if isinstance(el.get('children'), list):
            for child in el['children']:
                if _has_cmd_bar_recursive(child):
                    return True
        if isinstance(el.get('columns'), list):
            for child in el['columns']:
                if _has_cmd_bar_recursive(child):
                    return True
        return False

    def _apply_dlist_table_heuristic(el, list_name, has_main_table):
        if not isinstance(el, dict):
            return
        if el.get('table') is not None and str(el.get('path', '')) == list_name:
            # Маркер дин-список-таблицы → emit_table эмитит блок свойств
            el['_dynList'] = True
            if 'tableAutofill' not in el:
                el['tableAutofill'] = False
            if 'commandBarLocation' not in el:
                el['commandBarLocation'] = 'None'
            # RowPictureDataPath: умный дефолт <Список>.DefaultPicture, если ключ ОТСУТСТВУЕТ.
            # Декомпилятор опускает при rpdp == smart-default; реальное отсутствие → ""-маркер (не
            # перезатирается). Гейт has_main_table снят: дин-список без mainTable тоже несёт RowPictureDataPath.
            if 'rowPictureDataPath' not in el:
                el['rowPictureDataPath'] = f'{list_name}.DefaultPicture'
        if isinstance(el.get('children'), list):
            for child in el['children']:
                _apply_dlist_table_heuristic(child, list_name, has_main_table)

    def _is_object_like_type(t):
        if not t:
            return False
        if t == 'DynamicList' or t == 'ConstantsSet':
            return True
        object_suffixes = (
            'CatalogObject', 'DocumentObject', 'DataProcessorObject', 'ReportObject',
            'ExternalDataProcessorObject', 'ExternalReportObject', 'BusinessProcessObject',
            'TaskObject', 'ChartOfAccountsObject', 'ChartOfCharacteristicTypesObject',
            'ChartOfCalculationTypesObject', 'ExchangePlanObject',
        )
        record_set_prefixes = (
            'InformationRegisterRecordSet', 'AccumulationRegisterRecordSet',
            'AccountingRegisterRecordSet', 'CalculationRegisterRecordSet',
            'InformationRegisterRecordManager',
        )
        for s in object_suffixes:
            if t.startswith(s + '.'):
                return True
        for s in record_set_prefixes:
            if t.startswith(s + '.'):
                return True
        return False

    # 1b.1: Normalize synonyms recursively
    if isinstance(defn.get('elements'), list):
        for el in defn['elements']:
            _normalize_synonyms(el)

    # 1b.2: Extract autoCmdBar element from defn['elements']
    main_acb_def = None
    if isinstance(defn.get('elements'), list):
        auto_bars = [el for el in defn['elements'] if isinstance(el, dict) and el.get('autoCmdBar') is not None]
        if len(auto_bars) > 1:
            print(f"form-compile: more than one autoCmdBar in def.elements (found {len(auto_bars)}); only one allowed.", file=sys.stderr)
            sys.exit(1)
        if len(auto_bars) == 1:
            main_acb_def = auto_bars[0]
            defn['elements'] = [el for el in defn['elements'] if el is not main_acb_def]

    # 1b.3: Infer main attribute
    if isinstance(defn.get('attributes'), list):
        has_explicit_main = any(a.get('main') is True for a in defn['attributes'] if isinstance(a, dict))
        if not has_explicit_main:
            candidates = []
            for a in defn['attributes']:
                if not isinstance(a, dict):
                    continue
                if 'main' in a and a.get('main') is False:
                    continue
                if _is_object_like_type(str(a.get('type', ''))):
                    candidates.append(a)
            if len(candidates) == 1:
                candidates[0]['main'] = True
                print(f"[INFO] Inferred main attribute: {candidates[0].get('name')} ({candidates[0].get('type')})")
            elif len(candidates) > 1:
                names = ', '.join(c.get('name', '') for c in candidates)
                print(f"[WARN] Multiple main-attribute candidates: {names}; specify \"main\": true explicitly")

    # 1b.4: DynamicList → table heuristic (для ВСЕХ DynamicList-реквизитов, не только main)
    if isinstance(defn.get('attributes'), list) and isinstance(defn.get('elements'), list):
        for attr in defn['attributes']:
            if not isinstance(attr, dict) or str(attr.get('type', '')) != 'DynamicList':
                continue
            settings = attr.get('settings') or {}
            has_mt = bool(isinstance(settings, dict) and settings.get('mainTable'))
            for el in defn['elements']:
                _apply_dlist_table_heuristic(el, attr.get('name', ''), has_mt)

    # 1b.5: Compute main AutoCommandBar Autofill (B3)
    def _compute_main_acb_autofill():
        if main_acb_def is not None:
            if 'autofill' in main_acb_def:
                return bool(main_acb_def.get('autofill'))
            return True
        if isinstance(defn.get('elements'), list):
            for el in defn['elements']:
                if _has_cmd_bar_recursive(el):
                    return False
        return True

    # --- 2. Main compilation ---
    _next_id = 0
    _seen_element_names.clear()  # пул имён элементов (на случай повторного вызова в одном процессе)
    lines = []

    lines.append('<?xml version="1.0" encoding="UTF-8"?>')
    lines.append(f'<Form {form_ns_decl} version="{format_version}">')

    # Title
    form_title = defn.get('title')
    if not form_title and defn.get('properties') and defn['properties'].get('title'):
        form_title = defn['properties']['title']
    if form_title:
        emit_mltext(lines, '\t', 'Title', form_title)

    # Properties (skip 'title' — handled above)
    # When form-level Title is set, default autoTitle=false (≈95% of ERP forms do this;
    # otherwise platform appends synonym → "Title: Synonym" double-titles).
    props_src = defn.get('properties') or {}
    props_clone = OrderedDict()
    if form_title and 'autoTitle' not in props_src:
        props_clone['autoTitle'] = False
    for k, v in props_src.items():
        if k != 'title':
            props_clone[k] = v
    emit_properties(lines, props_clone, '\t')

    # CommandSet (excluded commands)
    if defn.get('excludedCommands') and len(defn['excludedCommands']) > 0:
        lines.append('\t<CommandSet>')
        for cmd in defn['excludedCommands']:
            lines.append(f'\t\t<ExcludedCommand>{cmd}</ExcludedCommand>')
        lines.append('\t</CommandSet>')

    # MobileDeviceCommandBarContent — форменный список имён командных панелей/кнопок
    # (Presentation пустой, CheckState=0, тип xs:string — константы; варьируется только имя-Value).
    # 12 форм корпуса несут один пустой item (Value="") — список присутствует, но не пуст по len.
    if defn.get('mobileCommandBarContent') is not None and len(defn['mobileCommandBarContent']) > 0:
        lines.append('\t<MobileDeviceCommandBarContent>')
        for nm in defn['mobileCommandBarContent']:
            lines.append('\t\t<xr:Item>')
            lines.append('\t\t\t<xr:Presentation/>')
            lines.append('\t\t\t<xr:CheckState>0</xr:CheckState>')
            # пустое значение → самозакрывающийся тег (зеркало платформы)
            if not str(nm):
                lines.append('\t\t\t<xr:Value xsi:type="xs:string"/>')
            else:
                lines.append(f'\t\t\t<xr:Value xsi:type="xs:string">{esc_xml_text(str(nm))}</xr:Value>')
            lines.append('\t\t</xr:Item>')
        lines.append('\t</MobileDeviceCommandBarContent>')

    # AutoCommandBar (always present, id=-1)
    acb_autofill = _compute_main_acb_autofill()
    acb_name = '\u0424\u043e\u0440\u043c\u0430\u041a\u043e\u043c\u0430\u043d\u0434\u043d\u0430\u044f\u041f\u0430\u043d\u0435\u043b\u044c'
    acb_halign = None
    if main_acb_def is not None:
        v = main_acb_def.get('autoCmdBar')
        if v:
            acb_name = str(v)
        if main_acb_def.get('name'):
            acb_name = str(main_acb_def['name'])
        if main_acb_def.get('horizontalAlign'):
            acb_halign = str(main_acb_def['horizontalAlign'])
    has_acb_children = bool(main_acb_def and isinstance(main_acb_def.get('children'), list) and len(main_acb_def['children']) > 0)
    # DisplayImportance форменной панели (адаптивная важность) — атрибут тега
    acb_di_attr = di_attr(main_acb_def) if main_acb_def is not None else ''
    has_inner = bool(acb_halign) or (not acb_autofill) or has_acb_children
    if has_inner:
        lines.append(f'\t<AutoCommandBar name="{acb_name}" id="-1"{acb_di_attr}>')
        if acb_halign:
            lines.append(f'\t\t<HorizontalAlign>{acb_halign}</HorizontalAlign>')
        if not acb_autofill:
            lines.append('\t\t<Autofill>false</Autofill>')
        if has_acb_children:
            lines.append('\t\t<ChildItems>')
            for child in main_acb_def['children']:
                emit_element(lines, child, '\t\t\t', in_cmd_bar=True)
            lines.append('\t\t</ChildItems>')
        lines.append('\t</AutoCommandBar>')
    else:
        lines.append(f'\t<AutoCommandBar name="{acb_name}" id="-1"{acb_di_attr}/>')

    # Events
    if defn.get('events'):
        for evt_name in defn['events']:
            if evt_name not in KNOWN_FORM_EVENTS:
                print(f"[WARN] Unknown form event '{evt_name}'. Known: {', '.join(KNOWN_FORM_EVENTS)}")
        lines.append('\t<Events>')
        for evt_name, evt_handler in defn['events'].items():
            lines.append(f'\t\t<Event name="{evt_name}">{evt_handler}</Event>')
        lines.append('\t</Events>')

    # ChildItems (elements)
    if defn.get('elements') and len(defn['elements']) > 0:
        lines.append('\t<ChildItems>')
        for el in defn['elements']:
            emit_element(lines, el, '\t\t')
        lines.append('\t</ChildItems>')

    # Attributes
    emit_attributes(lines, defn.get('attributes'), '\t', conditional_appearance=defn.get('conditionalAppearance'))

    # Parameters
    emit_parameters(lines, defn.get('parameters'), '\t')

    # Commands
    emit_commands(lines, defn.get('commands'), '\t')

    # CommandInterface (командный интерфейс формы — последний дочерний Form)
    emit_command_interface(lines, defn.get('commandInterface'), '\t')

    # Close
    lines.append('</Form>')

    # --- 3. Write output ---
    out_path = args.OutputPath
    if not os.path.isabs(out_path):
        out_path = os.path.join(os.getcwd(), out_path)
    out_dir = os.path.dirname(out_path)
    if out_dir and not os.path.exists(out_dir):
        os.makedirs(out_dir, exist_ok=True)

    content = '\r\n'.join(lines)
    write_utf8_bom(out_path, content)

    # --- 4. Auto-register form in parent object XML ---
    # Infer parent from OutputPath: .../TypePlural/ObjectName/Forms/FormName/Ext/Form.xml
    form_xml_dir = os.path.dirname(out_path)    # Ext
    form_name_dir = os.path.dirname(form_xml_dir)  # FormName
    forms_dir = os.path.dirname(form_name_dir)    # Forms
    object_dir = os.path.dirname(forms_dir)       # ObjectName
    type_plural_dir = os.path.dirname(object_dir)  # TypePlural

    form_name = os.path.basename(form_name_dir)
    object_name = os.path.basename(object_dir)
    forms_leaf = os.path.basename(forms_dir)

    if forms_leaf == 'Forms':
        object_xml_path = os.path.join(type_plural_dir, f'{object_name}.xml')
        if os.path.exists(object_xml_path):
            # newline='' => без трансляции переводов строк: иначе CRLF молча
            # схлопнется в LF при чтении и файл будет переписан в LF (#44/#46/#47).
            with open(object_xml_path, 'r', encoding='utf-8-sig', newline='') as f:
                raw_text = f.read()
            # Перевод строки вставки берём из самого файла, а не из канона:
            # правка существующего файла сохраняет его стиль.
            eol = '\r\n' if '\r\n' in raw_text else '\n'

            # Check if already registered
            if f'<Form>{form_name}</Form>' not in raw_text:
                # Insert before </ChildObjects>
                if '</ChildObjects>' in raw_text:
                    # Отступ вставки берём у закрывающего тега +1 уровень: подстановка
                    # по голому '</ChildObjects>' удваивала бы уже присутствующий отступ
                    # строки (получалось 5 табов вместо 3 — PS-порт через DOM даёт 3).
                    raw_text = re.sub(r'([ \t]*)</ChildObjects>',
                                      lambda m: m.group(1) + '\t' + f'<Form>{form_name}</Form>' + eol + m.group(1) + '</ChildObjects>',
                                      raw_text, count=1)
                elif '<ChildObjects/>' in raw_text:
                    replacement = ('<ChildObjects>' + eol + f'\t\t\t<Form>{form_name}</Form>' + eol + '\t\t</ChildObjects>')
                    raw_text = raw_text.replace('<ChildObjects/>', replacement, 1)

                write_utf8_bom(object_xml_path, raw_text)
                print(f"     Registered: <Form>{form_name}</Form> in {object_name}.xml")

    # --- 5. Summary ---
    el_count = _next_id
    print(f"[OK] Compiled: {args.OutputPath}")
    print(f"     Elements+IDs: {el_count}")
    if defn.get('attributes'):
        print(f"     Attributes: {len(defn['attributes'])}")
    if defn.get('commands'):
        print(f"     Commands: {len(defn['commands'])}")
    if defn.get('parameters'):
        print(f"     Parameters: {len(defn['parameters'])}")


if __name__ == '__main__':
    main()
