#!/usr/bin/env python3
# skd-info v1.12 — Analyze 1C DCS structure
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills

import argparse
import os
import re
import sys
from collections import OrderedDict
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)


S_NS = "http://v8.1c.ru/8.1/data-composition-system/schema"
DCSCOM_NS = "http://v8.1c.ru/8.1/data-composition-system/common"
DCSCOR_NS = "http://v8.1c.ru/8.1/data-composition-system/core"
DCSSET_NS = "http://v8.1c.ru/8.1/data-composition-system/settings"
V8_NS = "http://v8.1c.ru/8.1/data/core"
V8UI_NS = "http://v8.1c.ru/8.1/data/ui"
XS_NS = "http://www.w3.org/2001/XMLSchema"
XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
DCSAT_NS = "http://v8.1c.ru/8.1/data-composition-system/area-template"

NSMAP = {
    "s": S_NS,
    "dcscom": DCSCOM_NS,
    "dcscor": DCSCOR_NS,
    "dcsset": DCSSET_NS,
    "v8": V8_NS,
    "v8ui": V8UI_NS,
    "xs": XS_NS,
    "xsi": XSI_NS,
    "dcsat": DCSAT_NS,
}


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


def get_ml_text(node):
    if node is None:
        return ""
    content = node.find("v8:item/v8:content", NSMAP)
    if content is not None and content.text:
        return content.text
    text = (node.text or "").strip()
    if text:
        return text
    # Also check inner text (all text content)
    all_text = "".join(node.itertext()).strip()
    if all_text:
        return all_text
    return ""


def unescape_xml(text):
    if not text:
        return text
    text = text.replace("&amp;", "&")
    text = text.replace("&gt;", ">")
    text = text.replace("&lt;", "<")
    text = text.replace("&quot;", '"')
    text = text.replace("&apos;", "'")
    return text


def get_compact_type(value_type_node):
    if value_type_node is None:
        return ""
    types = []
    for t in value_type_node.findall("v8:Type", NSMAP):
        raw = (t.text or "").strip()
        if raw == "xs:string":
            types.append("String")
        elif raw == "xs:decimal":
            types.append("Number")
        elif raw == "xs:boolean":
            types.append("Boolean")
        elif raw == "xs:dateTime":
            types.append("DateTime")
        elif raw == "v8:StandardPeriod":
            types.append("StandardPeriod")
        elif raw == "v8:StandardBeginningDate":
            types.append("StandardBeginningDate")
        elif raw == "v8:AccountType":
            types.append("AccountType")
        elif raw == "v8:Null":
            types.append("Null")
        else:
            # Strip namespace prefixes like d4p1: cfg:
            clean = re.sub(r'^[a-zA-Z0-9]+:', '', raw)
            types.append(clean)
    if not types:
        return ""
    return " | ".join(types)


def get_dataset_type(ds_node):
    xsi_type = ds_node.get(f"{{{XSI_NS}}}type", "")
    if "DataSetQuery" in xsi_type:
        return "Query"
    if "DataSetObject" in xsi_type:
        return "Object"
    if "DataSetUnion" in xsi_type:
        return "Union"
    return "Unknown"


def get_field_count(ds_node):
    return len(ds_node.findall("s:field", NSMAP))


def get_query_line_count(ds_node):
    query_node = ds_node.find("s:query", NSMAP)
    if query_node is None:
        return 0
    text = "".join(query_node.itertext())
    return len(text.split("\n"))


def get_structure_item_type(item_node):
    xsi_type = item_node.get(f"{{{XSI_NS}}}type", "")
    if "StructureItemGroup" in xsi_type:
        return "Group"
    if "StructureItemTable" in xsi_type:
        return "Table"
    if "StructureItemChart" in xsi_type:
        return "Chart"
    return "Unknown"


def get_group_fields(item_node):
    fields = []
    for gi in item_node.findall("dcsset:groupItems/dcsset:item", NSMAP):
        field_node = gi.find("dcsset:field", NSMAP)
        group_type = gi.find("dcsset:groupType", NSMAP)
        if field_node is not None:
            f = (field_node.text or "").strip()
            gt = (group_type.text or "").strip() if group_type is not None else ""
            if gt and gt != "Items":
                f += f"({gt})"
            fields.append(f)
    return fields


def get_selection_fields(item_node):
    fields = []
    for si in item_node.findall("dcsset:selection/dcsset:item", NSMAP):
        xsi_type = si.get(f"{{{XSI_NS}}}type", "")
        if "SelectedItemAuto" in xsi_type:
            fields.append("Auto")
        elif "SelectedItemField" in xsi_type:
            f = si.find("dcsset:field", NSMAP)
            if f is not None:
                fields.append((f.text or "").strip())
        elif "SelectedItemFolder" in xsi_type:
            fields.append("Folder")
    return fields


def get_filter_summary(settings_node):
    filters = []
    for fi in settings_node.findall("dcsset:filter/dcsset:item", NSMAP):
        xsi_type = fi.get(f"{{{XSI_NS}}}type", "")

        if "FilterItemGroup" in xsi_type:
            group_type = fi.find("dcsset:groupType", NSMAP)
            gt = (group_type.text or "").strip() if group_type is not None else "And"
            sub_count = len(fi.findall("dcsset:item", NSMAP))
            filters.append(f"[Group:{gt} {sub_count} items]")
            continue

        use = fi.find("dcsset:use", NSMAP)
        is_active = "[ ]" if (use is not None and (use.text or "").strip() == "false") else "[x]"

        left = fi.find("dcsset:left", NSMAP)
        comp = fi.find("dcsset:comparisonType", NSMAP)
        right = fi.find("dcsset:right", NSMAP)
        pres = fi.find("dcsset:presentation", NSMAP)
        user_setting = fi.find("dcsset:userSettingID", NSMAP)

        left_str = (left.text or "").strip() if left is not None else "?"
        comp_str = (comp.text or "").strip() if comp is not None else "?"
        right_str = ""
        if right is not None:
            right_text = "".join(right.itertext()).strip()
            right_str = f" {right_text}"

        pres_str = ""
        if pres is not None:
            pt = get_ml_text(pres)
            if pt:
                pres_str = f'  "{pt}"'

        user_str = ""
        if user_setting is not None:
            user_str = "  [user]"

        filters.append(f"{is_active} {left_str} {comp_str}{right_str}{pres_str}{user_str}")
    return filters


def build_structure_tree(item_node, prefix, is_last, out_lines):
    item_type = get_structure_item_type(item_node)
    name_node = item_node.find("dcsset:name", NSMAP)
    item_name = (name_node.text or "").strip() if name_node is not None else ""

    group_fields = get_group_fields(item_node)
    group_str = "[" + ", ".join(group_fields) + "]" if group_fields else "(detail)"

    sel_fields = get_selection_fields(item_node)
    sel_str = "Selection: " + ", ".join(sel_fields) if sel_fields else ""

    line = ""
    if item_type == "Group":
        line = f"{item_type} {group_str}"
        if item_name:
            line = f'{item_type} "{item_name}" {group_str}'
    elif item_type == "Table":
        line = "Table"
        if item_name:
            line = f'Table "{item_name}"'
    elif item_type == "Chart":
        line = "Chart"
        if item_name:
            line = f'Chart "{item_name}"'

    out_lines.append(f"{prefix}{line}")
    if sel_str and item_type == "Group":
        out_lines.append(f"{prefix}      {sel_str}")

    # For Table, show columns and rows
    if item_type == "Table":
        columns = item_node.findall("dcsset:column", NSMAP)
        rows = item_node.findall("dcsset:row", NSMAP)

        for col in columns:
            col_group = get_group_fields(col)
            col_group_str = "[" + ", ".join(col_group) + "]" if col_group else "(detail)"
            col_sel = get_selection_fields(col)
            col_sel_str = "Selection: " + ", ".join(col_sel) if col_sel else ""
            conn_c = "\u251C\u2500\u2500" if rows else "\u2514\u2500\u2500"
            cont_c = "\u2502     " if rows else "      "
            out_lines.append(f"{prefix}{conn_c} Columns: {col_group_str}")
            if col_sel_str:
                out_lines.append(f"{prefix}{cont_c} {col_sel_str}")

        for row in rows:
            row_group = get_group_fields(row)
            row_group_str = "[" + ", ".join(row_group) + "]" if row_group else "(detail)"
            row_sel = get_selection_fields(row)
            row_sel_str = "Selection: " + ", ".join(row_sel) if row_sel else ""
            out_lines.append(f"{prefix}\u2514\u2500\u2500 Rows: {row_group_str}")
            if row_sel_str:
                out_lines.append(f"{prefix}      {row_sel_str}")

    # Recurse into nested structure items (for Group)
    if item_type == "Group":
        children = item_node.findall("dcsset:item", NSMAP)
        for i, child in enumerate(children):
            child_type = child.get(f"{{{XSI_NS}}}type", "")
            if "StructureItem" in child_type:
                last = (i == len(children) - 1)
                connector = "\u2514\u2500 " if last else "\u251C\u2500 "
                continuation = "    " if last else "\u2502   "
                build_structure_tree(child, f"{prefix}{continuation}", last, out_lines)


def get_support_status_for_path(target_path):
    try:
        def 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:
                pass
            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
        rp = os.path.abspath(target_path)
        # The target file itself may be the element meta-xml (e.g. Subsystems/X.xml).
        elem_uuid = root_uuid(rp)
        if _sg_is_external_root(rp):
            return None
        bin_path = None
        d = os.path.dirname(rp)
        for _ in range(12):
            if not d:
                break
            if _sg_is_external_root(d + ".xml"):
                return None
            if not elem_uuid:
                elem_uuid = root_uuid(d + ".xml")
            if not bin_path:
                cand = os.path.join(d, "Ext", "ParentConfigurations.bin")
                if os.path.exists(cand) or os.path.exists(os.path.join(d, "Configuration.xml")):
                    bin_path = cand
            if elem_uuid and bin_path:
                break
            parent = os.path.dirname(d)
            if parent == d:
                break
            d = parent
        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 "снято с поддержки (правки свободны)"
        if g == 1:
            return "конфигурация read-only (возможность изменения выключена) — правки невозможны без включения"
        if not elem_uuid:
            return "не на поддержке"
        best = None
        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
        if best is None:
            return "не на поддержке"
        return {
            0: "на замке — прямая правка сломает обновления; дорабатывай через cfe-* либо включи редактирование объекта",
            1: "редактируется с сохранением поддержки",
            2: "снято с поддержки (правки свободны)",
        }.get(best, "не на поддержке")
    except Exception:
        return "не на поддержке"


def main():
    sys.stdout.reconfigure(encoding="utf-8")
    sys.stderr.reconfigure(encoding="utf-8")
    parser = argparse.ArgumentParser(description="Analyze 1C DCS structure", allow_abbrev=False)
    parser.add_argument("-TemplatePath", "-Path", required=True)
    parser.add_argument("-Mode", default="overview",
                        choices=["overview", "query", "fields", "links", "calculated",
                                 "resources", "params", "variant", "trace", "templates", "full"])
    parser.add_argument("-Name", default=None)
    parser.add_argument("-Batch", type=int, default=0)
    parser.add_argument("-Limit", type=int, default=150)
    parser.add_argument("-Offset", type=int, default=0)
    parser.add_argument("-OutFile", default=None)
    parser.add_argument("-Raw", action="store_true")
    args = ci_parse_args(parser)

    # --- Resolve path ---
    original_path = args.TemplatePath
    template_path = original_path
    if not template_path.endswith(".xml"):
        candidate = os.path.join(template_path, "Ext", "Template.xml")
        if os.path.isfile(candidate):
            template_path = candidate

    # If still not found, try resolving from object directory (Reports/X, DataProcessors/X)
    if not os.path.isfile(template_path) and not template_path.endswith(".xml"):
        templates_dir = os.path.join(original_path, "Templates")
        if os.path.isdir(templates_dir):
            dcs_templates = []
            for fname in os.listdir(templates_dir):
                if not fname.endswith(".xml"):
                    continue
                meta_path = os.path.join(templates_dir, fname)
                if not os.path.isfile(meta_path):
                    continue
                try:
                    meta_tree = etree.parse(meta_path, etree.XMLParser(remove_blank_text=True))
                    tt_nodes = meta_tree.xpath("//*[local-name()='TemplateType']")
                    if tt_nodes and (tt_nodes[0].text or "").strip() == "DataCompositionSchema":
                        tpl_name = os.path.splitext(fname)[0]
                        tpl_path = os.path.join(templates_dir, tpl_name, "Ext", "Template.xml")
                        if os.path.isfile(tpl_path):
                            dcs_templates.append(tpl_path)
                except Exception:
                    continue
            if len(dcs_templates) == 1:
                template_path = dcs_templates[0]
                resolved_display = os.path.relpath(os.path.abspath(template_path))
                print(f"[i] Resolved: {resolved_display}")
            elif len(dcs_templates) > 1:
                print(f"Multiple DCS templates found in: {original_path}")
                for i, p in enumerate(dcs_templates):
                    print(f"  {i+1}. {os.path.relpath(os.path.abspath(p))}")
                print("Specify the template path.")
                sys.exit(1)
            else:
                print(f"No DCS templates found in: {original_path}", file=sys.stderr)
                sys.exit(1)

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

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

    resolved_path = os.path.abspath(template_path)

    # --- Load XML ---
    xml_parser = etree.XMLParser(remove_blank_text=True)
    tree = etree.parse(resolved_path, xml_parser)
    root = tree.getroot()

    # --- Output collector ---
    lines = []

    # Determine template name from path
    path_parts = re.split(r'[/\\]', resolved_path)
    template_name = resolved_path
    for i in range(len(path_parts) - 1, -1, -1):
        if path_parts[i] == "Ext" and i >= 1:
            template_name = path_parts[i - 1]
            break

    with open(resolved_path, "r", encoding="utf-8-sig") as fh:
        total_xml_lines = len(fh.readlines())

    # === Helper functions that use root/lines/NSMAP ===

    def show_overview():
        lines.append(f"=== DCS: {template_name} ({total_xml_lines} lines) ===")
        _support = get_support_status_for_path(template_path)
        if _support is not None:
            lines.append(f"Поддержка: {_support}")
        lines.append("")

        # Sources
        sources = []
        for ds in root.findall("s:dataSource", NSMAP):
            ds_name_node = ds.find("s:name", NSMAP)
            ds_type_node = ds.find("s:dataSourceType", NSMAP)
            ds_name = (ds_name_node.text or "") if ds_name_node is not None else ""
            ds_type = (ds_type_node.text or "") if ds_type_node is not None else ""
            sources.append(f"{ds_name} ({ds_type})")
        lines.append("Sources: " + ", ".join(sources))
        lines.append("")

        # Datasets (recursive for Union)
        lines.append("Datasets:")
        for ds in root.findall("s:dataSet", NSMAP):
            ds_type = get_dataset_type(ds)
            ds_name_node = ds.find("s:name", NSMAP)
            ds_name = (ds_name_node.text or "") if ds_name_node is not None else ""
            field_count = get_field_count(ds)

            if ds_type == "Query":
                query_lines = get_query_line_count(ds)
                lines.append(f"  [Query]  {ds_name}   {field_count} fields, query {query_lines} lines")
            elif ds_type == "Object":
                obj_name = ds.find("s:objectName", NSMAP)
                obj_str = f"  objectName={obj_name.text}" if obj_name is not None else ""
                lines.append(f"  [Object] {ds_name}{obj_str}  {field_count} fields")
            elif ds_type == "Union":
                lines.append(f"  [Union]  {ds_name}  {field_count} fields")
                for sub_ds in ds.findall("s:item", NSMAP):
                    sub_type = get_dataset_type(sub_ds)
                    sub_name_node = sub_ds.find("s:name", NSMAP)
                    sub_name_str = (sub_name_node.text or "") if sub_name_node is not None else "?"
                    sub_fields = get_field_count(sub_ds)
                    if sub_type == "Query":
                        sub_query_lines = get_query_line_count(sub_ds)
                        lines.append(f"    \u251C\u2500 [Query] {sub_name_str}   {sub_fields} fields, query {sub_query_lines} lines")
                    elif sub_type == "Object":
                        sub_obj_name = sub_ds.find("s:objectName", NSMAP)
                        sub_obj_str = f"  objectName={sub_obj_name.text}" if sub_obj_name is not None else ""
                        lines.append(f"    \u251C\u2500 [Object] {sub_name_str}{sub_obj_str}  {sub_fields} fields")
                    else:
                        lines.append(f"    \u251C\u2500 [{sub_type}] {sub_name_str}  {sub_fields} fields")

        # Links -- only dataset pairs (not field-level)
        link_nodes = root.findall("s:dataSetLink", NSMAP)
        if link_nodes:
            link_pairs = OrderedDict()
            for lnk in link_nodes:
                src_ds = (lnk.find("s:sourceDataSet", NSMAP).text or "")
                dst_ds = (lnk.find("s:destinationDataSet", NSMAP).text or "")
                key = f"{src_ds} -> {dst_ds}"
                if key not in link_pairs:
                    link_pairs[key] = 0
                link_pairs[key] += 1
            link_strs = []
            for key, cnt in link_pairs.items():
                if cnt > 1:
                    link_strs.append(f"{key} ({cnt} fields)")
                else:
                    link_strs.append(key)
            lines.append("Links: " + ", ".join(link_strs))

        # Calculated fields -- count only
        calc_fields = root.findall("s:calculatedField", NSMAP)
        if calc_fields:
            lines.append(f"Calculated: {len(calc_fields)}")

        # Totals -- count + group flag
        total_fields = root.findall("s:totalField", NSMAP)
        if total_fields:
            has_grouped = False
            unique_paths = {}
            for tf in total_fields:
                tf_path = (tf.find("s:dataPath", NSMAP).text or "")
                unique_paths[tf_path] = True
                if tf.find("s:group", NSMAP) is not None:
                    has_grouped = True
            group_note = ", with group formulas" if has_grouped else ""
            if len(unique_paths) == len(total_fields):
                lines.append(f"Resources: {len(total_fields)}{group_note}")
            else:
                lines.append(f"Resources: {len(total_fields)} ({len(unique_paths)} fields{group_note})")

        # Templates -- count with binding types
        tpl_defs = root.findall("s:template", NSMAP)
        field_tpls = root.findall("s:fieldTemplate", NSMAP)
        group_tpls = root.findall("s:groupTemplate", NSMAP)
        group_header_tpls = root.findall("s:groupHeaderTemplate", NSMAP)
        group_footer_tpls = root.findall("s:groupFooterTemplate", NSMAP)
        if tpl_defs:
            parts = []
            if field_tpls:
                parts.append(f"{len(field_tpls)} field")
            grp_count = len(group_tpls) + len(group_header_tpls) + len(group_footer_tpls)
            if grp_count > 0:
                parts.append(f"{grp_count} group")
            if parts:
                lines.append(f"Templates: {len(tpl_defs)} defined ({', '.join(parts)} bindings)")
            else:
                lines.append(f"Templates: {len(tpl_defs)} defined")

        # Parameters -- split visible/hidden
        params = root.findall("s:parameter", NSMAP)
        if params:
            visible_names = []
            hidden_count = 0
            for p in params:
                p_name = (p.find("s:name", NSMAP).text or "")
                use_restrict = p.find("s:useRestriction", NSMAP)
                is_hidden = (use_restrict is not None and (use_restrict.text or "").strip() == "true")
                if is_hidden:
                    hidden_count += 1
                else:
                    visible_names.append(p_name)
            param_line = f"Params: {len(params)}"
            if hidden_count > 0 and visible_names:
                param_line += f" ({len(visible_names)} visible, {hidden_count} hidden)"
            elif hidden_count == len(params):
                param_line += " (all hidden)"
            if visible_names and len(visible_names) <= 8:
                param_line += ": " + ", ".join(visible_names)
            lines.append(param_line)
        else:
            lines.append("Params: (none)")

        lines.append("")

        # Variants
        variants = root.findall("s:settingsVariant", NSMAP)
        if variants:
            lines.append("Variants:")
            for var_idx, v in enumerate(variants, 1):
                v_name = (v.find("dcsset:name", NSMAP).text or "")
                v_pres = v.find("dcsset:presentation", NSMAP)
                v_pres_str = ""
                if v_pres is not None:
                    pt = get_ml_text(v_pres)
                    if pt:
                        v_pres_str = f'  "{pt}"'

                settings = v.find("dcsset:settings", NSMAP)
                struct_items = []
                if settings is not None:
                    for si in settings.findall("dcsset:item", NSMAP):
                        si_type = get_structure_item_type(si)
                        gf = get_group_fields(si)
                        g_str = "(" + ",".join(gf) + ")" if gf else "(detail)"
                        struct_items.append(f"{si_type}{g_str}")

                # Compact: if many identical items, show count
                if len(struct_items) > 3:
                    from collections import Counter
                    counted = Counter(struct_items)
                    compact_parts = []
                    for name, count in counted.most_common():
                        if count > 1:
                            compact_parts.append(f"{count}x {name}")
                        else:
                            compact_parts.append(name)
                    struct_items = compact_parts

                struct_str = "  " + ", ".join(struct_items) if struct_items else ""

                filter_count = 0
                if settings is not None:
                    filter_count = len(settings.findall("dcsset:filter/dcsset:item", NSMAP))
                filter_str = f"  {filter_count} filters" if filter_count > 0 else ""

                lines.append(f"  [{var_idx}] {v_name}{v_pres_str}{struct_str}{filter_str}")

    def show_overview_hints():
        lines.append("")
        hints = []
        # Collect query dataset names for hint
        query_ds_names = []
        for ds in root.findall("s:dataSet", NSMAP):
            ds_type = get_dataset_type(ds)
            if ds_type == "Query":
                query_ds_names.append((ds.find("s:name", NSMAP).text or ""))
            elif ds_type == "Union":
                for sub_ds in ds.findall("s:item", NSMAP):
                    if get_dataset_type(sub_ds) == "Query":
                        sn = sub_ds.find("s:name", NSMAP)
                        if sn is not None:
                            query_ds_names.append((sn.text or ""))
        if len(query_ds_names) == 1:
            hints.append("-Mode query             query text")
        elif len(query_ds_names) > 1:
            hints.append(f"-Mode query -Name <ds>  query text ({', '.join(query_ds_names)})")
        hints.append("-Mode fields            field tables by dataset")
        link_count = len(root.findall("s:dataSetLink", NSMAP))
        if link_count > 0:
            hints.append(f"-Mode links             dataset connections ({link_count})")
        calc_count = len(root.findall("s:calculatedField", NSMAP))
        total_count = len(root.findall("s:totalField", NSMAP))
        if calc_count > 0:
            hints.append(f"-Mode calculated        calculated field expressions ({calc_count})")
        if total_count > 0:
            hints.append(f"-Mode resources         resource aggregation ({total_count})")
        params = root.findall("s:parameter", NSMAP)
        if params:
            hints.append("-Mode params            parameter details")
        variants = root.findall("s:settingsVariant", NSMAP)
        if len(variants) == 1:
            hints.append("-Mode variant           variant structure")
        elif len(variants) > 1:
            hints.append(f"-Mode variant -Name <N> variant structure (1..{len(variants)})")
        tpl_defs = root.findall("s:template", NSMAP)
        if tpl_defs:
            hints.append("-Mode templates         template bindings and expressions")
        hints.append("-Mode trace -Name <f>   trace field origin (by name or title)")
        hints.append("-Mode full              all sections at once")
        lines.append("Next:")
        for h in hints:
            lines.append(f"  {h}")

    def show_query():
        # Find dataset
        data_sets = root.findall("s:dataSet", NSMAP)
        target_ds = None

        if args.Name:
            # Search by name: prefer nested Query items over parent Union
            # Pass 1: search nested items first
            for ds in data_sets:
                for sub_ds in ds.findall("s:item", NSMAP):
                    sub_name_node = sub_ds.find("s:name", NSMAP)
                    if sub_name_node is not None and (sub_name_node.text or "") == args.Name:
                        target_ds = sub_ds
                        break
                if target_ds is not None:
                    break
            # Pass 2: search top-level
            if target_ds is None:
                for ds in data_sets:
                    ds_name_node = ds.find("s:name", NSMAP)
                    if ds_name_node is not None and (ds_name_node.text or "") == args.Name:
                        target_ds = ds
                        break
            if target_ds is None:
                print(f"Dataset '{args.Name}' not found", file=sys.stderr)
                sys.exit(1)
        else:
            # Take first Query dataset
            for ds in data_sets:
                ds_type = get_dataset_type(ds)
                if ds_type == "Query":
                    target_ds = ds
                    break
                if ds_type == "Union":
                    for sub_ds in ds.findall("s:item", NSMAP):
                        if get_dataset_type(sub_ds) == "Query":
                            target_ds = sub_ds
                            break
                    if target_ds is not None:
                        break
            if target_ds is None:
                print("No Query dataset found", file=sys.stderr)
                sys.exit(1)

        query_node = target_ds.find("s:query", NSMAP)
        if query_node is None:
            # If this is a Union, list nested query datasets
            ds_type = get_dataset_type(target_ds)
            if ds_type == "Union":
                sub_names = []
                for sub_ds in target_ds.findall("s:item", NSMAP):
                    sn = sub_ds.find("s:name", NSMAP)
                    if sn is not None:
                        sub_names.append((sn.text or ""))
                ds_name_text = (target_ds.find("s:name", NSMAP).text or "")
                print(f"Dataset '{ds_name_text}' is a Union. Specify nested: {', '.join(sub_names)}", file=sys.stderr)
            else:
                print("Dataset has no query element", file=sys.stderr)
            sys.exit(1)

        raw_query = unescape_xml("".join(query_node.itertext()))

        # Raw mode: emit verbatim query text only (no headers/TOC/batch split) for round-trip
        if args.Raw:
            for ql in raw_query.strip().split("\n"):
                lines.append(ql.rstrip())
            return

        ds_name_str = (target_ds.find("s:name", NSMAP).text or "")

        # Split into batches
        batches = []
        batch_texts = re.split(r';\s*\r?\n\s*/{16,}\s*\r?\n', raw_query)
        for bt in batch_texts:
            trimmed = bt.strip()
            if trimmed:
                batches.append(trimmed)

        total_query_lines = len(raw_query.split("\n"))

        if len(batches) <= 1:
            # Single query
            lines.append(f"=== Query: {ds_name_str} ({total_query_lines} lines) ===")
            lines.append("")
            for ql in raw_query.strip().split("\n"):
                lines.append(ql.rstrip())
        else:
            lines.append(f"=== Query: {ds_name_str} ({total_query_lines} lines, {len(batches)} batches) ===")

            if args.Batch == 0:
                # Show TOC
                line_num = 1
                for bi in range(len(batches)):
                    batch_lines = batches[bi].split("\n")
                    end_line = line_num + len(batch_lines) - 1
                    # Detect target
                    target = ""
                    for bl in batch_lines:
                        m = re.match(r'^\s*(?:\u041F\u041E\u041C\u0415\u0421\u0422\u0418\u0422\u042C|INTO)\s+(\S+)', bl)
                        if m:
                            target = "\u2192 " + m.group(1)
                            break
                    lines.append(f"  Batch {bi + 1}: lines {line_num}-{end_line}  {target}")
                    line_num = end_line + 3  # +separator

                lines.append("")

                # Show all batches
                for bi in range(len(batches)):
                    lines.append(f"--- Batch {bi + 1} ---")
                    for ql in batches[bi].split("\n"):
                        lines.append(ql.rstrip())
                    lines.append("")
            else:
                # Show specific batch
                if args.Batch > len(batches):
                    print(f"Batch {args.Batch} not found (total: {len(batches)})", file=sys.stderr)
                    sys.exit(1)
                lines.append("")
                lines.append(f"--- Batch {args.Batch} ---")
                for ql in batches[args.Batch - 1].split("\n"):
                    lines.append(ql.rstrip())

    def show_fields():
        data_sets = root.findall("s:dataSet", NSMAP)

        def show_dataset_fields(ds_node):
            ds_type = get_dataset_type(ds_node)
            ds_name_str = (ds_node.find("s:name", NSMAP).text or "")
            fields = ds_node.findall("s:field", NSMAP)

            lines.append(f"=== Fields: {ds_name_str} [{ds_type}] ({len(fields)}) ===")
            lines.append("  dataPath                          title                  role       restrict     format")

            for f in fields:
                dp = f.find("s:dataPath", NSMAP)
                dp_str = (dp.text or "").strip() if dp is not None else "-"

                title_node = f.find("s:title", NSMAP)
                title_str = get_ml_text(title_node) if title_node is not None else ""
                if not title_str:
                    title_str = "-"

                # Role
                role = f.find("s:role", NSMAP)
                role_str = "-"
                if role is not None:
                    role_parts = []
                    for child in role:
                        if isinstance(child.tag, str) and (child.text or "").strip() == "true":
                            role_parts.append(localname(child))
                    if role_parts:
                        role_str = ",".join(role_parts)

                # UseRestriction
                restrict = f.find("s:useRestriction", NSMAP)
                restrict_str = "-"
                if restrict is not None:
                    restrict_parts = []
                    for child in restrict:
                        if isinstance(child.tag, str) and (child.text or "").strip() == "true":
                            ln = localname(child)
                            restrict_parts.append(ln[:4])
                    if restrict_parts:
                        restrict_str = ",".join(restrict_parts)

                # Appearance format
                format_str = "-"
                appearance = f.find("s:appearance", NSMAP)
                if appearance is not None:
                    for app_item in appearance.findall("dcscor:item", NSMAP):
                        param_node = app_item.find("dcscor:parameter", NSMAP)
                        val_node = app_item.find("dcscor:value", NSMAP)
                        if param_node is not None and val_node is not None:
                            param_text = (param_node.text or "").strip()
                            if param_text in ("\u0424\u043E\u0440\u043C\u0430\u0442", "Format"):
                                format_str = (val_node.text or "").strip()

                # presentationExpression
                pres_expr = f.find("s:presentationExpression", NSMAP)
                pres_str = "  presExpr" if pres_expr is not None else ""

                dp_pad = dp_str.ljust(35)
                title_pad = title_str.ljust(22)
                role_pad = role_str.ljust(10)
                restrict_pad = restrict_str.ljust(12)

                lines.append(f"  {dp_pad} {title_pad} {role_pad} {restrict_pad} {format_str}{pres_str}")

        def collect_field_info(ds_node):
            ds_type = get_dataset_type(ds_node)
            ds_name_str = (ds_node.find("s:name", NSMAP).text or "")
            for f in ds_node.findall("s:field", NSMAP):
                dp = f.find("s:dataPath", NSMAP)
                if dp is None or (dp.text or "") != args.Name:
                    continue

                info = {"dataset": f"{ds_name_str} [{ds_type}]"}

                title_node = f.find("s:title", NSMAP)
                info["title"] = get_ml_text(title_node) if title_node is not None else ""

                # ValueType
                vt = f.find("s:valueType", NSMAP)
                info["type"] = get_compact_type(vt) if vt is not None else ""

                # Role
                role = f.find("s:role", NSMAP)
                role_parts = []
                if role is not None:
                    for child in role:
                        if not isinstance(child.tag, str):
                            continue
                        txt = (child.text or "").strip()
                        if txt == "true":
                            role_parts.append(localname(child))
                        elif txt == "false":
                            pass
                        else:
                            role_parts.append(f"{localname(child)}={txt}")
                info["role"] = ", ".join(role_parts)

                # UseRestriction
                restrict = f.find("s:useRestriction", NSMAP)
                restrict_parts = []
                if restrict is not None:
                    for child in restrict:
                        if isinstance(child.tag, str) and (child.text or "").strip() == "true":
                            restrict_parts.append(localname(child))
                info["restrict"] = ", ".join(restrict_parts)

                # Format
                format_str = ""
                appearance = f.find("s:appearance", NSMAP)
                if appearance is not None:
                    for app_item in appearance.findall("dcscor:item", NSMAP):
                        pn = app_item.find("dcscor:parameter", NSMAP)
                        vn = app_item.find("dcscor:value", NSMAP)
                        if pn is not None and vn is not None:
                            pn_text = (pn.text or "").strip()
                            if pn_text in ("\u0424\u043E\u0440\u043C\u0430\u0442", "Format"):
                                format_str = (vn.text or "").strip()
                info["format"] = format_str

                # PresentationExpression
                pres_expr = f.find("s:presentationExpression", NSMAP)
                info["presExpr"] = "".join(pres_expr.itertext()) if pres_expr is not None else ""

                return info
            return None

        if args.Name:
            # Detail for specific field by dataPath -- search all datasets
            field_infos = []
            for ds in data_sets:
                info = collect_field_info(ds)
                if info:
                    field_infos.append(info)
                ds_type = get_dataset_type(ds)
                if ds_type == "Union":
                    for sub_ds in ds.findall("s:item", NSMAP):
                        info = collect_field_info(sub_ds)
                        if info:
                            field_infos.append(info)

            if not field_infos:
                print(f"Field '{args.Name}' not found in any dataset", file=sys.stderr)
                sys.exit(1)

            # Use first match for detail
            first = field_infos[0]
            title_str = f' "{first["title"]}"' if first["title"] else ""
            lines.append(f"=== Field: {args.Name}{title_str} ===")
            lines.append("")

            # Datasets
            ds_list = ", ".join(fi["dataset"] for fi in field_infos)
            lines.append(f"Dataset: {ds_list}")

            if first["type"]:
                lines.append(f"Type: {first['type']}")
            if first["role"]:
                lines.append(f"Role: {first['role']}")
            if first["restrict"]:
                lines.append(f"Restrict: {first['restrict']}")
            if first["format"]:
                lines.append(f"Format: {first['format']}")
            if first["presExpr"]:
                lines.append("PresentationExpression:")
                for el in first["presExpr"].split("\n"):
                    lines.append(f"  {el.rstrip()}")
        else:
            # Compact map: field names per dataset
            lines.append("=== Fields map ===")

            def show_dataset_field_map(ds_node, indent):
                ds_type = get_dataset_type(ds_node)
                ds_name_str = (ds_node.find("s:name", NSMAP).text or "")
                fields = ds_node.findall("s:field", NSMAP)
                field_names = []
                for f in fields:
                    dp = f.find("s:dataPath", NSMAP)
                    if dp is not None:
                        field_names.append((dp.text or ""))
                name_list = ", ".join(field_names)
                if len(name_list) > 100:
                    name_list = name_list[:97] + "..."
                lines.append(f"{indent}{ds_name_str} [{ds_type}] ({len(fields)}): {name_list}")

            for ds in data_sets:
                show_dataset_field_map(ds, "")
                ds_type = get_dataset_type(ds)
                if ds_type == "Union":
                    for sub_ds in ds.findall("s:item", NSMAP):
                        show_dataset_field_map(sub_ds, "  ")

            lines.append("")
            lines.append("Use -Name <field> for details.")

    def show_links():
        link_nodes = root.findall("s:dataSetLink", NSMAP)
        if not link_nodes:
            lines.append("(no links)")
        else:
            lines.append(f"=== Links ({len(link_nodes)}) ===")
            lines.append("")
            # Group by source->dest pair
            current_pair = ""
            for lnk in link_nodes:
                src_ds = (lnk.find("s:sourceDataSet", NSMAP).text or "")
                dst_ds = (lnk.find("s:destinationDataSet", NSMAP).text or "")
                src_expr = (lnk.find("s:sourceExpression", NSMAP).text or "")
                dst_expr = (lnk.find("s:destinationExpression", NSMAP).text or "")
                param_node = lnk.find("s:parameter", NSMAP)

                pair = f"{src_ds} -> {dst_ds}"
                if pair != current_pair:
                    if current_pair:
                        lines.append("")
                    lines.append(f"{pair} :")
                    current_pair = pair

                param_str = ""
                if param_node is not None:
                    param_str = f"  param={param_node.text or ''}"

                lines.append(f"  {src_expr} -> {dst_expr}{param_str}")

    def show_calculated():
        calc_fields = root.findall("s:calculatedField", NSMAP)
        if not calc_fields:
            lines.append("(no calculated fields)")
        elif args.Name:
            found = False
            for cf in calc_fields:
                cf_path = (cf.find("s:dataPath", NSMAP).text or "")
                if cf_path == args.Name:
                    lines.append(f"=== Calculated: {cf_path} ===")
                    lines.append("")

                    cf_expr = "".join(cf.find("s:expression", NSMAP).itertext())
                    lines.append("Expression:")
                    for el in cf_expr.split("\n"):
                        lines.append(f"  {el.rstrip()}")

                    cf_title = cf.find("s:title", NSMAP)
                    if cf_title is not None:
                        t = get_ml_text(cf_title)
                        if t:
                            lines.append(f"Title: {t}")

                    cf_restrict = cf.find("s:useRestriction", NSMAP)
                    if cf_restrict is not None:
                        parts = []
                        for child in cf_restrict:
                            if isinstance(child.tag, str) and (child.text or "").strip() == "true":
                                parts.append(localname(child))
                        if parts:
                            lines.append(f"Restrict: {', '.join(parts)}")

                    found = True
                    break
            if not found:
                print(f"Calculated field '{args.Name}' not found", file=sys.stderr)
                sys.exit(1)
        else:
            # Map
            lines.append(f"=== Calculated fields ({len(calc_fields)}) ===")
            for cf in calc_fields:
                cf_path = (cf.find("s:dataPath", NSMAP).text or "")
                cf_title = cf.find("s:title", NSMAP)
                title_str = ""
                if cf_title is not None:
                    t = get_ml_text(cf_title)
                    if t and t != cf_path:
                        title_str = f'  "{t}"'
                lines.append(f"  {cf_path}{title_str}")
            lines.append("")
            lines.append("Use -Name <field> for full expression.")

    def show_resources():
        total_fields = root.findall("s:totalField", NSMAP)
        if not total_fields:
            lines.append("(no resources)")
        elif args.Name:
            matched = []
            for tf in total_fields:
                tf_path = (tf.find("s:dataPath", NSMAP).text or "")
                if tf_path == args.Name:
                    matched.append(tf)
            if not matched:
                print(f"Resource '{args.Name}' not found", file=sys.stderr)
                sys.exit(1)
            lines.append(f"=== Resource: {args.Name} ===")
            lines.append("")
            for tf in matched:
                tf_expr = (tf.find("s:expression", NSMAP).text or "")
                tf_group = tf.find("s:group", NSMAP)
                group_str = "(overall)"
                if tf_group is not None:
                    group_str = (tf_group.text or "")
                lines.append(f"  [{group_str}] {tf_expr}")
        else:
            # Map
            lines.append(f"=== Resources ({len(total_fields)}) ===")
            res_map = OrderedDict()
            for tf in total_fields:
                tf_path = (tf.find("s:dataPath", NSMAP).text or "")
                tf_group = tf.find("s:group", NSMAP)
                if tf_path not in res_map:
                    res_map[tf_path] = {"hasGroup": False}
                if tf_group is not None:
                    res_map[tf_path]["hasGroup"] = True
            for key, val in res_map.items():
                group_mark = " *" if val["hasGroup"] else ""
                lines.append(f"  {key}{group_mark}")
            lines.append("")
            lines.append("  * = has group-level formulas")
            lines.append("")
            lines.append("Use -Name <field> for full formula.")

    def show_params():
        params = root.findall("s:parameter", NSMAP)
        lines.append(f"=== Parameters ({len(params)}) ===")
        lines.append("  Name                            Type                   Default          Visible  Expression")

        for p in params:
            p_name = (p.find("s:name", NSMAP).text or "")
            p_type = get_compact_type(p.find("s:valueType", NSMAP))
            if not p_type:
                p_type = "-"

            # Default value
            val_node = p.find("s:value", NSMAP)
            val_str = "-"
            if val_node is not None:
                nil_attr = val_node.get(f"{{{XSI_NS}}}nil", "")
                if nil_attr == "true":
                    val_str = "null"
                else:
                    raw = "".join(val_node.itertext()).strip()
                    if raw == "0001-01-01T00:00:00":
                        val_str = "-"
                    elif raw:
                        # Check for StandardPeriod variant
                        variant = val_node.find("v8:variant", NSMAP)
                        if variant is not None:
                            val_str = (variant.text or "")
                        else:
                            val_str = raw
                            if len(val_str) > 15:
                                val_str = val_str[:12] + "..."

            # Visibility
            use_restrict = p.find("s:useRestriction", NSMAP)
            vis_str = "yes"
            if use_restrict is not None:
                ur_text = (use_restrict.text or "").strip()
                if ur_text == "true":
                    vis_str = "hidden"
                elif ur_text == "false":
                    vis_str = "yes"

            # Expression
            expr_node = p.find("s:expression", NSMAP)
            expr_str = "-"
            if expr_node is not None:
                expr_text = "".join(expr_node.itertext()).strip()
                if expr_text:
                    expr_str = unescape_xml(expr_text)

            # availableAsField
            avail_field = p.find("s:availableAsField", NSMAP)
            avail_str = ""
            if avail_field is not None and (avail_field.text or "").strip() == "false":
                avail_str = " [noField]"

            name_pad = p_name.ljust(33)
            type_pad = p_type.ljust(22)
            val_pad = val_str.ljust(16)
            vis_pad = vis_str.ljust(8)

            lines.append(f"  {name_pad} {type_pad} {val_pad} {vis_pad} {expr_str}{avail_str}")

    def show_variant():
        variants = root.findall("s:settingsVariant", NSMAP)

        if not args.Name:
            # --- Variant list (map) ---
            if not variants:
                lines.append("=== Variants: (none) ===")
            else:
                lines.append(f"=== Variants ({len(variants)}) ===")
                for var_idx, v in enumerate(variants, 1):
                    v_name = (v.find("dcsset:name", NSMAP).text or "")
                    v_pres = v.find("dcsset:presentation", NSMAP)
                    v_pres_str = ""
                    if v_pres is not None:
                        pt = get_ml_text(v_pres)
                        if pt:
                            v_pres_str = f'  "{pt}"'

                    settings = v.find("dcsset:settings", NSMAP)
                    struct_items = []
                    if settings is not None:
                        for si in settings.findall("dcsset:item", NSMAP):
                            si_type = get_structure_item_type(si)
                            gf = get_group_fields(si)
                            g_str = "(" + ",".join(gf) + ")" if gf else "(detail)"
                            struct_items.append(f"{si_type}{g_str}")
                    if len(struct_items) > 3:
                        from collections import Counter
                        counted = Counter(struct_items)
                        compact_parts = []
                        for name, count in counted.most_common():
                            if count > 1:
                                compact_parts.append(f"{count}x {name}")
                            else:
                                compact_parts.append(name)
                        struct_items = compact_parts
                    struct_str = "  " + ", ".join(struct_items) if struct_items else ""

                    filter_count = 0
                    if settings is not None:
                        filter_count = len(settings.findall("dcsset:filter/dcsset:item", NSMAP))
                    filter_str = f"  {filter_count} filters" if filter_count > 0 else ""

                    # Selection fields
                    sel_fields = []
                    if settings is not None:
                        sel_fields = get_selection_fields(settings)
                    sel_str = "  sel: " + ", ".join(sel_fields) if sel_fields else ""

                    lines.append(f"  [{var_idx}] {v_name}{v_pres_str}{struct_str}{filter_str}")
                    if sel_str:
                        lines.append(f"        {sel_str}")
        else:
            # --- Variant detail ---
            target_variant = None
            match_idx = 0
            for var_idx, v in enumerate(variants, 1):
                v_name = (v.find("dcsset:name", NSMAP).text or "")
                if v_name == args.Name or str(var_idx) == args.Name:
                    target_variant = v
                    match_idx = var_idx
                    break
            if target_variant is None:
                print(f"Variant '{args.Name}' not found. Use -Mode variant without -Name to see list.", file=sys.stderr)
                sys.exit(1)

            v_name = (target_variant.find("dcsset:name", NSMAP).text or "")
            v_pres = target_variant.find("dcsset:presentation", NSMAP)
            v_pres_str = ""
            if v_pres is not None:
                pt = get_ml_text(v_pres)
                if pt:
                    v_pres_str = f' "{pt}"'

            lines.append(f"=== Variant [{match_idx}]: {v_name}{v_pres_str} ===")

            settings = target_variant.find("dcsset:settings", NSMAP)
            if settings is None:
                lines.append("  (empty settings)")
            else:
                # Selection at settings level
                top_sel = get_selection_fields(settings)
                if top_sel:
                    lines.append("")
                    lines.append("Selection: " + ", ".join(top_sel))

                # Structure
                struct_items = settings.findall("dcsset:item", NSMAP)
                has_struct = False
                for si in struct_items:
                    si_xsi_type = si.get(f"{{{XSI_NS}}}type", "")
                    if "StructureItem" in si_xsi_type:
                        has_struct = True
                        break

                if has_struct:
                    lines.append("")
                    lines.append("Structure:")
                    for si in struct_items:
                        si_xsi_type = si.get(f"{{{XSI_NS}}}type", "")
                        if "StructureItem" in si_xsi_type:
                            build_structure_tree(si, "  ", False, lines)

                # Filter
                filters = get_filter_summary(settings)
                if filters:
                    lines.append("")
                    lines.append("Filter:")
                    for f in filters:
                        lines.append(f"  {f}")

                # Data parameters
                data_params = settings.findall("dcsset:dataParameters/dcsset:item", NSMAP)
                if data_params:
                    dp_strs = []
                    for dp in data_params:
                        dp_param = dp.find("dcscor:parameter", NSMAP)
                        dp_val = dp.find("dcscor:value", NSMAP)
                        if dp_param is not None and dp_val is not None:
                            dp_strs.append(f'{(dp_param.text or "")}="{(dp_val.text or "")}"')
                    if dp_strs:
                        lines.append("")
                        lines.append("DataParams: " + ", ".join(dp_strs))

                # Output parameters
                out_params = settings.findall("dcsset:outputParameters/dcscor:item", NSMAP)
                if out_params:
                    op_strs = []
                    for op in out_params:
                        op_param = op.find("dcscor:parameter", NSMAP)
                        op_val = op.find("dcscor:value", NSMAP)
                        if op_param is not None and op_val is not None:
                            param_name = (op_param.text or "")
                            param_val = (op_val.text or "")
                            # Shorten known long names
                            short_map = {
                                "\u041C\u0430\u043A\u0435\u0442\u041E\u0444\u043E\u0440\u043C\u043B\u0435\u043D\u0438\u044F": "style",
                                "\u0420\u0430\u0441\u043F\u043E\u043B\u043E\u0436\u0435\u043D\u0438\u0435\u041F\u043E\u043B\u0435\u0439\u0413\u0440\u0443\u043F\u043F\u0438\u0440\u043E\u0432\u043A\u0438": "groups",
                                "\u0413\u043E\u0440\u0438\u0437\u043E\u043D\u0442\u0430\u043B\u044C\u043D\u043E\u0435\u0420\u0430\u0441\u043F\u043E\u043B\u043E\u0436\u0435\u043D\u0438\u0435\u041E\u0431\u0449\u0438\u0445\u0418\u0442\u043E\u0433\u043E\u0432": "totalsH",
                                "\u0412\u0435\u0440\u0442\u0438\u043A\u0430\u043B\u044C\u043D\u043E\u0435\u0420\u0430\u0441\u043F\u043E\u043B\u043E\u0436\u0435\u043D\u0438\u0435\u041E\u0431\u0449\u0438\u0445\u0418\u0442\u043E\u0433\u043E\u0432": "totalsV",
                                "\u0412\u044B\u0432\u043E\u0434\u0438\u0442\u044C\u0417\u0430\u0433\u043E\u043B\u043E\u0432\u043E\u043A": "header",
                                "\u0412\u044B\u0432\u043E\u0434\u0438\u0442\u044C\u041E\u0442\u0431\u043E\u0440": "filter",
                                "\u0412\u044B\u0432\u043E\u0434\u0438\u0442\u044C\u041F\u0430\u0440\u0430\u043C\u0435\u0442\u0440\u044B\u0414\u0430\u043D\u043D\u044B\u0445": "dataParams",
                                "\u0420\u0430\u0441\u043F\u043E\u043B\u043E\u0436\u0435\u043D\u0438\u0435\u0420\u0435\u043A\u0432\u0438\u0437\u0438\u0442\u043E\u0432": "attrs",
                            }
                            short = short_map.get(param_name, None)
                            if short:
                                op_strs.append(f"{short}={param_val}")
                            else:
                                op_strs.append(f"{param_name}={param_val}")
                    if op_strs:
                        lines.append("")
                        lines.append("Output: " + "  ".join(op_strs))

    def show_trace():
        if not args.Name:
            print("Trace mode requires -Name <field_name_or_title>", file=sys.stderr)
            sys.exit(1)

        # --- Build field index ---
        ds_fields = {}     # dataPath -> { "datasets": [], "title": "" }
        calc_fields_map = {}   # dataPath -> { "expression": "", "title": "" }
        res_fields = {}    # dataPath -> [{ "expression": "", "group": "" }]
        title_map = {}     # title -> dataPath

        # Scan dataset fields (including nested Union items)
        data_sets = root.findall("s:dataSet", NSMAP)
        for ds in data_sets:
            ds_name = (ds.find("s:name", NSMAP).text or "")
            ds_type = get_dataset_type(ds)

            for f in ds.findall("s:field", NSMAP):
                dp = f.find("s:dataPath", NSMAP)
                if dp is None:
                    continue
                dp_str = (dp.text or "")
                if dp_str not in ds_fields:
                    ds_fields[dp_str] = {"datasets": [], "title": ""}
                ds_fields[dp_str]["datasets"].append(f"{ds_name} [{ds_type}]")
                title_node = f.find("s:title", NSMAP)
                if title_node is not None:
                    t = get_ml_text(title_node)
                    if t:
                        if not ds_fields[dp_str]["title"]:
                            ds_fields[dp_str]["title"] = t
                        if t not in title_map:
                            title_map[t] = dp_str

            if ds_type == "Union":
                for sub_ds in ds.findall("s:item", NSMAP):
                    sub_name = (sub_ds.find("s:name", NSMAP).text or "")
                    sub_type = get_dataset_type(sub_ds)
                    for f in sub_ds.findall("s:field", NSMAP):
                        dp = f.find("s:dataPath", NSMAP)
                        if dp is None:
                            continue
                        dp_str = (dp.text or "")
                        if dp_str not in ds_fields:
                            ds_fields[dp_str] = {"datasets": [], "title": ""}
                        ds_fields[dp_str]["datasets"].append(f"{sub_name} [{sub_type}]")
                        title_node = f.find("s:title", NSMAP)
                        if title_node is not None:
                            t = get_ml_text(title_node)
                            if t:
                                if not ds_fields[dp_str]["title"]:
                                    ds_fields[dp_str]["title"] = t
                                if t not in title_map:
                                    title_map[t] = dp_str

        # Scan calculated fields
        for cf in root.findall("s:calculatedField", NSMAP):
            dp_str = (cf.find("s:dataPath", NSMAP).text or "")
            expr = "".join(cf.find("s:expression", NSMAP).itertext())
            cf_title = cf.find("s:title", NSMAP)
            t = ""
            if cf_title is not None:
                t = get_ml_text(cf_title)
            calc_fields_map[dp_str] = {"expression": expr, "title": t}
            if t and t not in title_map:
                title_map[t] = dp_str

        # Scan resources
        for tf in root.findall("s:totalField", NSMAP):
            dp_str = (tf.find("s:dataPath", NSMAP).text or "")
            expr = (tf.find("s:expression", NSMAP).text or "")
            grp = tf.find("s:group", NSMAP)
            group_str = "(overall)"
            if grp is not None:
                group_str = (grp.text or "")
            if dp_str not in res_fields:
                res_fields[dp_str] = []
            res_fields[dp_str].append({"expression": expr, "group": group_str})

        # --- Resolve name: try dataPath, then exact title, then substring title ---
        target_path = args.Name
        known_paths = set()
        known_paths.update(ds_fields.keys())
        known_paths.update(calc_fields_map.keys())
        known_paths.update(res_fields.keys())
        is_known = args.Name in known_paths

        if not is_known:
            if args.Name in title_map:
                target_path = title_map[args.Name]
            else:
                # Substring match in titles
                matched_title = None
                for key in title_map:
                    if args.Name in key:
                        matched_title = key
                        break
                if matched_title:
                    target_path = title_map[matched_title]
                else:
                    print(f"Field '{args.Name}' not found by dataPath or title", file=sys.stderr)
                    sys.exit(1)

        # --- Build output ---
        title = ""
        if target_path in calc_fields_map and calc_fields_map[target_path]["title"]:
            title = calc_fields_map[target_path]["title"]
        elif target_path in ds_fields and ds_fields[target_path]["title"]:
            title = ds_fields[target_path]["title"]
        title_str = f' "{title}"' if title else ""

        lines.append(f"=== Trace: {target_path}{title_str} ===")
        lines.append("")

        # Dataset origin
        if target_path in ds_fields:
            unique_ds = list(dict.fromkeys(ds_fields[target_path]["datasets"]))
            lines.append(f"Dataset: {', '.join(unique_ds)}")
        else:
            lines.append("Dataset: (schema-level only, not in dataset fields)")

        # Calculated field
        if target_path in calc_fields_map:
            cf = calc_fields_map[target_path]
            lines.append("")
            lines.append("Calculated:")
            for el in cf["expression"].split("\n"):
                lines.append(f"  {el.rstrip()}")

            # Extract operands: find known field names in expression
            operands = []
            all_known = set()
            all_known.update(ds_fields.keys())
            all_known.update(calc_fields_map.keys())
            all_known.discard(target_path)
            # Sort by length descending to match longer names first
            all_known_sorted = sorted(all_known, key=len, reverse=True)

            for field_name in all_known_sorted:
                escaped = re.escape(field_name)
                pattern = f"(?<![\\u0430-\\u044F\\u0410-\\u042F\\u0451\\u0401a-zA-Z0-9_.]){escaped}(?![\\u0430-\\u044F\\u0410-\\u042F\\u0451\\u0401a-zA-Z0-9_.])"
                if re.search(pattern, cf["expression"]):
                    operands.append(field_name)

            if operands:
                lines.append("  Operands:")
                for op in operands:
                    if op in calc_fields_map:
                        lines.append(f"    {op} -> calculated")
                    elif op in ds_fields:
                        op_ds = list(dict.fromkeys(ds_fields[op]["datasets"]))
                        lines.append(f"    {op} -> {', '.join(op_ds)}")
                    else:
                        lines.append(f"    {op}")

        # Resource
        if target_path in res_fields:
            lines.append("")
            lines.append("Resource:")
            for r in res_fields[target_path]:
                lines.append(f"  [{r['group']}] {r['expression']}")

        # Simple dataset field, no calc/resource
        if target_path not in calc_fields_map and target_path not in res_fields:
            if target_path in ds_fields:
                lines.append("")
                lines.append("(direct dataset field, no calculated expression or resource)")

    def show_templates():
        # --- Helper: check if expression is trivial ---
        def is_trivial_expr(param_name, expr):
            e = expr.strip()
            n = param_name.strip()
            if e == n:
                return True
            # Представление(...)
            if e == f"\u041F\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u043B\u0435\u043D\u0438\u0435({n})":
                return True
            return False

        # --- Helper: parse template content (rows/cells) ---
        def get_template_content(tpl_node):
            inner_t = tpl_node.find("s:template", NSMAP)
            if inner_t is None:
                return {"rows": 0, "cells": [], "params": [], "nonTrivial": []}

            rows = inner_t.findall("dcsat:item", NSMAP)
            row_count = len(rows)
            cell_data = []
            for row_idx, row in enumerate(rows, 1):
                row_cells = []
                for cell in row.findall("dcsat:tableCell", NSMAP):
                    field = cell.find("dcsat:item", NSMAP)
                    if field is None:
                        row_cells.append("(empty)")
                        continue
                    val = field.find("dcsat:value", NSMAP)
                    if val is None:
                        row_cells.append("(empty)")
                        continue
                    xsi_type = val.get(f"{{{XSI_NS}}}type", "")
                    if "LocalStringType" in xsi_type:
                        text = get_ml_text(val)
                        if text:
                            row_cells.append(f'"{text}"')
                        else:
                            row_cells.append("(empty)")
                    elif "Parameter" in xsi_type:
                        row_cells.append(f"{{{val.text or ''}}}")
                    else:
                        row_cells.append("(?)")
                cell_data.append({"row": row_idx, "cells": row_cells})

            # Parameters
            param_nodes = tpl_node.findall("s:parameter", NSMAP)
            param_list = []
            non_trivial_list = []
            for p in param_nodes:
                pn = p.find("dcsat:name", NSMAP)
                pe = p.find("dcsat:expression", NSMAP)
                if pn is not None and pe is not None:
                    p_name = (pn.text or "")
                    p_expr = (pe.text or "")
                    param_list.append({"name": p_name, "expression": p_expr})
                    if not is_trivial_expr(p_name, p_expr):
                        non_trivial_list.append({"name": p_name, "expression": p_expr})

            return {
                "rows": row_count,
                "cells": cell_data,
                "params": param_list,
                "nonTrivial": non_trivial_list,
            }

        # --- Build template name -> node index ---
        tpl_index = {}
        for t in root.findall("s:template", NSMAP):
            tn = t.find("s:name", NSMAP)
            if tn is not None:
                tpl_index[(tn.text or "")] = t

        # --- Parse bindings ---
        # Group bindings: groupTemplate + groupHeaderTemplate + groupFooterTemplate
        group_bindings = OrderedDict()

        for gt in root.findall("s:groupTemplate", NSMAP):
            gn = gt.find("s:groupName", NSMAP)
            gf = gt.find("s:groupField", NSMAP)
            gn_str = (gn.text or "") if gn is not None else ((gf.text or "") if gf is not None else "(default)")
            tt = gt.find("s:templateType", NSMAP)
            tn = gt.find("s:template", NSMAP)
            tt_str = (tt.text or "") if tt is not None else "-"
            tn_str = (tn.text or "") if tn is not None else "-"

            if gn_str not in group_bindings:
                group_bindings[gn_str] = []
            group_bindings[gn_str].append({"type": tt_str, "tplName": tn_str})

        for ght in root.findall("s:groupHeaderTemplate", NSMAP):
            gn = ght.find("s:groupName", NSMAP)
            gf = ght.find("s:groupField", NSMAP)
            gn_str = (gn.text or "") if gn is not None else ((gf.text or "") if gf is not None else "(default)")
            tn = ght.find("s:template", NSMAP)
            tn_str = (tn.text or "") if tn is not None else "-"

            if gn_str not in group_bindings:
                group_bindings[gn_str] = []
            group_bindings[gn_str].append({"type": "GroupHeader", "tplName": tn_str})

        for gft in root.findall("s:groupFooterTemplate", NSMAP):
            gn = gft.find("s:groupName", NSMAP)
            gf = gft.find("s:groupField", NSMAP)
            gn_str = (gn.text or "") if gn is not None else ((gf.text or "") if gf is not None else "(default)")
            tn = gft.find("s:template", NSMAP)
            tn_str = (tn.text or "") if tn is not None else "-"

            if gn_str not in group_bindings:
                group_bindings[gn_str] = []
            group_bindings[gn_str].append({"type": "GroupFooter", "tplName": tn_str})

        # Field bindings: fieldTemplate
        field_bindings = OrderedDict()
        field_non_trivial = []

        for ft in root.findall("s:fieldTemplate", NSMAP):
            fn = ft.find("s:field", NSMAP)
            tn = ft.find("s:template", NSMAP)
            if fn is not None and tn is not None:
                f_name = (fn.text or "")
                t_name = (tn.text or "")
                field_bindings[f_name] = t_name
                # Check params for non-trivial expressions
                if t_name in tpl_index:
                    content = get_template_content(tpl_index[t_name])
                    for nt in content["nonTrivial"]:
                        field_non_trivial.append({
                            "field": f_name,
                            "template": t_name,
                            "name": nt["name"],
                            "expression": nt["expression"],
                        })

        total_tpl = len(tpl_index)
        field_count = len(field_bindings)
        group_bind_count = sum(len(v) for v in group_bindings.values())

        if not args.Name:
            # --- MAP mode ---
            lines.append(f"=== Templates ({total_tpl} defined: {field_count} field, {group_bind_count} group) ===")

            # Field bindings
            if field_bindings:
                lines.append("")
                if not field_non_trivial:
                    field_names = list(field_bindings.keys())
                    if len(field_names) <= 8:
                        lines.append(f"Field bindings ({field_count}): {', '.join(field_names)}  (all trivial)")
                    else:
                        lines.append(f"Field bindings ({field_count}): (all trivial)")
                        lines.append(f"  {', '.join(field_names[:8])}, ...")
                else:
                    unique_nt_fields = set(nt["field"] for nt in field_non_trivial)
                    trivial_count = len(field_bindings) - len(unique_nt_fields)
                    lines.append(f"Field bindings ({field_count}, {trivial_count} trivial):")
                    for nt in field_non_trivial:
                        lines.append(f"  {nt['field']}: {nt['name']} = {nt['expression']}")

            # Group bindings
            if group_bindings:
                lines.append("")
                lines.append(f"Group bindings ({group_bind_count}):")
                for g_name, bindings in group_bindings.items():
                    parts = []
                    for b in bindings:
                        info = f"{b['type']} -> {b['tplName']}"
                        if b["tplName"] in tpl_index:
                            content = get_template_content(tpl_index[b["tplName"]])
                            # Check if any cell has content
                            has_content = False
                            for r in content["cells"]:
                                for c in r["cells"]:
                                    if c != "(empty)":
                                        has_content = True
                                        break
                                if has_content:
                                    break
                            info += f" ({content['rows']} rows"
                            if content["params"]:
                                info += f", {len(content['params'])} params"
                            info += ")"
                            if not has_content and not content["params"]:
                                info += " spacer"
                            if content["nonTrivial"]:
                                nt_names = ", ".join(nt["name"] for nt in content["nonTrivial"])
                                info += f" *{nt_names}"
                        parts.append(info)
                    lines.append(f"  {g_name}")
                    for p in parts:
                        lines.append(f"    {p}")

            if field_bindings or group_bindings:
                lines.append("")
                lines.append("Use -Name <group|field> for template details.")
        else:
            # --- DETAIL mode ---
            found = False

            # Check group bindings first
            if args.Name in group_bindings:
                found = True
                bindings = group_bindings[args.Name]
                lines.append(f"=== Templates: {args.Name} ===")
                for b in bindings:
                    lines.append("")
                    t_name = b["tplName"]
                    if t_name not in tpl_index:
                        lines.append(f"{b['type']} -> {t_name}  (template not found)")
                        continue
                    content = get_template_content(tpl_index[t_name])
                    cell_count = sum(len(r["cells"]) for r in content["cells"])
                    lines.append(f"{b['type']} -> {t_name} [{content['rows']} rows, {cell_count} cells]:")

                    for r in content["cells"]:
                        cell_str = " | ".join(r["cells"])
                        lines.append(f"  Row {r['row']}: {cell_str}")

                    if content["nonTrivial"]:
                        lines.append("  Params:")
                        for nt in content["nonTrivial"]:
                            lines.append(f"    {nt['name']} = {nt['expression']}")

            # Check field bindings
            if args.Name in field_bindings:
                if found:
                    lines.append("")
                found = True
                t_name = field_bindings[args.Name]
                lines.append(f"=== Field template: {args.Name} -> {t_name} ===")
                if t_name in tpl_index:
                    content = get_template_content(tpl_index[t_name])
                    cell_count = sum(len(r["cells"]) for r in content["cells"])
                    lines.append(f"[{content['rows']} rows, {cell_count} cells]")

                    for r in content["cells"]:
                        cell_str = " | ".join(r["cells"])
                        lines.append(f"  Row {r['row']}: {cell_str}")

                    if content["nonTrivial"]:
                        lines.append("  Non-trivial params:")
                        for nt in content["nonTrivial"]:
                            lines.append(f"    {nt['name']} = {nt['expression']}")
                    else:
                        lines.append("  (all params trivial)")

            if not found:
                print(f"Group or field '{args.Name}' not found in template bindings", file=sys.stderr)
                sys.exit(1)

    # === Execute mode ===

    mode = args.Mode

    if mode == "overview":
        show_overview()
        show_overview_hints()
    elif mode == "query":
        show_query()
    elif mode == "fields":
        show_fields()
    elif mode == "links":
        show_links()
    elif mode == "calculated":
        show_calculated()
    elif mode == "resources":
        show_resources()
    elif mode == "params":
        show_params()
    elif mode == "variant":
        show_variant()
    elif mode == "trace":
        show_trace()
    elif mode == "templates":
        show_templates()
    elif mode == "full":
        show_overview()
        lines.append("")
        lines.append("--- query ---")
        lines.append("")
        if root.findall(".//s:dataSet[@xsi:type='DataSetQuery']", NSMAP):
            show_query()
        else:
            obj_names = [n.text for n in root.findall(".//s:dataSet[@xsi:type='DataSetObject']/s:objectName", NSMAP) if n.text]
            if obj_names:
                lines.append(f"(no query datasets; external datasets: {', '.join(obj_names)})")
            else:
                lines.append("(no query datasets)")
        lines.append("")
        lines.append("--- fields ---")
        lines.append("")
        show_fields()
        lines.append("")
        lines.append("--- resources ---")
        lines.append("")
        show_resources()
        lines.append("")
        lines.append("--- params ---")
        lines.append("")
        show_params()
        lines.append("")
        lines.append("--- variant ---")
        lines.append("")
        show_variant()

    # --- Output ---

    result = lines
    total_lines = len(result)

    # OutFile
    if args.OutFile:
        out_path = args.OutFile
        if not os.path.isabs(out_path):
            out_path = os.path.join(os.getcwd(), out_path)
        with open(out_path, "w", encoding="utf-8-sig") as fh:
            # Хвостовой перевод строки — как у PS-порта (WriteAllLines его добавляет).
            # Это текстовый отчёт, а не XML метаданных: канон Конфигуратора сюда не
            # относится, важен лишь паритет портов.
            fh.write("\n".join(result) + "\n")
        print(f"Written {total_lines} lines to {args.OutFile}")
        sys.exit(0)

    # Pagination
    if args.Offset > 0:
        if args.Offset >= total_lines:
            print(f"[INFO] Offset {args.Offset} exceeds total lines ({total_lines}). Nothing to show.")
            sys.exit(0)
        result = result[args.Offset:]

    if not args.Raw and len(result) > args.Limit:
        shown = result[:args.Limit]
        for line in shown:
            print(line)
        print("")
        print(f"[TRUNCATED] Shown {args.Limit} of {total_lines} lines. Use -Offset {args.Offset + args.Limit} to continue.")
    else:
        for line in result:
            print(line)


if __name__ == "__main__":
    main()
