#!/usr/bin/env python3
# role-validate v1.5 — Validate 1C role Rights.xml structure
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
"""Validates role Rights.xml: root element, global flags, objects, rights, RLS, templates."""
import sys, os, argparse, re
from lxml import etree

# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.
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)


GUID_PATTERN = re.compile(
    r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
    re.IGNORECASE
)

RIGHTS_NS = 'http://v8.1c.ru/8.2/roles'

# --- Known rights per object type ---
KNOWN_RIGHTS = {
    'Configuration': [
        'Administration', 'DataAdministration', 'UpdateDataBaseConfiguration',
        'ConfigurationExtensionsAdministration', 'ActiveUsers', 'EventLog', 'ExclusiveMode',
        'ThinClient', 'ThickClient', 'WebClient', 'MobileClient', 'ExternalConnection',
        'Automation', 'Output', 'SaveUserData', 'TechnicalSpecialistMode',
        'InteractiveOpenExtDataProcessors', 'InteractiveOpenExtReports',
        'AnalyticsSystemClient', 'CollaborationSystemInfoBaseRegistration',
        'MainWindowModeNormal', 'MainWindowModeWorkplace',
        'MainWindowModeEmbeddedWorkplace', 'MainWindowModeFullscreenWorkplace', 'MainWindowModeKiosk',
    ],
    'Catalog': [
        'Read', 'Insert', 'Update', 'Delete', 'View', 'Edit', 'InputByString',
        'InteractiveInsert', 'InteractiveSetDeletionMark', 'InteractiveClearDeletionMark',
        'InteractiveDelete', 'InteractiveDeleteMarked',
        'InteractiveDeletePredefinedData', 'InteractiveSetDeletionMarkPredefinedData',
        'InteractiveClearDeletionMarkPredefinedData', 'InteractiveDeleteMarkedPredefinedData',
        'ReadDataHistory', 'ViewDataHistory', 'UpdateDataHistory',
        'UpdateDataHistoryOfMissingData', 'ReadDataHistoryOfMissingData',
        'UpdateDataHistorySettings', 'UpdateDataHistoryVersionComment',
        'EditDataHistoryVersionComment', 'SwitchToDataHistoryVersion',
    ],
    'Document': [
        'Read', 'Insert', 'Update', 'Delete', 'View', 'Edit', 'InputByString',
        'Posting', 'UndoPosting',
        'InteractiveInsert', 'InteractiveSetDeletionMark', 'InteractiveClearDeletionMark',
        'InteractiveDelete', 'InteractiveDeleteMarked',
        'InteractivePosting', 'InteractivePostingRegular', 'InteractiveUndoPosting',
        'InteractiveChangeOfPosted',
        'ReadDataHistory', 'ViewDataHistory', 'UpdateDataHistory',
        'UpdateDataHistoryOfMissingData', 'ReadDataHistoryOfMissingData',
        'UpdateDataHistorySettings', 'UpdateDataHistoryVersionComment',
        'EditDataHistoryVersionComment', 'SwitchToDataHistoryVersion',
    ],
    'InformationRegister': [
        'Read', 'Update', 'View', 'Edit', 'TotalsControl',
        'ReadDataHistory', 'ViewDataHistory', 'UpdateDataHistory',
        'UpdateDataHistoryOfMissingData', 'ReadDataHistoryOfMissingData',
        'UpdateDataHistorySettings', 'UpdateDataHistoryVersionComment',
        'EditDataHistoryVersionComment', 'SwitchToDataHistoryVersion',
    ],
    'AccumulationRegister': ['Read', 'Update', 'View', 'Edit', 'TotalsControl'],
    'AccountingRegister': ['Read', 'Update', 'View', 'Edit', 'TotalsControl'],
    'CalculationRegister': ['Read', 'View'],
    'Constant': [
        'Read', 'Update', 'View', 'Edit',
        'ReadDataHistory', 'ViewDataHistory', 'UpdateDataHistory',
        'UpdateDataHistorySettings', 'UpdateDataHistoryVersionComment',
        'EditDataHistoryVersionComment', 'SwitchToDataHistoryVersion',
    ],
    'ChartOfAccounts': [
        'Read', 'Insert', 'Update', 'Delete', 'View', 'Edit', 'InputByString',
        'InteractiveInsert', 'InteractiveSetDeletionMark', 'InteractiveClearDeletionMark',
        'InteractiveDelete',
        'InteractiveDeletePredefinedData', 'InteractiveSetDeletionMarkPredefinedData',
        'InteractiveClearDeletionMarkPredefinedData', 'InteractiveDeleteMarkedPredefinedData',
        'ReadDataHistory', 'ReadDataHistoryOfMissingData',
        'UpdateDataHistory', 'UpdateDataHistoryOfMissingData',
        'UpdateDataHistorySettings', 'UpdateDataHistoryVersionComment',
    ],
    'ChartOfCharacteristicTypes': [
        'Read', 'Insert', 'Update', 'Delete', 'View', 'Edit', 'InputByString',
        'InteractiveInsert', 'InteractiveSetDeletionMark', 'InteractiveClearDeletionMark',
        'InteractiveDelete', 'InteractiveDeleteMarked',
        'InteractiveDeletePredefinedData', 'InteractiveSetDeletionMarkPredefinedData',
        'InteractiveClearDeletionMarkPredefinedData', 'InteractiveDeleteMarkedPredefinedData',
        'ReadDataHistory', 'ViewDataHistory', 'UpdateDataHistory',
        'ReadDataHistoryOfMissingData', 'UpdateDataHistoryOfMissingData',
        'UpdateDataHistorySettings', 'UpdateDataHistoryVersionComment',
        'EditDataHistoryVersionComment', 'SwitchToDataHistoryVersion',
    ],
    'ChartOfCalculationTypes': [
        'Read', 'Insert', 'Update', 'Delete', 'View', 'Edit', 'InputByString',
        'InteractiveInsert', 'InteractiveSetDeletionMark', 'InteractiveClearDeletionMark',
        'InteractiveDelete',
        'InteractiveDeletePredefinedData', 'InteractiveSetDeletionMarkPredefinedData',
        'InteractiveClearDeletionMarkPredefinedData', 'InteractiveDeleteMarkedPredefinedData',
    ],
    'ExchangePlan': [
        'Read', 'Insert', 'Update', 'Delete', 'View', 'Edit', 'InputByString',
        'InteractiveInsert', 'InteractiveSetDeletionMark', 'InteractiveClearDeletionMark',
        'InteractiveDelete', 'InteractiveDeleteMarked',
        'ReadDataHistory', 'ViewDataHistory', 'UpdateDataHistory',
        'ReadDataHistoryOfMissingData', 'UpdateDataHistoryOfMissingData',
        'UpdateDataHistorySettings', 'UpdateDataHistoryVersionComment',
        'EditDataHistoryVersionComment', 'SwitchToDataHistoryVersion',
    ],
    'BusinessProcess': [
        'Read', 'Insert', 'Update', 'Delete', 'View', 'Edit', 'InputByString',
        'Start', 'InteractiveInsert', 'InteractiveSetDeletionMark', 'InteractiveClearDeletionMark',
        'InteractiveDelete', 'InteractiveActivate', 'InteractiveStart',
    ],
    'Task': [
        'Read', 'Insert', 'Update', 'Delete', 'View', 'Edit', 'InputByString',
        'Execute', 'InteractiveInsert', 'InteractiveSetDeletionMark', 'InteractiveClearDeletionMark',
        'InteractiveDelete', 'InteractiveActivate', 'InteractiveExecute',
    ],
    'DataProcessor': ['Use', 'View'],
    'Report': ['Use', 'View'],
    'CommonForm': ['View'],
    'CommonCommand': ['View'],
    'Subsystem': ['View'],
    'FilterCriterion': ['View'],
    'DocumentJournal': ['Read', 'View'],
    'Sequence': ['Read', 'Update'],
    'WebService': ['Use'],
    'HTTPService': ['Use'],
    'IntegrationService': ['Use'],
    'SessionParameter': ['Get', 'Set'],
    'CommonAttribute': ['View', 'Edit'],
    'ExternalDataSource': [
        'Use', 'Administration', 'StandardAuthenticationChange',
        'SessionStandardAuthenticationChange', 'SessionOSAuthenticationChange',
    ],
}

# Виды вложенности (предпоследний сегмент пути) → допустимые права. Списки сняты с корпуса
# типовых конфигураций и с выгрузки роли, где права проставлены по всему дереву редактора:
# догадкам тут не место — закрытый список превращает промах в ложный отказ.
NESTED_KIND_RIGHTS = {
    'Attribute': ['View', 'Edit'],
    'StandardAttribute': ['View', 'Edit'],
    'TabularSection': ['View', 'Edit'],
    'StandardTabularSection': ['View', 'Edit'],
    'Dimension': ['View', 'Edit'],
    'Resource': ['View', 'Edit'],
    'AccountingFlag': ['View', 'Edit'],
    'ExtDimensionAccountingFlag': ['View', 'Edit'],
    'AddressingAttribute': ['View', 'Edit'],
    'Field': ['View', 'Edit'],
    'Command': ['View'],
    'Subsystem': ['View'],
    'Operation': ['Use'],
    'Method': ['Use'],
    'IntegrationServiceChannel': ['Use'],
    'Recalculation': ['Read', 'Update'],
    'Cube': ['Read', 'View'],
    'DimensionTable': ['Read', 'View'],
    'Function': ['Use', 'View'],
    'Table': [
        'Read', 'Insert', 'Update', 'Delete', 'View', 'Edit', 'InputByString',
        'InteractiveInsert', 'InteractiveDelete',
    ],
}

# Виды, существующие только у одного типа-родителя: без этой привязки
# `Catalog.Товары.Field.Цена` прошёл бы как валидный вложенный объект.
KIND_OWNERS = {
    'Table': 'ExternalDataSource',
    'Cube': 'ExternalDataSource',
    'Function': 'ExternalDataSource',
    'Field': 'ExternalDataSource',
    'DimensionTable': 'ExternalDataSource',
    'Recalculation': 'CalculationRegister',
    'Operation': 'WebService',
    'Method': 'HTTPService',
    'IntegrationServiceChannel': 'IntegrationService',
}

# Подсказка формата для каждого вида сервиса: {0} подставляется как `Тип.Имя` из роли.
SERVICE_LEAF_HINTS = {
    'WebService': '{0}.Operation.<Операция>',
    'HTTPService': '{0}.URLTemplate.<Шаблон>.Method.<Метод>',
    'IntegrationService': '{0}.IntegrationServiceChannel.<Канал>',
}

# Один и тот же вид под разными родителями имеет разный набор: измерение регистра —
# View + Edit, измерение куба внешнего источника — только View. Объединять нельзя,
# объединение молча разрешило бы Edit там, где платформа его не даёт.
NESTED_KIND_RIGHTS_BY_TYPE = {
    'ExternalDataSource': {
        'Dimension': ['View'],
        'Resource': ['View'],
    },
}

# Типы без прав в ролях (в дереве редактора ролей их нет). Таблица НЕ управляет поведением —
# отказ даёт отсутствие типа в KNOWN_RIGHTS; здесь только причина для сообщения.
NO_RIGHTS_TYPES = [
    'Enum', 'CommonModule', 'DefinedType', 'CommonPicture', 'CommonTemplate', 'Language',
    'FunctionalOption', 'FunctionalOptionsParameter', 'EventSubscription', 'ScheduledJob',
    'StyleItem', 'Style', 'SettingsStorage', 'XDTOPackage', 'WSReference', 'DocumentNumerator',
]


def get_object_type(name):
    dot_idx = name.find('.')
    if dot_idx < 0:
        return name
    return name[:dot_idx]


def is_nested_object(name):
    return name.count('.') >= 2


def get_nested_kind(name):
    """Вид вложенности — предпоследний сегмент: путь бывает и восьмисегментным
    (ExternalDataSource.И.Cube.К.DimensionTable.Т.Field.П), считать от конца."""
    parts = name.split('.')
    if len(parts) < 3:
        return None
    return parts[-2]


def get_nested_rights(object_type, kind):
    by_type = NESTED_KIND_RIGHTS_BY_TYPE.get(object_type)
    if by_type and kind in by_type:
        return by_type[kind]
    return NESTED_KIND_RIGHTS.get(kind)


def find_similar(needle, haystack):
    result = []
    needle_lower = needle.lower()
    for h in haystack:
        h_lower = h.lower()
        if needle_lower in h_lower or h_lower in needle_lower:
            result.append(h)
        if len(result) >= 3:
            break
    return result


def format_type_error(obj_name, object_type):
    """Запрещённый тип и незнакомый тип — разные диагнозы: первый документирован,
    второй скорее опечатка или пробел в таблице."""
    if object_type in NO_RIGHTS_TYPES:
        return f"{obj_name}: тип '{object_type}' не имеет прав в роли — блок нужно удалить"
    similar = find_similar(object_type, list(KNOWN_RIGHTS.keys()))
    sug = f" Возможно: {', '.join(similar)}?" if similar else ''
    return f"{obj_name}: неизвестный тип объекта '{object_type}'.{sug}"


def get_child_text(parent, local_name, ns):
    """Get text of first child element with given local name in namespace."""
    for child in parent:
        if not isinstance(child.tag, str):
            continue
        if etree.QName(child.tag).localname == local_name and etree.QName(child.tag).namespace == ns:
            return child.text or ''
    return None


def get_child_el(parent, local_name, ns):
    """Get first child element with given local name in namespace."""
    for child in parent:
        if not isinstance(child.tag, str):
            continue
        if etree.QName(child.tag).localname == local_name and etree.QName(child.tag).namespace == ns:
            return child
    return None


def main():
    sys.stdout.reconfigure(encoding="utf-8")
    sys.stderr.reconfigure(encoding="utf-8")
    parser = argparse.ArgumentParser(
        description='Validate 1C role Rights.xml structure', allow_abbrev=False
    )
    parser.add_argument('-RightsPath', '-Path', dest='RightsPath', required=True)
    parser.add_argument('-OutFile', dest='OutFile', default='')
    parser.add_argument('-Detailed', dest='Detailed', action='store_true')
    parser.add_argument('-MaxErrors', dest='MaxErrors', type=int, default=30)
    args = ci_parse_args(parser)

    rights_path = args.RightsPath
    out_file = args.OutFile

    if not os.path.isabs(rights_path):
        rights_path = os.path.join(os.getcwd(), rights_path)

    # A: Directory → Ext/Rights.xml
    if os.path.isdir(rights_path):
        rights_path = os.path.join(rights_path, 'Ext', 'Rights.xml')
    # B1: Missing Ext/
    if not os.path.exists(rights_path):
        fn = os.path.basename(rights_path)
        if fn == 'Rights.xml':
            c = os.path.join(os.path.dirname(rights_path), 'Ext', fn)
            if os.path.exists(c):
                rights_path = c

    resolved_path = os.path.abspath(rights_path)

    # Auto-detect metadata: Roles/Name/Ext/Rights.xml → Roles/Name.xml
    ext_dir = os.path.dirname(resolved_path)
    role_dir = os.path.dirname(ext_dir)
    roles_dir = os.path.dirname(role_dir)
    role_dir_name = os.path.basename(role_dir)
    metadata_path = os.path.join(roles_dir, f'{role_dir_name}.xml')

    # --- Output helpers ---
    lines = []
    errors = 0
    warnings = 0
    ok_count = 0
    stopped = False

    def report_ok(msg):
        nonlocal ok_count
        ok_count += 1
        if args.Detailed:
            lines.append(f'[OK]    {msg}')

    def report_warn(msg):
        nonlocal warnings
        warnings += 1
        lines.append(f'[WARN]  {msg}')

    def report_error(msg):
        nonlocal errors, stopped
        errors += 1
        lines.append(f'[ERROR] {msg}')
        if errors >= args.MaxErrors:
            stopped = True

    # --- 3. Validate Rights.xml ---

    def write_output(text):
        if out_file:
            out_path = out_file if os.path.isabs(out_file) else os.path.join(os.getcwd(), out_file)
            out_dir = os.path.dirname(out_path)
            if out_dir and not os.path.exists(out_dir):
                os.makedirs(out_dir, exist_ok=True)
            with open(out_path, 'w', encoding='utf-8-sig', newline='') as f:
                f.write(text)
            print(f'Written to: {out_path}')
        else:
            print(text)

    if not os.path.exists(rights_path):
        report_error(f'File not found: {rights_path}')
        result = '\n'.join(lines)
        write_output(result)
        sys.exit(1)

    # 3a. Parse XML
    xml_doc = None
    try:
        xml_parser = etree.XMLParser(remove_blank_text=False)
        xml_doc = etree.parse(rights_path, xml_parser)
        report_ok('XML well-formed')
    except etree.XMLSyntaxError as e:
        report_error(f'XML parse error: {e}')
        result = '\n'.join(lines)
        write_output(result)
        sys.exit(1)

    root = xml_doc.getroot()
    root_local = etree.QName(root.tag).localname
    root_ns = etree.QName(root.tag).namespace or ''

    # 3b. Check root element
    if root_local != 'Rights':
        report_error(f"Root element is '{root_local}', expected 'Rights'")
    elif root_ns != RIGHTS_NS:
        report_warn(f"Namespace is '{root_ns}', expected '{RIGHTS_NS}'")
    else:
        report_ok('Root element: <Rights> with correct namespace')

    # 3c. Global flags
    flag_names = ['setForNewObjects', 'setForAttributesByDefault', 'independentRightsOfChildObjects']
    flags_found = 0
    for fn in flag_names:
        nodes = root.findall(f'{{{RIGHTS_NS}}}{fn}')
        if len(nodes) > 0:
            val = nodes[0].text or ''
            if val not in ('true', 'false'):
                report_warn(f"{fn} = '{val}' (expected 'true' or 'false')")
            flags_found += 1
        else:
            report_warn(f'Missing global flag: {fn}')
    if flags_found == 3:
        report_ok('3 global flags present')

    # 3d. Objects
    objects = root.findall(f'{{{RIGHTS_NS}}}object')
    obj_count = len(objects)
    right_count = 0
    rls_count = 0

    for obj in objects:
        obj_name = ''
        for child in obj:
            if not isinstance(child.tag, str):
                continue
            if etree.QName(child.tag).localname == 'name' and etree.QName(child.tag).namespace == RIGHTS_NS:
                obj_name = child.text or ''
                break

        if not obj_name:
            report_error('Object without <name>')
            continue

        object_type = get_object_type(obj_name)
        is_nested = is_nested_object(obj_name)

        # Тип проверяется ВСЕГДА, включая вложенные пути: раньше `Enum.Х.Attribute.Y`
        # не проверялся вообще — ветка «unknown» стояла под `not is_nested`.
        nested_kind = get_nested_kind(obj_name) if is_nested else None
        type_known = object_type in KNOWN_RIGHTS
        if not type_known:
            report_error(format_type_error(obj_name, object_type))
        elif is_nested and nested_kind in KIND_OWNERS and object_type != KIND_OWNERS[nested_kind]:
            report_error(f"{obj_name}: вид '{nested_kind}' бывает только у {KIND_OWNERS[nested_kind]}")
        elif is_nested and get_nested_rights(object_type, nested_kind) is None:
            report_error(f"{obj_name}: неизвестный вид вложенности '{nested_kind}'")
        elif not is_nested and object_type in SERVICE_LEAF_HINTS:
            # Право на сервис целиком платформа игнорирует: проверяется лист (метод шаблона
            # URL, операция, канал). Роль формально валидна и молча не работает — 403 на всех
            # маршрутах, причина по симптому не читается. В Конфигураторе галки на корне
            # сервиса нет вовсе, так что это недостижимое состояние, а не стилистика.
            hint = SERVICE_LEAF_HINTS[object_type].format(obj_name)
            report_error(f"{obj_name}: право на сервис целиком не действует — "
                         f"назначайте права на вложенные объекты: {hint}")

        # Check rights
        for child in obj:
            if not isinstance(child.tag, str):
                continue
            if etree.QName(child.tag).localname != 'right' or etree.QName(child.tag).namespace != RIGHTS_NS:
                continue

            r_name = ''
            r_value = ''
            has_rls = False

            for rc in child:
                if not isinstance(rc.tag, str):
                    continue
                rc_local = etree.QName(rc.tag).localname
                rc_ns = etree.QName(rc.tag).namespace
                if rc_ns != RIGHTS_NS:
                    continue

                if rc_local == 'name':
                    r_name = rc.text or ''
                elif rc_local == 'value':
                    r_value = rc.text or ''
                elif rc_local == 'restrictionByCondition':
                    has_rls = True
                    rls_count += 1
                    # Check condition not empty
                    cond_node = get_child_el(rc, 'condition', RIGHTS_NS)
                    if cond_node is None or not (cond_node.text or ''):
                        report_warn(f"{obj_name}: RLS condition for '{r_name}' is empty")

            if not r_name:
                report_error(f'{obj_name}: <right> without <name>')
                continue

            if r_value not in ('true', 'false'):
                report_error(f"{obj_name}: right '{r_name}' has invalid value '{r_value}'")
                continue

            right_count += 1

            # Validate right name. Тип уже отвергнут выше — второй раз про него не пишем.
            if not type_known:
                pass
            elif is_nested:
                valid_nested = get_nested_rights(object_type, nested_kind)
                if valid_nested is not None and r_name not in valid_nested:
                    report_error(f"{obj_name}: право '{r_name}' недопустимо для вида '{nested_kind}' (допустимо: {', '.join(valid_nested)})")
            else:
                valid_rights = KNOWN_RIGHTS[object_type]
                if r_name not in valid_rights:
                    similar = find_similar(r_name, valid_rights)
                    sug_str = f" Возможно: {', '.join(similar)}?" if similar else ''
                    report_error(f"{obj_name}: право '{r_name}' не существует у типа '{object_type}'.{sug_str}")

    report_ok(f'{obj_count} objects, {right_count} rights')
    if rls_count > 0:
        report_ok(f'{rls_count} RLS restrictions')

    # 3e. Templates
    templates = root.findall(f'{{{RIGHTS_NS}}}restrictionTemplate')
    if len(templates) > 0:
        tpl_names = []
        for tpl in templates:
            t_name = ''
            t_cond = ''
            for child in tpl:
                if not isinstance(child.tag, str):
                    continue
                local = etree.QName(child.tag).localname
                ns = etree.QName(child.tag).namespace
                if ns != RIGHTS_NS:
                    continue
                if local == 'name':
                    t_name = child.text or ''
                elif local == 'condition':
                    t_cond = child.text or ''
            if not t_name:
                report_warn('Restriction template without <name>')
            else:
                paren_idx = t_name.find('(')
                short_name = t_name[:paren_idx] if paren_idx > 0 else t_name
                tpl_names.append(short_name)
            if not t_cond:
                report_warn(f"Template '{t_name}': empty <condition>")
        report_ok(f'{len(templates)} templates: {", ".join(tpl_names)}')

    # --- 4. Validate metadata (optional) ---
    inferred_role_name = ''
    if os.path.isfile(metadata_path):
        lines.append('')

        try:
            meta_parser = etree.XMLParser(remove_blank_text=False)
            meta_xml = etree.parse(metadata_path, meta_parser)
            meta_root = meta_xml.getroot()
            # Find <Role> element anywhere
            role_node = None
            for el in meta_root.iter():
                if isinstance(el.tag, str) and etree.QName(el.tag).localname == 'Role':
                    role_node = el
                    break

            if role_node is None:
                report_error('Metadata: <Role> element not found')
            else:
                uuid_val = role_node.get('uuid', '')
                if GUID_PATTERN.match(uuid_val):
                    report_ok(f'Metadata: UUID valid ({uuid_val})')
                else:
                    report_error(f"Metadata: invalid UUID format '{uuid_val}'")

                # Find Name
                name_node = None
                for el in role_node.iter():
                    if isinstance(el.tag, str) and etree.QName(el.tag).localname == 'Name':
                        name_node = el
                        break

                if name_node is not None and name_node.text:
                    report_ok(f'Metadata: Name = {name_node.text}')
                    inferred_role_name = name_node.text
                else:
                    report_error('Metadata: <Name> is empty or missing')

                # Find Synonym
                syn_node = None
                for el in role_node.iter():
                    if isinstance(el.tag, str) and etree.QName(el.tag).localname == 'Synonym':
                        syn_node = el
                        break

                if syn_node is not None and len(syn_node) > 0:
                    report_ok('Metadata: Synonym present')
                else:
                    report_warn('Metadata: <Synonym> is empty')
        except etree.XMLSyntaxError as e:
            report_error(f'Metadata XML parse error: {e}')

    # --- 5. Check registration in Configuration.xml ---
    config_dir = os.path.dirname(roles_dir)            # config root
    config_xml_path = os.path.join(config_dir, 'Configuration.xml')

    if not inferred_role_name:
        inferred_role_name = os.path.basename(role_dir)

    if os.path.exists(config_xml_path):
        lines.append('')
        try:
            cfg_parser = etree.XMLParser(remove_blank_text=False)
            cfg_xml = etree.parse(config_xml_path, cfg_parser)
            cfg_ns = {'md': 'http://v8.1c.ru/8.3/MDClasses'}
            child_obj = cfg_xml.getroot().find('.//md:Configuration/md:ChildObjects', cfg_ns)
            if child_obj is not None:
                role_nodes = child_obj.findall('md:Role', cfg_ns)
                found = False
                for rn in role_nodes:
                    if (rn.text or '') == inferred_role_name:
                        found = True
                        break
                if found:
                    report_ok(f'Configuration.xml: <Role>{inferred_role_name}</Role> registered')
                else:
                    report_warn(f'Configuration.xml: <Role>{inferred_role_name}</Role> NOT found in ChildObjects')
        except etree.XMLSyntaxError as e:
            report_warn(f'Configuration.xml: parse error \u2014 {e}')

    # --- 6. Summary ---

    # Insert header at position 0
    lines.insert(0, f'=== Validation: Role.{inferred_role_name} ===')

    checks = ok_count + errors + warnings
    if errors == 0 and warnings == 0 and not args.Detailed:
        result = f'=== Validation OK: Role.{inferred_role_name} ({checks} checks) ==='
    else:
        lines.append('')
        lines.append(f'=== Result: {errors} errors, {warnings} warnings ({checks} checks) ===')
        result = '\n'.join(lines)
    write_output(result)
    sys.exit(1 if errors > 0 else 0)


if __name__ == '__main__':
    main()
