# form-info v1.8 — Analyze 1C managed form structure
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
[CmdletBinding(PositionalBinding=$false)]
param(
	[Parameter(Mandatory=$true, Position=0)]
	[Alias('Path')]
	[string]$FormPath,
	[int]$Limit = 150,
	[int]$Offset = 0,
	[string]$Expand
)

$ErrorActionPreference = "Stop"
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8

# --- Resolve FormPath ---
if (-not [System.IO.Path]::IsPathRooted($FormPath)) {
	$FormPath = Join-Path (Get-Location).Path $FormPath
}
# A: Directory → Ext/Form.xml
if (Test-Path $FormPath -PathType Container) {
	$FormPath = Join-Path (Join-Path $FormPath "Ext") "Form.xml"
}
# B1: Missing Ext/ (Forms/Форма/Form.xml → Forms/Форма/Ext/Form.xml)
if (-not (Test-Path $FormPath)) {
	$fn = [System.IO.Path]::GetFileName($FormPath)
	if ($fn -eq "Form.xml") {
		$c = Join-Path (Join-Path (Split-Path $FormPath) "Ext") $fn
		if (Test-Path $c) { $FormPath = $c }
	}
}
# B2: Descriptor (Forms/Форма.xml → Forms/Форма/Ext/Form.xml)
if (-not (Test-Path $FormPath) -and $FormPath.EndsWith(".xml")) {
	$stem = [System.IO.Path]::GetFileNameWithoutExtension($FormPath)
	$dir = Split-Path $FormPath
	$c = Join-Path (Join-Path (Join-Path $dir $stem) "Ext") "Form.xml"
	if (Test-Path $c) { $FormPath = $c }
}

if (-not (Test-Path $FormPath)) {
	Write-Error "File not found: $FormPath"
	exit 1
}

# --- Load XML ---

$xmlDoc = New-Object System.Xml.XmlDocument
$xmlDoc.PreserveWhitespace = $false
$xmlDoc.Load((Resolve-Path $FormPath).Path)

$ns = New-Object System.Xml.XmlNamespaceManager($xmlDoc.NameTable)
$ns.AddNamespace("d", "http://v8.1c.ru/8.3/xcf/logform")
$ns.AddNamespace("v8", "http://v8.1c.ru/8.1/data/core")
$ns.AddNamespace("v8ui", "http://v8.1c.ru/8.1/data/ui")
$ns.AddNamespace("xr", "http://v8.1c.ru/8.3/xcf/readable")
$ns.AddNamespace("xs", "http://www.w3.org/2001/XMLSchema")
$ns.AddNamespace("xsi", "http://www.w3.org/2001/XMLSchema-instance")
$ns.AddNamespace("cfg", "http://v8.1c.ru/8.1/data/enterprise/current-config")
$ns.AddNamespace("dcsset", "http://v8.1c.ru/8.1/data-composition-system/settings")

$root = $xmlDoc.DocumentElement

# --- Detect extension (BaseForm) ---
$baseFormNode = $root.SelectSingleNode("d:BaseForm", $ns)
$isExtension = ($baseFormNode -ne $null)

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

function Get-MLText($node) {
	if (-not $node) { return "" }
	$content = $node.SelectSingleNode("v8:item/v8:content", $ns)
	if ($content) { return $content.InnerText }
	$text = $node.InnerText.Trim()
	if ($text) { return $text }
	return ""
}

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

function Format-Type($typeNode) {
	if (-not $typeNode -or -not $typeNode.HasChildNodes) { return "" }

	$typeSet = $typeNode.SelectSingleNode("v8:TypeSet", $ns)
	if ($typeSet) {
		$val = $typeSet.InnerText
		# Strip cfg:/d5p1: prefix for DefinedType, keep as-is
		$val = $val -replace '^(cfg|d\d+p\d+):', ''
		return $val
	}

	$types = $typeNode.SelectNodes("v8:Type", $ns)
	if ($types.Count -eq 0) { return "" }

	$parts = @()
	foreach ($t in $types) {
		$raw = $t.InnerText
		switch -Wildcard ($raw) {
			"xs:string" {
				$sq = $typeNode.SelectSingleNode("v8:StringQualifiers/v8:Length", $ns)
				$len = if ($sq) { [int]$sq.InnerText } else { 0 }
				if ($len -gt 0) { $parts += "string($len)" } else { $parts += "string" }
			}
			"xs:decimal" {
				$nq = $typeNode.SelectSingleNode("v8:NumberQualifiers", $ns)
				if ($nq) {
					$d = $nq.SelectSingleNode("v8:Digits", $ns)
					$f = $nq.SelectSingleNode("v8:FractionDigits", $ns)
					$digits = if ($d) { $d.InnerText } else { "0" }
					$frac = if ($f) { $f.InnerText } else { "0" }
					$parts += "decimal($digits,$frac)"
				} else {
					$parts += "decimal"
				}
			}
			"xs:boolean" { $parts += "boolean" }
			"xs:dateTime" {
				$dq = $typeNode.SelectSingleNode("v8:DateQualifiers/v8:DateFractions", $ns)
				if ($dq) {
					switch ($dq.InnerText) {
						"Date" { $parts += "date" }
						"Time" { $parts += "time" }
						default { $parts += "dateTime" }
					}
				} else {
					$parts += "dateTime"
				}
			}
			"xs:binary" { $parts += "binary" }
			{ $_ -like "cfg:*" -or $_ -match '^d\d+p\d+:' } { $parts += ($raw -replace '^(cfg|d\d+p\d+):', '') }
			"v8:ValueTable" { $parts += "ValueTable" }
			"v8:ValueTree" { $parts += "ValueTree" }
			"v8:ValueListType" { $parts += "ValueList" }
			"v8:TypeDescription" { $parts += "TypeDescription" }
			"v8:Universal" { $parts += "Universal" }
			"v8:FixedArray" { $parts += "FixedArray" }
			"v8:FixedStructure" { $parts += "FixedStructure" }
			"v8ui:FormattedString" { $parts += "FormattedString" }
			"v8ui:Picture" { $parts += "Picture" }
			"v8ui:Color" { $parts += "Color" }
			"v8ui:Font" { $parts += "Font" }
			"dcsset:*" { $parts += $raw.Replace("dcsset:", "DCS.") }
			"dcssch:*" { $parts += $raw.Replace("dcssch:", "DCS.") }
			"dcscor:*" { $parts += $raw.Replace("dcscor:", "DCS.") }
			default { $parts += $raw }
		}
	}

	return ($parts -join " | ")
}

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

function Test-TitleDiffers($node, [string]$name) {
	$titleNode = $node.SelectSingleNode("d:Title", $ns)
	if (-not $titleNode) { return $null }
	$titleText = Get-MLText $titleNode
	if (-not $titleText) { return $null }
	# Normalize: remove spaces, lowercase
	$normTitle = ($titleText -replace '\s', '').ToLower()
	$normName = $name.ToLower()
	if ($normTitle -eq $normName) { return $null }
	return $titleText
}

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

function Get-EventsStr($node) {
	$eventsNode = $node.SelectSingleNode("d:Events", $ns)
	if (-not $eventsNode) { return "" }
	$evts = @()
	foreach ($e in $eventsNode.SelectNodes("d:Event", $ns)) {
		$eName = $e.GetAttribute("name")
		$ct = $e.GetAttribute("callType")
		if ($ct) { $evts += "$eName[$ct]" }
		else { $evts += $eName }
	}
	if ($evts.Count -eq 0) { return "" }
	return " {$($evts -join ', ')}"
}

# --- Helper: get flags ---

function Get-Flags($node) {
	$flags = @()
	$vis = $node.SelectSingleNode("d:Visible", $ns)
	if ($vis -and $vis.InnerText -eq "false") { $flags += "visible:false" }
	$en = $node.SelectSingleNode("d:Enabled", $ns)
	if ($en -and $en.InnerText -eq "false") { $flags += "enabled:false" }
	$ro = $node.SelectSingleNode("d:ReadOnly", $ns)
	if ($ro -and $ro.InnerText -eq "true") { $flags += "ro" }
	if ($flags.Count -eq 0) { return "" }
	return " [$($flags -join ',')]"
}

# --- Element type abbreviations ---

$skipElements = @{
	"ExtendedTooltip" = $true
	"ContextMenu" = $true
	"AutoCommandBar" = $true
	"SearchStringAddition" = $true
	"ViewStatusAddition" = $true
	"SearchControlAddition" = $true
	"ColumnGroup" = $true
}

function Get-ElementTag($node) {
	$localName = $node.LocalName
	switch ($localName) {
		"UsualGroup" {
			$groupNode = $node.SelectSingleNode("d:Group", $ns)
			$orient = ""
			if ($groupNode) {
				switch ($groupNode.InnerText) {
					"Vertical" { $orient = ":V" }
					"Horizontal" { $orient = ":H" }
					"AlwaysHorizontal" { $orient = ":AH" }
					"AlwaysVertical" { $orient = ":AV" }
				}
			}
			$beh = $node.SelectSingleNode("d:Behavior", $ns)
			$collapse = ""
			if ($beh -and $beh.InnerText -eq "Collapsible") { $collapse = ",collapse" }
			return "[Group$orient$collapse]"
		}
		"InputField" { return "[Input]" }
		"CheckBoxField" { return "[Check]" }
		"LabelDecoration" { return "[Label]" }
		"LabelField" { return "[LabelField]" }
		"PictureDecoration" { return "[Picture]" }
		"PictureField" { return "[PicField]" }
		"CalendarField" { return "[Calendar]" }
		"Table" { return "[Table]" }
		"Button" { return "[Button]" }
		"CommandBar" { return "[CmdBar]" }
		"Pages" { return "[Pages]" }
		"Page" { return "[Page]" }
		"Popup" { return "[Popup]" }
		"ButtonGroup" { return "[BtnGroup]" }
		default { return "[$localName]" }
	}
}

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

function Count-SignificantChildren($childItemsNode) {
	if (-not $childItemsNode) { return 0 }
	$count = 0
	foreach ($child in $childItemsNode.ChildNodes) {
		if ($child.NodeType -ne "Element") { continue }
		if ($skipElements.ContainsKey($child.LocalName)) { continue }
		$count++
	}
	return $count
}

# --- Build element tree recursively ---

$treeLines = [System.Collections.Generic.List[string]]::new()
$script:hasCollapsed = $false

function Build-Tree($childItemsNode, [string]$prefix, [bool]$isLast) {
	if (-not $childItemsNode) { return }

	# Collect significant children
	$children = @()
	foreach ($child in $childItemsNode.ChildNodes) {
		if ($child.NodeType -ne "Element") { continue }
		if ($skipElements.ContainsKey($child.LocalName)) { continue }
		$children += $child
	}

	for ($i = 0; $i -lt $children.Count; $i++) {
		$child = $children[$i]
		$last = ($i -eq $children.Count - 1)
		$connector = if ($last) { [char]0x2514 + [string][char]0x2500 } else { [char]0x251C + [string][char]0x2500 }
		$continuation = if ($last) { "  " } else { [string][char]0x2502 + " " }

		$tag = Get-ElementTag $child
		$name = $child.GetAttribute("name")
		$flags = Get-Flags $child
		$events = Get-EventsStr $child

		# DataPath or CommandName
		$binding = ""
		$dp = $child.SelectSingleNode("d:DataPath", $ns)
		if ($dp) {
			$binding = " -> $($dp.InnerText)"
		} else {
			$cn = $child.SelectSingleNode("d:CommandName", $ns)
			if ($cn) {
				$cnVal = $cn.InnerText
				if ($cnVal -match '^Form\.StandardCommand\.(.+)$') {
					$binding = " -> $($Matches[1]) [std]"
				} elseif ($cnVal -match '^Form\.Command\.(.+)$') {
					$binding = " -> $($Matches[1]) [cmd]"
				} else {
					$binding = " -> $cnVal"
				}
			}
		}

		# Title differs?
		$titleStr = ""
		$diffTitle = Test-TitleDiffers $child $name
		if ($diffTitle) { $titleStr = " [title:$diffTitle]" }

		$line = "$prefix$connector $tag $name$binding$flags$titleStr$events"
		$treeLines.Add($line)

		# Recurse into containers (but not Page — show summary unless expanded)
		$localName = $child.LocalName
		if ($localName -eq "Page") {
			$ci = $child.SelectSingleNode("d:ChildItems", $ns)
			$pageName = $child.GetAttribute("name")
			$pageTitle = Test-TitleDiffers $child $pageName
			$shouldExpand = ($Expand -eq "*") -or ($Expand -eq $pageName) -or ($pageTitle -and $Expand -eq $pageTitle)
			if ($shouldExpand -and $ci) {
				Build-Tree $ci "$prefix$continuation" $last
			} else {
				$cnt = Count-SignificantChildren $ci
				$idx = $treeLines.Count - 1
				$treeLines[$idx] = $treeLines[$idx] + " ($cnt items)"
				$script:hasCollapsed = $true
			}
		} elseif ($localName -in @("UsualGroup", "Pages", "Table", "CommandBar", "ButtonGroup", "Popup")) {
			$ci = $child.SelectSingleNode("d:ChildItems", $ns)
			if ($ci) {
				Build-Tree $ci "$prefix$continuation" $last
			}
		}
	}
}

# --- Determine form name and object from path ---

$resolvedPath = (Resolve-Path $FormPath).Path
$parts = $resolvedPath -split '[/\\]'

$formName = ""
$objectContext = ""

# Look for /Forms/<FormName>/Ext/Form.xml pattern
$formsIdx = -1
for ($i = $parts.Count - 1; $i -ge 0; $i--) {
	if ($parts[$i] -eq "Forms") { $formsIdx = $i; break }
}

if ($formsIdx -ge 0 -and ($formsIdx + 1) -lt $parts.Count) {
	$formName = $parts[$formsIdx + 1]
	# Object is 2 levels up: .../<ObjectType>/<ObjectName>/Forms/...
	if ($formsIdx -ge 2) {
		$objType = $parts[$formsIdx - 2]
		$objName = $parts[$formsIdx - 1]
		$objectContext = "$objType.$objName"
	}
} else {
	# CommonForms pattern: .../<ObjectType>/<FormName>/Ext/Form.xml
	$extIdx = -1
	for ($i = $parts.Count - 1; $i -ge 0; $i--) {
		if ($parts[$i] -eq "Ext") { $extIdx = $i; break }
	}
	if ($extIdx -ge 2) {
		$formName = $parts[$extIdx - 1]
		$objType = $parts[$extIdx - 2]
		$objectContext = $objType
	} else {
		$formName = [System.IO.Path]::GetFileNameWithoutExtension($FormPath)
	}
}

# --- 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 (form/template/etc.) and the config root
# bin. Never throws — degrades to "не на поддержке".
function Test-ExternalObjectRoot([string]$xmlPath) {
	if (-not (Test-Path $xmlPath)) { return $false }
	try {
		[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
		$el = $mx.DocumentElement.FirstChild
		while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
		if ($el) { return @('ExternalDataProcessor','ExternalReport') -contains $el.LocalName }
	} catch {}
	return $false
}
function Get-SupportStatusForPath([string]$targetPath) {
	try {
		$rp = (Resolve-Path $targetPath).Path
		$elemUuid = $null
		$binPath = $null
		# Reads the uuid of the first metadata element in an .xml file (or $null).
		function Get-RootUuid([string]$xmlPath) {
			if (-not (Test-Path $xmlPath)) { return $null }
			try {
				[xml]$mx = Get-Content -Path $xmlPath -Encoding UTF8
				$el = $mx.DocumentElement.FirstChild
				while ($el -and $el.NodeType -ne 'Element') { $el = $el.NextSibling }
				if ($el) { $u = $el.GetAttribute("uuid"); if ($u) { return $u } }
			} catch {}
			return $null
		}
		# The target file itself may be the element meta-xml (e.g. Subsystems/X.xml).
		$elemUuid = Get-RootUuid $rp
		if (Test-ExternalObjectRoot $rp) { return $null }
		$d = [System.IO.Path]::GetDirectoryName($rp)
		for ($i = 0; $i -lt 12 -and $d; $i++) {
			if (Test-ExternalObjectRoot "$d.xml") { return $null }
			if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
			if (-not $binPath) {
				$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
				if ((Test-Path $cand) -or (Test-Path (Join-Path $d "Configuration.xml"))) { $binPath = $cand }
			}
			if ($elemUuid -and $binPath) { break }
			$parent = [System.IO.Path]::GetDirectoryName($d)
			if ($parent -eq $d) { break }
			$d = $parent
		}
		if (-not $binPath -or -not (Test-Path $binPath)) { return "не на поддержке" }
		$bytes = [System.IO.File]::ReadAllBytes($binPath)
		if ($bytes.Length -le 32) { return "снято с поддержки (правки свободны)" }
		$start = 0
		if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { $start = 3 }
		$text = [System.Text.Encoding]::UTF8.GetString($bytes, $start, $bytes.Length - $start)
		$h = [regex]::Match($text, '^\{6,(\d+),(\d+),')
		if (-not $h.Success) { return "не на поддержке" }
		$G = [int]$h.Groups[1].Value
		$K = [int]$h.Groups[2].Value
		if ($K -eq 0) { return "снято с поддержки (правки свободны)" }
		if ($G -eq 1) { return "конфигурация read-only (возможность изменения выключена) — правки невозможны без включения" }
		if (-not $elemUuid) { return "не на поддержке" }
		$u = [regex]::Escape($elemUuid.ToLower())
		$best = $null
		foreach ($m in [regex]::Matches($text, "([0-2]),0,$u")) {
			$f1 = [int]$m.Groups[1].Value
			if ($null -eq $best -or $f1 -lt $best) { $best = $f1 }
		}
		if ($null -eq $best) { return "не на поддержке" }
		switch ($best) {
			0 { return "на замке — прямая правка сломает обновления; дорабатывай через cfe-* либо включи редактирование объекта" }
			1 { return "редактируется с сохранением поддержки" }
			2 { return "снято с поддержки (правки свободны)" }
		}
		return "не на поддержке"
	} catch { return "не на поддержке" }
}

# --- Collect output ---

$lines = @()

# Header — include Title if present
$titleNode = $root.SelectSingleNode("d:Title", $ns)
$formTitle = $null
if ($titleNode) {
	$formTitle = Get-MLText $titleNode
	if (-not $formTitle) { $formTitle = $titleNode.InnerText }
}
$extMarker = if ($isExtension) { " [EXTENSION]" } else { "" }
$header = "=== Form: $formName$extMarker"
if ($formTitle) { $header += " — `"$formTitle`"" }
if ($objectContext) { $header += " ($objectContext)" }
$header += " ==="
$lines += $header
$support = Get-SupportStatusForPath $FormPath
if ($null -ne $support) { $lines += "Поддержка: $support" }

# --- Form properties (Title excluded — shown in header) ---

$propNames = @(
	"Width", "Height", "Group",
	"WindowOpeningMode", "EnterKeyBehavior", "AutoTitle", "AutoURL",
	"AutoFillCheck", "Customizable", "CommandBarLocation",
	"SaveDataInSettings", "AutoSaveDataInSettings",
	"AutoTime", "UsePostingMode", "RepostOnWrite",
	"UseForFoldersAndItems",
	"ReportResult", "DetailsData", "ReportFormType",
	"VerticalScroll", "ScalingMode"
)

$props = @()
foreach ($pn in $propNames) {
	$pNode = $root.SelectSingleNode("d:$pn", $ns)
	if ($pNode) {
		$val = Get-MLText $pNode
		if (-not $val) { $val = $pNode.InnerText }
		$props += "$pn=$val"
	}
}

if ($props.Count -gt 0) {
	$lines += ""
	$lines += "Properties: $($props -join ', ')"
}

# --- Excluded commands ---

$excludedCmds = @()
foreach ($ec in $root.SelectNodes("d:CommandSet/d:ExcludedCommand", $ns)) {
	$excludedCmds += $ec.InnerText
}

# --- Form events ---

$formEvents = $root.SelectSingleNode("d:Events", $ns)
if ($formEvents -and $formEvents.HasChildNodes) {
	$lines += ""
	$lines += "Events:"
	foreach ($e in $formEvents.SelectNodes("d:Event", $ns)) {
		$eName = $e.GetAttribute("name")
		$eHandler = $e.InnerText
		$ct = $e.GetAttribute("callType")
		$ctStr = if ($ct) { "[$ct]" } else { "" }
		$lines += "  $eName${ctStr} -> $eHandler"
	}
}

# --- Main AutoCommandBar (form's id=-1 panel) ---

function Format-MainAcb($acbNode) {
	if (-not $acbNode) { return @() }
	$result = @()
	$autofillNode = $acbNode.SelectSingleNode("d:Autofill", $ns)
	$autofill = $true
	if ($autofillNode -and $autofillNode.InnerText -eq "false") { $autofill = $false }
	$halignNode = $acbNode.SelectSingleNode("d:HorizontalAlign", $ns)
	$flags = @()
	$flags += if ($autofill) { "autofill" } else { "no-autofill" }
	if ($halignNode) { $flags += "align=$($halignNode.InnerText)" }
	$header = "AutoCommandBar [$($flags -join ', ')]"
	$childItemsNode = $acbNode.SelectSingleNode("d:ChildItems", $ns)
	$buttons = @()
	if ($childItemsNode) {
		foreach ($btn in $childItemsNode.ChildNodes) {
			if ($btn.NodeType -ne "Element") { continue }
			if ($skipElements.ContainsKey($btn.LocalName)) { continue }
			$bName = $btn.GetAttribute("name")
			$cmdNode = $btn.SelectSingleNode("d:CommandName", $ns)
			$cmdRef = if ($cmdNode) { $cmdNode.InnerText } else { "" }
			$locNode = $btn.SelectSingleNode("d:LocationInCommandBar", $ns)
			$locStr = if ($locNode) { " [$($locNode.InnerText)]" } else { "" }
			$tag = Get-ElementTag $btn
			if ($cmdRef) {
				$buttons += "  $tag $bName -> $cmdRef$locStr"
			} else {
				$buttons += "  $tag $bName$locStr"
			}
		}
	}
	if ($buttons.Count -eq 0 -and $autofill -and -not $halignNode) {
		# Default empty panel — terse one-liner
		return @("AutoCommandBar [autofill]")
	}
	$result += $header
	$result += $buttons
	return $result
}

# Determine position from CommandBarLocation form property
$cbLocNode = $root.SelectSingleNode("d:CommandBarLocation", $ns)
$cbLoc = if ($cbLocNode) { $cbLocNode.InnerText } else { "Auto" }
$mainAcbNode = $root.SelectSingleNode("d:AutoCommandBar", $ns)
$acbLines = @()
if ($cbLoc -ne "None" -and $mainAcbNode) {
	$acbLines = Format-MainAcb $mainAcbNode
}

# AutoCommandBar above Elements (Auto/Top)
if ($acbLines.Count -gt 0 -and ($cbLoc -eq "Auto" -or $cbLoc -eq "Top")) {
	$lines += ""
	$lines += $acbLines
}

# --- Element tree ---

$childItems = $root.SelectSingleNode("d:ChildItems", $ns)
if ($childItems) {
	$lines += ""
	$lines += "Elements:"
	Build-Tree $childItems "  " $false
	$lines += $treeLines.ToArray()
}

# AutoCommandBar below Elements (Bottom)
if ($acbLines.Count -gt 0 -and $cbLoc -eq "Bottom") {
	$lines += ""
	$lines += $acbLines
}

# --- Attributes ---

$attrsNode = $root.SelectSingleNode("d:Attributes", $ns)
if ($attrsNode) {
	$attrLines = @()
	foreach ($attr in $attrsNode.SelectNodes("d:Attribute", $ns)) {
		$aName = $attr.GetAttribute("name")
		$typeNode = $attr.SelectSingleNode("d:Type", $ns)
		$typeStr = Format-Type $typeNode

		$mainAttr = $attr.SelectSingleNode("d:MainAttribute", $ns)
		$isMain = ($mainAttr -and $mainAttr.InnerText -eq "true")

		$prefix = if ($isMain) { "*" } else { " " }
		$mainSuffix = if ($isMain) { " (main)" } else { "" }

		# DynamicList: show MainTable
		$settings = $attr.SelectSingleNode("d:Settings", $ns)
		$dynTable = ""
		if ($settings -and $typeStr -eq "DynamicList") {
			$mt = $settings.SelectSingleNode("d:MainTable", $ns)
			if ($mt) { $dynTable = " -> $($mt.InnerText)" }
		}

		# ValueTable/ValueTree columns
		$colStr = ""
		$columns = $attr.SelectSingleNode("d:Columns", $ns)
		if ($columns -and ($typeStr -eq "ValueTable" -or $typeStr -eq "ValueTree")) {
			$cols = @()
			foreach ($col in $columns.SelectNodes("d:Column", $ns)) {
				$cName = $col.GetAttribute("name")
				$cTypeNode = $col.SelectSingleNode("d:Type", $ns)
				$cType = Format-Type $cTypeNode
				if ($cType) { $cols += "$cName`: $cType" } else { $cols += $cName }
			}
			if ($cols.Count -gt 0) {
				$colStr = " [$($cols -join ', ')]"
			}
		}

		$line = "  $prefix$aName`: $typeStr$colStr$dynTable$mainSuffix"
		if (-not $typeStr -and -not $colStr -and -not $dynTable) {
			$line = "  $prefix$aName$mainSuffix"
		}
		$attrLines += $line
	}
	if ($attrLines.Count -gt 0) {
		$lines += ""
		$lines += "Attributes:"
		$lines += $attrLines
	}
}

# --- Parameters ---

$paramsNode = $root.SelectSingleNode("d:Parameters", $ns)
if ($paramsNode) {
	$paramLines = @()
	foreach ($param in $paramsNode.SelectNodes("d:Parameter", $ns)) {
		$pName = $param.GetAttribute("name")
		$typeNode = $param.SelectSingleNode("d:Type", $ns)
		$typeStr = Format-Type $typeNode

		$keyParam = $param.SelectSingleNode("d:KeyParameter", $ns)
		$isKey = ($keyParam -and $keyParam.InnerText -eq "true")
		$keySuffix = if ($isKey) { " (key)" } else { "" }

		if ($typeStr) {
			$paramLines += "  $pName`: $typeStr$keySuffix"
		} else {
			$paramLines += "  $pName$keySuffix"
		}
	}
	if ($paramLines.Count -gt 0) {
		$lines += ""
		$lines += "Parameters:"
		$lines += $paramLines
	}
}

# --- Commands ---

$cmdsNode = $root.SelectSingleNode("d:Commands", $ns)
if ($cmdsNode) {
	$cmdLines = @()
	foreach ($cmd in $cmdsNode.SelectNodes("d:Command", $ns)) {
		$cName = $cmd.GetAttribute("name")
		$shortcut = $cmd.SelectSingleNode("d:Shortcut", $ns)
		$scStr = if ($shortcut) { " [$($shortcut.InnerText)]" } else { "" }

		# Collect all Action elements (may have multiple with callType)
		$actions = $cmd.SelectNodes("d:Action", $ns)
		if ($actions.Count -gt 1) {
			$actParts = @()
			foreach ($a in $actions) {
				$ct = $a.GetAttribute("callType")
				$ctStr = if ($ct) { "[$ct]" } else { "" }
				$actParts += "$($a.InnerText)$ctStr"
			}
			$actionStr = " -> $($actParts -join ', ')"
		} elseif ($actions.Count -eq 1) {
			$ct = $actions[0].GetAttribute("callType")
			$ctStr = if ($ct) { "[$ct]" } else { "" }
			$actionStr = " -> $($actions[0].InnerText)$ctStr"
		} else {
			$actionStr = ""
		}

		$cmdLines += "  $cName$actionStr$scStr"
	}
	if ($cmdLines.Count -gt 0) {
		$lines += ""
		$lines += "Commands:"
		$lines += $cmdLines
	}
}

# --- BaseForm footer ---

if ($isExtension) {
	$bfVersion = $baseFormNode.GetAttribute("version")
	$bfStr = if ($bfVersion) { "present (version $bfVersion)" } else { "present" }
	$lines += ""
	$lines += "BaseForm: $bfStr"
}

# --- Expand hint ---

if ($script:hasCollapsed) {
	$lines += ""
	$lines += "Hint: use -Expand <name> to expand a collapsed section, -Expand * for all"
}

# --- Truncation protection ---

$totalLines = $lines.Count

if ($Offset -gt 0) {
	if ($Offset -ge $totalLines) {
		Write-Host "[INFO] Offset $Offset exceeds total lines ($totalLines). Nothing to show."
		exit 0
	}
	$lines = $lines[$Offset..($totalLines - 1)]
}

if ($lines.Count -gt $Limit) {
	$shown = $lines[0..($Limit - 1)]
	foreach ($l in $shown) { Write-Host $l }
	$remaining = $totalLines - $Offset - $Limit
	Write-Host ""
	Write-Host "[TRUNCATED] Shown $Limit of $totalLines lines. Use -Offset $($Offset + $Limit) to continue."
} else {
	foreach ($l in $lines) { Write-Host $l }
}
