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

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


# --- Namespace map ---

NSMAP = {
    "d": "http://v8.1c.ru/8.3/xcf/logform",
    "v8": "http://v8.1c.ru/8.1/data/core",
    "v8ui": "http://v8.1c.ru/8.1/data/ui",
    "xr": "http://v8.1c.ru/8.3/xcf/readable",
    "xs": "http://www.w3.org/2001/XMLSchema",
    "xsi": "http://www.w3.org/2001/XMLSchema-instance",
    "cfg": "http://v8.1c.ru/8.1/data/enterprise/current-config",
    "dcsset": "http://v8.1c.ru/8.1/data-composition-system/settings",
}

# --- Skip elements ---

SKIP_ELEMENTS = {
    "ExtendedTooltip",
    "ContextMenu",
    "AutoCommandBar",
    "SearchStringAddition",
    "ViewStatusAddition",
    "SearchControlAddition",
    "ColumnGroup",
}


# --- Helper: extract multilang text ---

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
    # Fallback: concatenate all text
    text = "".join(node.itertext()).strip()
    if text:
        return text
    return ""


# --- Helper: format type compactly ---

def format_type(type_node):
    if type_node is None or len(type_node) == 0:
        return ""

    type_set = type_node.find("v8:TypeSet", NSMAP)
    if type_set is not None:
        val = type_set.text or ""
        if val.startswith("cfg:"):
            val = val[4:]
        return val

    types = type_node.findall("v8:Type", NSMAP)
    if len(types) == 0:
        return ""

    parts = []
    for t in types:
        raw = t.text or ""
        if raw == "xs:string":
            sq = type_node.find("v8:StringQualifiers/v8:Length", NSMAP)
            length = int(sq.text) if sq is not None and sq.text else 0
            if length > 0:
                parts.append(f"string({length})")
            else:
                parts.append("string")
        elif raw == "xs:decimal":
            nq = type_node.find("v8:NumberQualifiers", NSMAP)
            if nq is not None:
                d = nq.find("v8:Digits", NSMAP)
                f = nq.find("v8:FractionDigits", NSMAP)
                digits = d.text if d is not None and d.text else "0"
                frac = f.text if f is not None and f.text else "0"
                parts.append(f"decimal({digits},{frac})")
            else:
                parts.append("decimal")
        elif raw == "xs:boolean":
            parts.append("boolean")
        elif raw == "xs:dateTime":
            dq = type_node.find("v8:DateQualifiers/v8:DateFractions", NSMAP)
            if dq is not None:
                frac_text = dq.text or ""
                if frac_text == "Date":
                    parts.append("date")
                elif frac_text == "Time":
                    parts.append("time")
                else:
                    parts.append("dateTime")
            else:
                parts.append("dateTime")
        elif raw == "xs:binary":
            parts.append("binary")
        elif raw.startswith("cfg:") or re.match(r'^d\d+p\d+:', raw):
            parts.append(re.sub(r'^(?:cfg|d\d+p\d+):', '', raw))
        elif raw == "v8:ValueTable":
            parts.append("ValueTable")
        elif raw == "v8:ValueTree":
            parts.append("ValueTree")
        elif raw == "v8:ValueListType":
            parts.append("ValueList")
        elif raw == "v8:TypeDescription":
            parts.append("TypeDescription")
        elif raw == "v8:Universal":
            parts.append("Universal")
        elif raw == "v8:FixedArray":
            parts.append("FixedArray")
        elif raw == "v8:FixedStructure":
            parts.append("FixedStructure")
        elif raw == "v8ui:FormattedString":
            parts.append("FormattedString")
        elif raw == "v8ui:Picture":
            parts.append("Picture")
        elif raw == "v8ui:Color":
            parts.append("Color")
        elif raw == "v8ui:Font":
            parts.append("Font")
        elif raw.startswith("dcsset:"):
            parts.append(raw.replace("dcsset:", "DCS."))
        elif raw.startswith("dcssch:"):
            parts.append(raw.replace("dcssch:", "DCS."))
        elif raw.startswith("dcscor:"):
            parts.append(raw.replace("dcscor:", "DCS."))
        else:
            parts.append(raw)

    return " | ".join(parts)


# --- Helper: check if title differs from name ---

def test_title_differs(node, name):
    title_node = node.find("d:Title", NSMAP)
    if title_node is None:
        return None
    title_text = get_ml_text(title_node)
    if not title_text:
        return None
    # Normalize: remove spaces, lowercase
    norm_title = title_text.replace(" ", "").lower()
    norm_name = name.lower()
    if norm_title == norm_name:
        return None
    return title_text


# --- Helper: get events as compact string ---

def get_events_str(node):
    events_node = node.find("d:Events", NSMAP)
    if events_node is None:
        return ""
    evts = []
    for e in events_node.findall("d:Event", NSMAP):
        e_name = e.get("name", "")
        ct = e.get("callType", "")
        if ct:
            evts.append(f"{e_name}[{ct}]")
        else:
            evts.append(e_name)
    if len(evts) == 0:
        return ""
    return " {" + ", ".join(evts) + "}"


# --- Helper: get flags ---

def get_flags(node):
    flags = []
    vis = node.find("d:Visible", NSMAP)
    if vis is not None and vis.text == "false":
        flags.append("visible:false")
    en = node.find("d:Enabled", NSMAP)
    if en is not None and en.text == "false":
        flags.append("enabled:false")
    ro = node.find("d:ReadOnly", NSMAP)
    if ro is not None and ro.text == "true":
        flags.append("ro")
    if len(flags) == 0:
        return ""
    return " [" + ",".join(flags) + "]"


# --- Element type abbreviations ---

def get_element_tag(node):
    local_name = etree.QName(node.tag).localname
    if local_name == "UsualGroup":
        group_node = node.find("d:Group", NSMAP)
        orient = ""
        if group_node is not None:
            g_text = group_node.text or ""
            if g_text == "Vertical":
                orient = ":V"
            elif g_text == "Horizontal":
                orient = ":H"
            elif g_text == "AlwaysHorizontal":
                orient = ":AH"
            elif g_text == "AlwaysVertical":
                orient = ":AV"
        beh = node.find("d:Behavior", NSMAP)
        collapse = ""
        if beh is not None and beh.text == "Collapsible":
            collapse = ",collapse"
        return f"[Group{orient}{collapse}]"
    elif local_name == "InputField":
        return "[Input]"
    elif local_name == "CheckBoxField":
        return "[Check]"
    elif local_name == "LabelDecoration":
        return "[Label]"
    elif local_name == "LabelField":
        return "[LabelField]"
    elif local_name == "PictureDecoration":
        return "[Picture]"
    elif local_name == "PictureField":
        return "[PicField]"
    elif local_name == "CalendarField":
        return "[Calendar]"
    elif local_name == "Table":
        return "[Table]"
    elif local_name == "Button":
        return "[Button]"
    elif local_name == "CommandBar":
        return "[CmdBar]"
    elif local_name == "Pages":
        return "[Pages]"
    elif local_name == "Page":
        return "[Page]"
    elif local_name == "Popup":
        return "[Popup]"
    elif local_name == "ButtonGroup":
        return "[BtnGroup]"
    else:
        return f"[{local_name}]"


# --- Count significant children (for Page summary) ---

def count_significant_children(child_items_node):
    if child_items_node is None:
        return 0
    count = 0
    for child in child_items_node:
        if not isinstance(child.tag, str):
            continue
        ln = etree.QName(child.tag).localname
        if ln in SKIP_ELEMENTS:
            continue
        count += 1
    return count


# --- Build element tree recursively ---

def build_tree(child_items_node, prefix, tree_lines, expand="", state=None):
    if child_items_node is None:
        return

    # Collect significant children
    children = []
    for child in child_items_node:
        if not isinstance(child.tag, str):
            continue
        ln = etree.QName(child.tag).localname
        if ln in SKIP_ELEMENTS:
            continue
        children.append(child)

    for i, child in enumerate(children):
        last = (i == len(children) - 1)
        connector = "\u2514\u2500" if last else "\u251C\u2500"
        continuation = "  " if last else "\u2502 "

        tag = get_element_tag(child)
        name = child.get("name", "")
        flags = get_flags(child)
        events = get_events_str(child)

        # DataPath or CommandName
        binding = ""
        dp = child.find("d:DataPath", NSMAP)
        if dp is not None and dp.text:
            binding = f" -> {dp.text}"
        else:
            cn = child.find("d:CommandName", NSMAP)
            if cn is not None and cn.text:
                cn_val = cn.text
                m = re.match(r'^Form\.StandardCommand\.(.+)$', cn_val)
                if m:
                    binding = f" -> {m.group(1)} [std]"
                else:
                    m = re.match(r'^Form\.Command\.(.+)$', cn_val)
                    if m:
                        binding = f" -> {m.group(1)} [cmd]"
                    else:
                        binding = f" -> {cn_val}"

        # Title differs?
        title_str = ""
        diff_title = test_title_differs(child, name)
        if diff_title:
            title_str = f" [title:{diff_title}]"

        line = f"{prefix}{connector} {tag} {name}{binding}{flags}{title_str}{events}"
        tree_lines.append(line)

        # Recurse into containers (but not Page -- show summary unless expanded)
        local_name = etree.QName(child.tag).localname
        if local_name == "Page":
            ci = child.find("d:ChildItems", NSMAP)
            page_name = child.get("name", "")
            page_title = test_title_differs(child, page_name)
            should_expand = (expand == "*") or (expand == page_name) or (page_title and expand == page_title)
            if should_expand and ci is not None:
                build_tree(ci, prefix + continuation, tree_lines, expand, state)
            else:
                cnt = count_significant_children(ci)
                tree_lines[-1] = tree_lines[-1] + f" ({cnt} items)"
                if state is not None:
                    state["has_collapsed"] = True
        elif local_name in ("UsualGroup", "Pages", "Table", "CommandBar", "ButtonGroup", "Popup"):
            ci = child.find("d:ChildItems", NSMAP)
            if ci is not None:
                build_tree(ci, prefix + continuation, tree_lines, expand, state)


# --- Support status (Ext/ParentConfigurations.bin) ---
# See docs/1c-support-state-spec.md. Walks up from the target path, taking the
# uuid of the nearest element meta-xml and the config root bin. Never throws —
# degrades to "не на поддержке".
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 "не на поддержке"


# --- Main ---

def main():
    sys.stdout.reconfigure(encoding="utf-8")
    sys.stderr.reconfigure(encoding="utf-8")
    parser = argparse.ArgumentParser(description="Analyze 1C managed form structure", allow_abbrev=False)
    parser.add_argument("-FormPath", "-Path", required=True, help="Path to Form.xml")
    parser.add_argument("-Limit", type=int, default=150, help="Max lines to show")
    parser.add_argument("-Offset", type=int, default=0, help="Line offset for pagination")
    parser.add_argument("-Expand", default="", help="Expand collapsed section by name, or * for all")
    args = ci_parse_args(parser)

    form_path = args.FormPath
    limit = args.Limit
    offset = args.Offset
    expand = args.Expand

    # --- Resolve FormPath ---
    if not os.path.isabs(form_path):
        form_path = os.path.join(os.getcwd(), form_path)
    # A: Directory → Ext/Form.xml
    if os.path.isdir(form_path):
        form_path = os.path.join(form_path, "Ext", "Form.xml")
    # B1: Missing Ext/ (Forms/Форма/Form.xml → Forms/Форма/Ext/Form.xml)
    if not os.path.isfile(form_path):
        fn = os.path.basename(form_path)
        if fn == "Form.xml":
            c = os.path.join(os.path.dirname(form_path), "Ext", fn)
            if os.path.isfile(c):
                form_path = c
    # B2: Descriptor (Forms/Форма.xml → Forms/Форма/Ext/Form.xml)
    if not os.path.isfile(form_path) and form_path.endswith(".xml"):
        stem = os.path.splitext(os.path.basename(form_path))[0]
        parent = os.path.dirname(form_path)
        c = os.path.join(parent, stem, "Ext", "Form.xml")
        if os.path.isfile(c):
            form_path = c

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

    # --- Load XML ---
    parser_xml = etree.XMLParser(remove_blank_text=False)
    tree = etree.parse(form_path, parser_xml)
    root = tree.getroot()

    # --- Detect extension (BaseForm) ---
    base_form_node = root.find("d:BaseForm", NSMAP)
    is_extension = base_form_node is not None

    # --- Determine form name and object from path ---
    resolved_path = os.path.abspath(form_path)
    parts = resolved_path.replace("\\", "/").split("/")

    form_name = ""
    object_context = ""

    # Look for /Forms/<FormName>/Ext/Form.xml pattern
    forms_idx = -1
    for i in range(len(parts) - 1, -1, -1):
        if parts[i] == "Forms":
            forms_idx = i
            break

    if forms_idx >= 0 and (forms_idx + 1) < len(parts):
        form_name = parts[forms_idx + 1]
        # Object is 2 levels up: .../<ObjectType>/<ObjectName>/Forms/...
        if forms_idx >= 2:
            obj_type = parts[forms_idx - 2]
            obj_name = parts[forms_idx - 1]
            object_context = f"{obj_type}.{obj_name}"
    else:
        # CommonForms pattern: .../<ObjectType>/<FormName>/Ext/Form.xml
        ext_idx = -1
        for i in range(len(parts) - 1, -1, -1):
            if parts[i] == "Ext":
                ext_idx = i
                break
        if ext_idx >= 2:
            form_name = parts[ext_idx - 1]
            obj_type = parts[ext_idx - 2]
            object_context = obj_type
        else:
            form_name = os.path.splitext(os.path.basename(form_path))[0]

    # --- Collect output ---
    lines = []

    # Header -- include Title if present
    title_node = root.find("d:Title", NSMAP)
    form_title = None
    if title_node is not None:
        form_title = get_ml_text(title_node)
        if not form_title:
            form_title = "".join(title_node.itertext()).strip() or None

    ext_marker = " [EXTENSION]" if is_extension else ""
    header = f"=== Form: {form_name}{ext_marker}"
    if form_title:
        header += f' — "{form_title}"'
    if object_context:
        header += f" ({object_context})"
    header += " ==="
    lines.append(header)
    _support = get_support_status_for_path(form_path)
    if _support is not None:
        lines.append(f"Поддержка: {_support}")

    # --- Form properties (Title excluded -- shown in header) ---
    prop_names = [
        "Width", "Height", "Group",
        "WindowOpeningMode", "EnterKeyBehavior", "AutoTitle", "AutoURL",
        "AutoFillCheck", "Customizable", "CommandBarLocation",
        "SaveDataInSettings", "AutoSaveDataInSettings",
        "AutoTime", "UsePostingMode", "RepostOnWrite",
        "UseForFoldersAndItems",
        "ReportResult", "DetailsData", "ReportFormType",
        "VerticalScroll", "ScalingMode",
    ]

    props = []
    for pn in prop_names:
        p_node = root.find(f"d:{pn}", NSMAP)
        if p_node is not None:
            val = get_ml_text(p_node)
            if not val:
                val = "".join(p_node.itertext()).strip()
            props.append(f"{pn}={val}")

    if len(props) > 0:
        lines.append("")
        lines.append("Properties: " + ", ".join(props))

    # --- Excluded commands ---
    excluded_cmds = []
    for ec in root.findall("d:CommandSet/d:ExcludedCommand", NSMAP):
        excluded_cmds.append(ec.text or "")

    # --- Form events ---
    form_events = root.find("d:Events", NSMAP)
    if form_events is not None and len(form_events) > 0:
        lines.append("")
        lines.append("Events:")
        for e in form_events.findall("d:Event", NSMAP):
            e_name = e.get("name", "")
            e_handler = e.text or ""
            ct = e.get("callType", "")
            ct_str = f"[{ct}]" if ct else ""
            lines.append(f"  {e_name}{ct_str} -> {e_handler}")

    # --- Main AutoCommandBar (form's id=-1 panel) ---
    def format_main_acb(acb_node):
        if acb_node is None:
            return []
        autofill_node = acb_node.find("d:Autofill", NSMAP)
        autofill = not (autofill_node is not None and autofill_node.text == "false")
        halign_node = acb_node.find("d:HorizontalAlign", NSMAP)
        flags = ["autofill" if autofill else "no-autofill"]
        if halign_node is not None and halign_node.text:
            flags.append(f"align={halign_node.text}")
        ci_node = acb_node.find("d:ChildItems", NSMAP)
        buttons = []
        if ci_node is not None:
            for btn in ci_node:
                if not isinstance(btn.tag, str):
                    continue
                ln = etree.QName(btn).localname
                if ln in SKIP_ELEMENTS:
                    continue
                b_name = btn.get("name", "")
                cmd_node = btn.find("d:CommandName", NSMAP)
                cmd_ref = cmd_node.text if cmd_node is not None and cmd_node.text else ""
                loc_node = btn.find("d:LocationInCommandBar", NSMAP)
                loc_str = f" [{loc_node.text}]" if loc_node is not None and loc_node.text else ""
                tag = get_element_tag(btn)
                if cmd_ref:
                    buttons.append(f"  {tag} {b_name} -> {cmd_ref}{loc_str}")
                else:
                    buttons.append(f"  {tag} {b_name}{loc_str}")
        if not buttons and autofill and halign_node is None:
            return ["AutoCommandBar [autofill]"]
        return [f"AutoCommandBar [{', '.join(flags)}]"] + buttons

    cb_loc_node = root.find("d:CommandBarLocation", NSMAP)
    cb_loc = cb_loc_node.text if cb_loc_node is not None and cb_loc_node.text else "Auto"
    main_acb_node = root.find("d:AutoCommandBar", NSMAP)
    acb_lines = []
    if cb_loc != "None" and main_acb_node is not None:
        acb_lines = format_main_acb(main_acb_node)

    if acb_lines and cb_loc in ("Auto", "Top"):
        lines.append("")
        lines.extend(acb_lines)

    # --- Element tree ---
    tree_state = {"has_collapsed": False}
    child_items = root.find("d:ChildItems", NSMAP)
    if child_items is not None:
        lines.append("")
        lines.append("Elements:")
        tree_lines = []
        build_tree(child_items, "  ", tree_lines, expand, tree_state)
        lines.extend(tree_lines)

    if acb_lines and cb_loc == "Bottom":
        lines.append("")
        lines.extend(acb_lines)

    # --- Attributes ---
    attrs_node = root.find("d:Attributes", NSMAP)
    if attrs_node is not None:
        attr_lines = []
        for attr in attrs_node.findall("d:Attribute", NSMAP):
            a_name = attr.get("name", "")
            type_node = attr.find("d:Type", NSMAP)
            type_str = format_type(type_node)

            main_attr = attr.find("d:MainAttribute", NSMAP)
            is_main = main_attr is not None and main_attr.text == "true"

            prefix_char = "*" if is_main else " "
            main_suffix = " (main)" if is_main else ""

            # DynamicList: show MainTable
            settings = attr.find("d:Settings", NSMAP)
            dyn_table = ""
            if settings is not None and type_str == "DynamicList":
                mt = settings.find("d:MainTable", NSMAP)
                if mt is not None and mt.text:
                    dyn_table = f" -> {mt.text}"

            # ValueTable/ValueTree columns
            col_str = ""
            columns = attr.find("d:Columns", NSMAP)
            if columns is not None and type_str in ("ValueTable", "ValueTree"):
                cols = []
                for col in columns.findall("d:Column", NSMAP):
                    c_name = col.get("name", "")
                    c_type_node = col.find("d:Type", NSMAP)
                    c_type = format_type(c_type_node)
                    if c_type:
                        cols.append(f"{c_name}: {c_type}")
                    else:
                        cols.append(c_name)
                if len(cols) > 0:
                    col_str = " [" + ", ".join(cols) + "]"

            if type_str or col_str or dyn_table:
                line = f"  {prefix_char}{a_name}: {type_str}{col_str}{dyn_table}{main_suffix}"
            else:
                line = f"  {prefix_char}{a_name}{main_suffix}"
            attr_lines.append(line)

        if len(attr_lines) > 0:
            lines.append("")
            lines.append("Attributes:")
            lines.extend(attr_lines)

    # --- Parameters ---
    params_node = root.find("d:Parameters", NSMAP)
    if params_node is not None:
        param_lines = []
        for param in params_node.findall("d:Parameter", NSMAP):
            p_name = param.get("name", "")
            type_node = param.find("d:Type", NSMAP)
            type_str = format_type(type_node)

            key_param = param.find("d:KeyParameter", NSMAP)
            is_key = key_param is not None and key_param.text == "true"
            key_suffix = " (key)" if is_key else ""

            if type_str:
                param_lines.append(f"  {p_name}: {type_str}{key_suffix}")
            else:
                param_lines.append(f"  {p_name}{key_suffix}")

        if len(param_lines) > 0:
            lines.append("")
            lines.append("Parameters:")
            lines.extend(param_lines)

    # --- Commands ---
    cmds_node = root.find("d:Commands", NSMAP)
    if cmds_node is not None:
        cmd_lines = []
        for cmd in cmds_node.findall("d:Command", NSMAP):
            c_name = cmd.get("name", "")
            shortcut = cmd.find("d:Shortcut", NSMAP)
            sc_str = f" [{shortcut.text}]" if shortcut is not None and shortcut.text else ""

            # Collect all Action elements (may have multiple with callType)
            actions = cmd.findall("d:Action", NSMAP)
            if len(actions) > 1:
                act_parts = []
                for a in actions:
                    ct = a.get("callType", "")
                    ct_str = f"[{ct}]" if ct else ""
                    act_parts.append(f"{a.text or ''}{ct_str}")
                action_str = " -> " + ", ".join(act_parts)
            elif len(actions) == 1:
                ct = actions[0].get("callType", "")
                ct_str = f"[{ct}]" if ct else ""
                action_str = f" -> {actions[0].text or ''}{ct_str}"
            else:
                action_str = ""

            cmd_lines.append(f"  {c_name}{action_str}{sc_str}")

        if len(cmd_lines) > 0:
            lines.append("")
            lines.append("Commands:")
            lines.extend(cmd_lines)

    # --- BaseForm footer ---
    if is_extension:
        bf_version = base_form_node.get("version", "")
        bf_str = f"present (version {bf_version})" if bf_version else "present"
        lines.append("")
        lines.append(f"BaseForm: {bf_str}")

    # --- Expand hint ---
    if tree_state["has_collapsed"]:
        lines.append("")
        lines.append("Hint: use -Expand <name> to expand a collapsed section, -Expand * for all")

    # --- Truncation protection ---
    total_lines = len(lines)

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

    if len(lines) > limit:
        shown = lines[:limit]
        for l in shown:
            print(l)
        remaining = total_lines - offset - limit
        print("")
        print(f"[TRUNCATED] Shown {limit} of {total_lines} lines. Use -Offset {offset + limit} to continue.")
    else:
        for l in lines:
            print(l)


if __name__ == "__main__":
    main()
