#!/usr/bin/env python3
# epf-validate v1.6 — Validate 1C external data processor / report structure
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
# Works for both EPF (ExternalDataProcessor) and ERF (ExternalReport) — auto-detects

import argparse
import os
import re
import sys
from io import StringIO
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)


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

NSMAP = {"md": MD_NS, "v8": V8_NS, "xr": XR_NS, "xsi": XSI_NS, "xs": XS_NS, "app": APP_NS}

GUID_PATTERN = re.compile(r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$')
IDENT_PATTERN = re.compile(r'^[A-Za-z\u0410-\u042F\u0401\u0430-\u044F\u0451_][A-Za-z0-9\u0410-\u042F\u0401\u0430-\u044F\u0451_]*$')

CLASS_IDS = {
    "ExternalDataProcessor": "c3831ec8-d8d5-4f93-8a22-f9bfae07327f",
    "ExternalReport": "e41aff26-25cf-4bb6-b6c1-3f478a75f374",
}

ALLOWED_CHILD_TYPES = {"Attribute", "TabularSection", "Form", "Template", "Command"}

CHILD_TYPE_ORDER = {
    "Attribute": 0,
    "TabularSection": 1,
    "Form": 2,
    "Template": 3,
    "Command": 4,
}


# ── Format version ───────────────────────────────────────────
# Проверенный диапазон версий формата выгрузки: 2.17 (8.3.24) … 2.21 (8.5). Полная лестница —
# docs/1c-configuration-spec.md, «Лестница версий». Версию задаёт платформа ВЫГРУЗКИ, а не режим
# совместимости конфигурации. Версии ниже 2.17 (платформы 8.3.23 и старше) существуют, но навыки
# на них не проверялись — это предупреждение о непокрытии, а не о некорректности файла.
FORMAT_VERIFIED_MIN = "2.17"
FORMAT_VERIFIED_MAX = "2.21"


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 localname(el):
    return etree.QName(el.tag).localname


def main():
    sys.stdout.reconfigure(encoding="utf-8")
    sys.stderr.reconfigure(encoding="utf-8")
    parser = argparse.ArgumentParser(description="Validate 1C external data processor/report structure", allow_abbrev=False)
    parser.add_argument("-ObjectPath", "-Path", required=True)
    parser.add_argument("-Detailed", action="store_true")
    parser.add_argument("-MaxErrors", type=int, default=30)
    parser.add_argument("-OutFile", default=None)
    args = ci_parse_args(parser)

    max_errors = args.MaxErrors

    # --- Resolve path ---
    object_path = args.ObjectPath
    if not os.path.isabs(object_path):
        object_path = os.path.join(os.getcwd(), object_path)

    if os.path.isdir(object_path):
        dir_name = os.path.basename(object_path)
        candidate = os.path.join(object_path, f"{dir_name}.xml")
        sibling = os.path.join(os.path.dirname(object_path), f"{dir_name}.xml")
        if os.path.isfile(candidate):
            object_path = candidate
        elif os.path.isfile(sibling):
            object_path = sibling
        else:
            xml_files = [f for f in os.listdir(object_path) if f.lower().endswith(".xml")]
            if xml_files:
                object_path = os.path.join(object_path, xml_files[0])
            else:
                print(f"[ERROR] No XML file found in directory: {object_path}")
                sys.exit(1)

    if not os.path.isfile(object_path):
        file_name = os.path.splitext(os.path.basename(object_path))[0]
        parent_dir = os.path.dirname(object_path)
        parent_dir_name = os.path.basename(parent_dir)
        if file_name == parent_dir_name:
            candidate = os.path.join(os.path.dirname(parent_dir), f"{file_name}.xml")
            if os.path.isfile(candidate):
                object_path = candidate

    if not os.path.isfile(object_path):
        print(f"[ERROR] File not found: {object_path}")
        sys.exit(1)

    resolved_path = os.path.abspath(object_path)
    src_dir = os.path.dirname(resolved_path)

    # --- Output infrastructure ---
    detailed = args.Detailed
    errors = 0
    warnings = 0
    ok_count = 0
    stopped = False
    output_lines = []

    def out_line(msg):
        output_lines.append(msg)

    def report_ok(msg):
        nonlocal ok_count
        ok_count += 1
        if detailed:
            out_line(f"[OK]    {msg}")

    def report_error(msg):
        nonlocal errors, stopped
        errors += 1
        out_line(f"[ERROR] {msg}")
        if errors >= max_errors:
            stopped = True

    def report_warn(msg):
        nonlocal warnings
        warnings += 1
        out_line(f"[WARN]  {msg}")

    def finalize():
        checks = ok_count + errors + warnings
        if errors == 0 and warnings == 0 and not detailed:
            result = f"=== Validation OK: {short_type}.{obj_name} ({checks} checks) ==="
        else:
            out_line("")
            out_line(f"=== Result: {errors} errors, {warnings} warnings ({checks} checks) ===")
            result = "\n".join(output_lines)
        print(result)
        if args.OutFile:
            with open(args.OutFile, "w", encoding="utf-8-sig") as fh:
                fh.write(result)
            print(f"Written to: {args.OutFile}")

    # --- 1. Parse XML ---
    out_line("")

    try:
        xml_parser = etree.XMLParser(remove_blank_text=True)
        tree = etree.parse(resolved_path, xml_parser)
    except Exception as e:
        out_line("=== Validation: (parse failed) ===")
        out_line("")
        report_error(f"1. XML parse failed: {e}")
        finalize()
        sys.exit(1)

    root = tree.getroot()

    # --- Check 1: Root structure ---
    check1_ok = True

    if localname(root) != "MetaDataObject":
        report_error(f"1. Root element is '{localname(root)}', expected 'MetaDataObject'")
        finalize()
        sys.exit(1)

    expected_ns = MD_NS
    if root.tag.split("}")[0].lstrip("{") != expected_ns:
        report_error(f"1. Root namespace is '{root.tag.split('}')[0].lstrip('{')}', expected '{expected_ns}'")
        check1_ok = False

    version = root.get("version", "")
    version_rank = format_rank(version)
    if not version:
        report_warn("1. Missing version attribute on MetaDataObject")
    elif version_rank == 0:
        report_error(f"1. Malformed version '{version}' (expected N.N)")
    elif version_rank < format_rank(FORMAT_VERIFIED_MIN):
        report_warn(f"1. Format version '{version}' is below the tested range "
                    f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — skills were not verified on it")
    elif version_rank > format_rank(FORMAT_VERIFIED_MAX):
        report_warn(f"1. Format version '{version}' is above the tested range "
                    f"{FORMAT_VERIFIED_MIN}-{FORMAT_VERIFIED_MAX} — skills were not verified on it")

    # Detect type
    child_elements = []
    for child in root:
        if isinstance(child.tag, str) and child.tag.startswith(f"{{{expected_ns}}}"):
            child_elements.append(child)

    if not child_elements:
        report_error("1. No metadata type element found inside MetaDataObject")
        finalize()
        sys.exit(1)
    elif len(child_elements) > 1:
        report_error(f"1. Multiple type elements found: {[localname(c) for c in child_elements]}")
        check1_ok = False

    type_node = child_elements[0]
    md_type = localname(type_node)

    if md_type not in ("ExternalDataProcessor", "ExternalReport"):
        report_error(f"1. Unexpected type '{md_type}' (expected ExternalDataProcessor or ExternalReport)")
        finalize()
        sys.exit(1)

    type_uuid = type_node.get("uuid", "")
    if not type_uuid:
        report_error(f"1. Missing uuid on <{md_type}>")
        check1_ok = False
    elif not GUID_PATTERN.match(type_uuid):
        report_error(f"1. Invalid uuid '{type_uuid}' on <{md_type}>")
        check1_ok = False

    props_node = type_node.find(f"{{{MD_NS}}}Properties")
    name_node = props_node.find(f"{{{MD_NS}}}Name") if props_node is not None else None
    obj_name = name_node.text if name_node is not None and name_node.text else "(unknown)"

    short_type = "EPF" if md_type == "ExternalDataProcessor" else "ERF"
    output_lines.insert(0, f"=== Validation: {short_type}.{obj_name} ===")

    if check1_ok:
        report_ok(f"1. Root structure: MetaDataObject/{md_type}, version {version}")

    if stopped:
        finalize()
        sys.exit(1)

    # --- Check 2: InternalInfo ---
    internal_info = type_node.find(f"{{{MD_NS}}}InternalInfo")
    if internal_info is None:
        report_error("2. InternalInfo block missing")
    else:
        check2_ok = True

        contained_obj = internal_info.find(f"{{{XR_NS}}}ContainedObject")
        if contained_obj is None:
            report_error("2. InternalInfo: missing xr:ContainedObject")
            check2_ok = False
        else:
            class_id_node = contained_obj.find(f"{{{XR_NS}}}ClassId")
            object_id_node = contained_obj.find(f"{{{XR_NS}}}ObjectId")

            expected_class_id = CLASS_IDS[md_type]
            if class_id_node is None or not class_id_node.text:
                report_error("2. Missing ClassId in ContainedObject")
                check2_ok = False
            elif class_id_node.text != expected_class_id:
                report_error(f"2. ClassId is '{class_id_node.text}', expected '{expected_class_id}' for {md_type}")
                check2_ok = False

            if object_id_node is not None and object_id_node.text and not GUID_PATTERN.match(object_id_node.text):
                report_error("2. Invalid ObjectId UUID")
                check2_ok = False

        gen_types = internal_info.findall(f"{{{XR_NS}}}GeneratedType")
        if not gen_types:
            report_error("2. No GeneratedType entries found")
            check2_ok = False
        else:
            for gt in gen_types:
                gt_name = gt.get("name", "")
                gt_category = gt.get("category", "")

                if gt_category != "Object":
                    report_warn(f"2. Unexpected GeneratedType category '{gt_category}' (expected 'Object')")

                expected_prefix = f"{md_type}Object."
                if gt_name and obj_name != "(unknown)" and not gt_name.startswith(expected_prefix):
                    report_warn(f"2. GeneratedType name '{gt_name}' does not start with '{expected_prefix}'")

                type_id = gt.find(f"{{{XR_NS}}}TypeId")
                value_id = gt.find(f"{{{XR_NS}}}ValueId")
                if type_id is not None and type_id.text and not GUID_PATTERN.match(type_id.text):
                    report_error("2. Invalid TypeId UUID in GeneratedType")
                    check2_ok = False
                if value_id is not None and value_id.text and not GUID_PATTERN.match(value_id.text):
                    report_error("2. Invalid ValueId UUID in GeneratedType")
                    check2_ok = False

        if check2_ok:
            report_ok(f"2. InternalInfo: ClassId correct, {len(gen_types)} GeneratedType")

    if stopped:
        finalize()
        sys.exit(1)

    # --- Check 3: Properties ---
    if props_node is None:
        report_error("3. Properties block missing")
    else:
        check3_ok = True

        if name_node is None or not name_node.text:
            report_error("3. Properties: Name is missing or empty")
            check3_ok = False
        else:
            name_val = name_node.text
            if not IDENT_PATTERN.match(name_val):
                report_error(f"3. Properties: Name '{name_val}' is not a valid 1C identifier")
                check3_ok = False
            if len(name_val) > 80:
                report_warn(f"3. Properties: Name '{name_val}' exceeds 80 characters ({len(name_val)})")

        syn_node = props_node.find(f"{{{MD_NS}}}Synonym")
        syn_present = False
        if syn_node is not None:
            syn_item = syn_node.find(f"{{{V8_NS}}}item")
            if syn_item is not None:
                syn_content = syn_item.find(f"{{{V8_NS}}}content")
                if syn_content is not None and syn_content.text:
                    syn_present = True

        default_form_node = props_node.find(f"{{{MD_NS}}}DefaultForm")
        default_form_val = (default_form_node.text or "").strip() if default_form_node is not None else ""

        aux_form_node = props_node.find(f"{{{MD_NS}}}AuxiliaryForm")
        aux_form_val = (aux_form_node.text or "").strip() if aux_form_node is not None else ""

        main_dcs_val = ""
        if md_type == "ExternalReport":
            main_dcs_node = props_node.find(f"{{{MD_NS}}}MainDataCompositionSchema")
            main_dcs_val = (main_dcs_node.text or "").strip() if main_dcs_node is not None else ""

        if check3_ok:
            syn_info = "Synonym present" if syn_present else "no Synonym"
            extras = ""
            if default_form_val:
                extras += ", DefaultForm set"
            if main_dcs_val:
                extras += ", MainDCS set"
            report_ok(f'3. Properties: Name="{obj_name}", {syn_info}{extras}')

    if stopped:
        finalize()
        sys.exit(1)

    # --- Check 4: ChildObjects ---
    child_obj_node = type_node.find(f"{{{MD_NS}}}ChildObjects")
    form_names = []
    template_names = []

    if child_obj_node is not None:
        check4_ok = True
        child_counts = {}
        last_order = -1
        order_ok = True

        for child in child_obj_node:
            if not isinstance(child.tag, str):
                continue
            child_tag = localname(child)

            if child_tag not in ALLOWED_CHILD_TYPES:
                report_error(f"4. ChildObjects: disallowed element '{child_tag}'")
                check4_ok = False
                continue

            child_counts[child_tag] = child_counts.get(child_tag, 0) + 1

            this_order = CHILD_TYPE_ORDER.get(child_tag, -1)
            if this_order < last_order and order_ok:
                report_warn(f"4. ChildObjects: '{child_tag}' appears after higher-order elements (expected: Attribute, TabularSection, Form, Template, Command)")
                order_ok = False
            last_order = this_order

            if child_tag == "Form":
                form_names.append((child.text or "").strip())
            elif child_tag == "Template":
                template_names.append((child.text or "").strip())

        if check4_ok:
            summary = ", ".join(f"{k}({v})" for k, v in sorted(child_counts.items(), key=lambda x: CHILD_TYPE_ORDER.get(x[0], 99)))
            if summary:
                report_ok(f"4. ChildObjects: {summary}")
            else:
                report_ok("4. ChildObjects: empty")
    else:
        pass  # no ChildObjects — nothing to check

    if stopped:
        finalize()
        sys.exit(1)

    # --- Check 5: DefaultForm / MainDCS cross-references ---
    check5_ok = True

    if default_form_val:
        expected_prefix = f"{md_type}.{obj_name}.Form."
        if default_form_val.startswith(expected_prefix):
            ref_form_name = default_form_val[len(expected_prefix):]
            if ref_form_name not in form_names:
                report_error(f"5. DefaultForm references '{ref_form_name}', but no such Form in ChildObjects")
                check5_ok = False
        else:
            report_warn(f"5. DefaultForm value '{default_form_val}' has unexpected prefix (expected '{expected_prefix}...')")

    if aux_form_val:
        expected_prefix = f"{md_type}.{obj_name}.Form."
        if aux_form_val.startswith(expected_prefix):
            ref_form_name = aux_form_val[len(expected_prefix):]
            if ref_form_name not in form_names:
                report_error(f"5. AuxiliaryForm references '{ref_form_name}', but no such Form in ChildObjects")
                check5_ok = False

    if main_dcs_val and md_type == "ExternalReport":
        expected_prefix = f"ExternalReport.{obj_name}.Template."
        if main_dcs_val.startswith(expected_prefix):
            ref_tpl_name = main_dcs_val[len(expected_prefix):]
            if ref_tpl_name not in template_names:
                report_error(f"5. MainDataCompositionSchema references '{ref_tpl_name}', but no such Template in ChildObjects")
                check5_ok = False
        else:
            report_warn(f"5. MainDataCompositionSchema value '{main_dcs_val}' has unexpected prefix")

    if check5_ok:
        refs = []
        if default_form_val:
            refs.append("DefaultForm")
        if aux_form_val:
            refs.append("AuxiliaryForm")
        if main_dcs_val:
            refs.append("MainDCS")
        if refs:
            report_ok(f"5. Cross-references: {', '.join(refs)} valid")
        else:
            pass  # no cross-references to check

    if stopped:
        finalize()
        sys.exit(1)

    # --- Check 6: Attributes ---
    def check_attribute(node, context):
        uuid = node.get("uuid", "")
        if not uuid:
            report_error(f"6. {context}Attribute missing uuid")
            return False
        if not GUID_PATTERN.match(uuid):
            report_error(f"6. {context}Attribute has invalid uuid '{uuid}'")
            return False

        el_props = node.find(f"{{{MD_NS}}}Properties")
        if el_props is None:
            report_error(f"6. {context}Attribute (uuid={uuid}) missing Properties")
            return False

        el_name = el_props.find(f"{{{MD_NS}}}Name")
        if el_name is None or not el_name.text:
            report_error(f"6. {context}Attribute (uuid={uuid}) missing or empty Name")
            return False

        name_val = el_name.text
        if not IDENT_PATTERN.match(name_val):
            report_error(f"6. {context}Attribute '{name_val}' has invalid identifier")
            return False

        type_el = el_props.find(f"{{{MD_NS}}}Type")
        if type_el is None:
            report_error(f"6. {context}Attribute '{name_val}' missing Type block")
            return False

        v8_types = type_el.findall(f"{{{V8_NS}}}Type")
        v8_type_sets = type_el.findall(f"{{{V8_NS}}}TypeSet")
        if not v8_types and not v8_type_sets:
            report_error(f"6. {context}Attribute '{name_val}' Type block has no v8:Type or v8:TypeSet")
            return False

        return True

    if child_obj_node is not None:
        attrs = child_obj_node.findall(f"{{{MD_NS}}}Attribute")
        check6_ok = True
        attr_count = 0

        for attr in attrs:
            if stopped:
                break
            ok = check_attribute(attr, "")
            if not ok:
                check6_ok = False
            attr_count += 1

        if attr_count > 0:
            if check6_ok:
                report_ok(f"6. Attributes: {attr_count} checked (UUID, Name, Type)")
        else:
            pass  # no attributes
    else:
        pass  # no ChildObjects

    if stopped:
        finalize()
        sys.exit(1)

    # --- Check 7: TabularSections ---
    if child_obj_node is not None:
        ts_sections = child_obj_node.findall(f"{{{MD_NS}}}TabularSection")
        if ts_sections:
            check7_ok = True
            ts_count = 0
            ts_attr_total = 0

            for ts in ts_sections:
                if stopped:
                    break
                ts_count += 1

                ts_uuid = ts.get("uuid", "")
                if not ts_uuid or not GUID_PATTERN.match(ts_uuid):
                    report_error(f"7. TabularSection #{ts_count}: invalid or missing uuid")
                    check7_ok = False

                ts_props = ts.find(f"{{{MD_NS}}}Properties")
                ts_name_node = ts_props.find(f"{{{MD_NS}}}Name") if ts_props is not None else None
                ts_name = ts_name_node.text if ts_name_node is not None and ts_name_node.text else "(unnamed)"

                if ts_name_node is None or not ts_name_node.text:
                    report_error(f"7. TabularSection #{ts_count}: missing or empty Name")
                    check7_ok = False
                elif not IDENT_PATTERN.match(ts_name):
                    report_error(f"7. TabularSection '{ts_name}': invalid identifier")
                    check7_ok = False

                ts_int_info = ts.find(f"{{{MD_NS}}}InternalInfo")
                if ts_int_info is not None:
                    ts_gens = ts_int_info.findall(f"{{{XR_NS}}}GeneratedType")
                    if len(ts_gens) < 2:
                        report_warn(f"7. TabularSection '{ts_name}': expected 2 GeneratedType, found {len(ts_gens)}")

                ts_child_obj = ts.find(f"{{{MD_NS}}}ChildObjects")
                if ts_child_obj is not None:
                    ts_attrs = ts_child_obj.findall(f"{{{MD_NS}}}Attribute")
                    ts_attr_names = {}
                    for ta in ts_attrs:
                        ta_ok = check_attribute(ta, f"TabularSection '{ts_name}'.")
                        if not ta_ok:
                            check7_ok = False
                        ts_attr_total += 1

                        ta_props = ta.find(f"{{{MD_NS}}}Properties")
                        if ta_props is not None:
                            ta_name_node = ta_props.find(f"{{{MD_NS}}}Name")
                            if ta_name_node is not None and ta_name_node.text:
                                if ta_name_node.text in ts_attr_names:
                                    report_error(f"7. Duplicate attribute '{ta_name_node.text}' in TabularSection '{ts_name}'")
                                    check7_ok = False
                                else:
                                    ts_attr_names[ta_name_node.text] = True

            if check7_ok:
                report_ok(f"7. TabularSections: {ts_count} sections, {ts_attr_total} inner attributes")
        else:
            pass  # no tabular sections
    else:
        pass  # no ChildObjects

    if stopped:
        finalize()
        sys.exit(1)

    # --- Check 8: Name uniqueness ---
    check8_ok = True
    all_names = {}

    if child_obj_node is not None:
        name_kinds = [
            ("Attribute", f"{{{MD_NS}}}Attribute"),
            ("TabularSection", f"{{{MD_NS}}}TabularSection"),
            ("Command", f"{{{MD_NS}}}Command"),
        ]

        for kind, xpath in name_kinds:
            nodes = child_obj_node.findall(xpath)
            for node in nodes:
                np = node.find(f"{{{MD_NS}}}Properties")
                if np is not None:
                    nn = np.find(f"{{{MD_NS}}}Name")
                    if nn is not None and nn.text:
                        nv = nn.text
                        if nv in all_names:
                            report_error(f"8. Duplicate name '{nv}' ({kind} conflicts with {all_names[nv]})")
                            check8_ok = False
                        else:
                            all_names[nv] = kind

        for fn in form_names:
            if fn in all_names:
                report_error(f"8. Duplicate name '{fn}' (Form conflicts with {all_names[fn]})")
                check8_ok = False
            else:
                all_names[fn] = "Form"
        for tn in template_names:
            if tn in all_names:
                report_error(f"8. Duplicate name '{tn}' (Template conflicts with {all_names[tn]})")
                check8_ok = False
            else:
                all_names[tn] = "Template"

    if check8_ok:
        report_ok(f"8. Name uniqueness: {len(all_names)} names, all unique")

    if stopped:
        finalize()
        sys.exit(1)

    # --- Check 9: File existence ---
    check9_ok = True
    files_checked = 0
    obj_dir = os.path.join(src_dir, obj_name)

    for fn in form_names:
        form_meta_xml = os.path.join(obj_dir, "Forms", f"{fn}.xml")
        if not os.path.isfile(form_meta_xml):
            report_error(f"9. Missing form descriptor: Forms/{fn}.xml")
            check9_ok = False
        else:
            files_checked += 1

        form_xml = os.path.join(obj_dir, "Forms", fn, "Ext", "Form.xml")
        if not os.path.isfile(form_xml):
            report_error(f"9. Missing form layout: Forms/{fn}/Ext/Form.xml")
            check9_ok = False
        else:
            files_checked += 1

    for tn in template_names:
        tpl_meta_xml = os.path.join(obj_dir, "Templates", f"{tn}.xml")
        if not os.path.isfile(tpl_meta_xml):
            report_error(f"9. Missing template descriptor: Templates/{tn}.xml")
            check9_ok = False
        else:
            files_checked += 1

        tpl_ext_dir = os.path.join(obj_dir, "Templates", tn, "Ext")
        if os.path.isdir(tpl_ext_dir):
            tpl_files = [f for f in os.listdir(tpl_ext_dir) if f.startswith("Template.")]
            if not tpl_files:
                report_error(f"9. Missing template content: Templates/{tn}/Ext/Template.*")
                check9_ok = False
            else:
                files_checked += 1
        else:
            report_error(f"9. Missing template Ext directory: Templates/{tn}/Ext/")
            check9_ok = False

    obj_module = os.path.join(obj_dir, "Ext", "ObjectModule.bsl")
    if os.path.isfile(obj_module):
        files_checked += 1

    if check9_ok:
        if files_checked > 0:
            report_ok(f"9. File existence: {files_checked} files verified")
        else:
            pass  # no forms/templates to check

    if stopped:
        finalize()
        sys.exit(1)

    # --- Check 10: Form descriptors structure ---
    check10_ok = True
    forms_checked = 0

    for fn in form_names:
        form_meta_xml = os.path.join(obj_dir, "Forms", f"{fn}.xml")
        if not os.path.isfile(form_meta_xml):
            continue

        try:
            f_parser = etree.XMLParser(remove_blank_text=True)
            f_tree = etree.parse(form_meta_xml, f_parser)
            f_root = f_tree.getroot()

            if localname(f_root) != "MetaDataObject":
                report_error(f"10. Form '{fn}': root element is '{localname(f_root)}', expected 'MetaDataObject'")
                check10_ok = False
                continue

            f_type_node = f_root.find(f"{{{MD_NS}}}Form")
            if f_type_node is None:
                report_error(f"10. Form '{fn}': missing <Form> element")
                check10_ok = False
                continue

            f_uuid = f_type_node.get("uuid", "")
            if not f_uuid or not GUID_PATTERN.match(f_uuid):
                report_error(f"10. Form '{fn}': invalid or missing uuid")
                check10_ok = False

            f_props = f_type_node.find(f"{{{MD_NS}}}Properties")
            if f_props is not None:
                f_name = f_props.find(f"{{{MD_NS}}}Name")
                if f_name is not None and f_name.text != fn:
                    report_error(f"10. Form '{fn}': Name in descriptor is '{f_name.text}', expected '{fn}'")
                    check10_ok = False

                f_type = f_props.find(f"{{{MD_NS}}}FormType")
                if f_type is not None and f_type.text != "Managed":
                    report_warn(f"10. Form '{fn}': FormType is '{f_type.text}' (expected 'Managed')")

            forms_checked += 1
        except Exception as e:
            report_error(f"10. Form '{fn}': XML parse error: {e}")
            check10_ok = False

    if check10_ok:
        if forms_checked > 0:
            report_ok(f"10. Form descriptors: {forms_checked} checked")
        else:
            pass  # no form descriptors to check

    # --- Final output ---
    finalize()

    if errors > 0:
        sys.exit(1)
    sys.exit(0)


if __name__ == "__main__":
    main()
