# cf-edit v1.28 — Edit 1C configuration root (Configuration.xml)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
[CmdletBinding(PositionalBinding=$false)]
param(
	[Parameter(Mandatory)][Alias('Path')][string]$ConfigPath,
	[string]$DefinitionFile,
	[ValidateSet("modify-property","add-childObject","remove-childObject","add-defaultRole","remove-defaultRole","set-defaultRoles","set-panels","set-home-page","sort-childObjects")]
	[string]$Operation,
	[string]$Value,
	[switch]$NoValidate
)

$ErrorActionPreference = "Stop"

# --- Разбор пользовательского JSON ---
# Одна строка в stderr вместо дампа исключения ConvertFrom-Json (issue #80): агент по стектрейсу
# идёт чинить скрипт, а не свой вызов. $source — файл или параметр. $expected заполняем только
# для полиморфного входа: у файла подсказка была бы наполнителем. -Inline печатает ещё и то,
# что доехало: у файла такого вопроса нет — путь назван, позицию дал парсер, файл на диске.
# Возврат через -NoEnumerate: без него одноэлементный
# JSON-массив разворачивался бы в скаляр вторым анруллингом.
function ConvertFrom-JsonInput([string]$text, [string]$source, [string]$expected, [switch]$Inline) {
	try {
		# PS 5.1 на пустой строке отдаёт $null, а не ошибку — навык уходил дальше с $null,
		# тогда как py-порт падал. Проверяем сами, чтобы порты вели себя одинаково.
		if ([string]::IsNullOrWhiteSpace($text)) { throw 'input is empty' }
		$parsed = $text | ConvertFrom-Json
	} catch {
		$what = if ($expected) { "$source expects $expected" } else { "Invalid JSON in $source" }
		if ($Inline) {
			$got = ($text -replace '\s+', ' ').Trim()
			$label = 'got'
			if (-not $got) { $got = '(empty)' }
			elseif ($got.Length -gt 60) { $label = 'got (first 60 chars)'; $got = $got.Substring(0, 60) }
			$what = "${what}, ${label}: ${got}"
		}
		[Console]::Error.WriteLine("[ERROR] ${what} ($($_.Exception.Message))")
		exit 1
	}
	Write-Output -NoEnumerate $parsed
}

# --- Чтение входного JSON-файла ---
# Кодировку берём из BOM — это объявление самого файла, а не догадка. Без BOM ждём строгий UTF-8:
# Get-Content -Encoding UTF8 на файле в cp1251 тихо меняет кириллицу на U+FFFD, JSON после этого
# разбирается успешно, и в конфигурацию уезжает имя из «замен». Кодовую страницу не подбираем:
# угаданное имя уйдёт в метаданные так же молча.
function Read-JsonInputFile([string]$path) {
	# Проверка здесь, а не по навыкам: часть навыков проверяла путь сама, часть — нет, и один и тот
	# же промах давал то внятную строку, то дамп MethodInvocationException. Навыки со своей
	# проверкой срабатывают раньше и сохраняют свой текст.
	if (-not (Test-Path -LiteralPath $path)) {
		[Console]::Error.WriteLine("[ERROR] File not found: $path")
		exit 1
	}
	if (Test-Path -LiteralPath $path -PathType Container) {
		[Console]::Error.WriteLine("[ERROR] Expected a JSON file, got a directory: $path")
		exit 1
	}
	$bytes = [System.IO.File]::ReadAllBytes($path)
	if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) {
		return [System.Text.Encoding]::UTF8.GetString($bytes, 3, $bytes.Length - 3)
	}
	if ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE) {
		return [System.Text.Encoding]::Unicode.GetString($bytes, 2, $bytes.Length - 2)
	}
	if ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFE -and $bytes[1] -eq 0xFF) {
		return [System.Text.Encoding]::BigEndianUnicode.GetString($bytes, 2, $bytes.Length - 2)
	}
	try {
		return (New-Object System.Text.UTF8Encoding($false, $true)).GetString($bytes)
	} catch {
		$detail = if ($_.Exception.InnerException) { $_.Exception.InnerException.Message } else { $_.Exception.Message }
		[Console]::Error.WriteLine("[ERROR] ${path} is not valid UTF-8: ${detail} - save the file as UTF-8, or add a BOM if it is UTF-16")
		exit 1
	}
}
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8

# --- Mode validation ---
if ($DefinitionFile -and $Operation) { Write-Error "Cannot use both -DefinitionFile and -Operation"; exit 1 }
if (-not $DefinitionFile -and -not $Operation) { Write-Error "Either -DefinitionFile or -Operation is required"; exit 1 }

# --- Resolve path ---
if (-not [System.IO.Path]::IsPathRooted($ConfigPath)) {
	$ConfigPath = Join-Path (Get-Location).Path $ConfigPath
}
if (Test-Path $ConfigPath -PathType Container) {
	$candidate = Join-Path $ConfigPath "Configuration.xml"
	if (Test-Path $candidate) { $ConfigPath = $candidate }
	else { Write-Error "No Configuration.xml in directory"; exit 1 }
}
if (-not (Test-Path $ConfigPath)) { Write-Error "File not found: $ConfigPath"; exit 1 }
$resolvedPath = (Resolve-Path $ConfigPath).Path
$script:configDir = [System.IO.Path]::GetDirectoryName($resolvedPath)

# --- Support guard (Ext/ParentConfigurations.bin) ---
# See docs/1c-support-state-spec.md. Blocks edits of vendor objects "на замке" /
# read-only configs unless allowed. Trigger = bin present; reaction from
# .v8-project.json editingAllowedCheck (deny|warn|off, default deny). Never
# throws — guard errors degrade to allow.
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
}
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 Find-V8Project([string]$startDir) {
	$d = $startDir
	for ($i = 0; $i -lt 20 -and $d; $i++) {
		$pj = Join-Path $d ".v8-project.json"
		if (Test-Path $pj) { return $pj }
		$parent = [System.IO.Path]::GetDirectoryName($d)
		if ($parent -eq $d) { break }
		$d = $parent
	}
	return $null
}
function Get-EditMode([string]$cfgDir) {
	try {
		$pj = Find-V8Project (Get-Location).Path
		if (-not $pj) { $pj = Find-V8Project $cfgDir }
		if (-not $pj) { return 'deny' }
		$proj = Get-Content -Raw $pj | ConvertFrom-Json
		$cfgFull = [System.IO.Path]::GetFullPath($cfgDir).TrimEnd('\', '/')
		if ($proj.databases) {
			foreach ($db in $proj.databases) {
				if ($db.configSrc) {
					$src = [System.IO.Path]::GetFullPath($db.configSrc).TrimEnd('\', '/')
					if ($cfgFull -eq $src -or $cfgFull.StartsWith($src + [System.IO.Path]::DirectorySeparatorChar)) {
						if ($db.editingAllowedCheck) { return $db.editingAllowedCheck }
					}
				}
			}
		}
		if ($proj.editingAllowedCheck) { return $proj.editingAllowedCheck }
		return 'deny'
	} catch { return 'deny' }
}
function Assert-EditAllowed([string]$targetPath, [string]$require) {
	try {
		$rp = $targetPath
		try { $rp = (Resolve-Path $targetPath -ErrorAction Stop).Path } catch {}
		# Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
		if (Test-ExternalObjectRoot $rp) { return }
		$elemUuid = Get-RootUuid $rp
		$cfgDir = $null; $binPath = $null
		$d = if (Test-Path $rp -PathType Container) { $rp } else { [System.IO.Path]::GetDirectoryName($rp) }
		for ($i = 0; $i -lt 12 -and $d; $i++) {
			if (Test-ExternalObjectRoot "$d.xml") { return }
			if (-not $elemUuid) { $elemUuid = Get-RootUuid "$d.xml" }
			if (-not $cfgDir) {
				$cand = Join-Path (Join-Path $d "Ext") "ParentConfigurations.bin"
				if ((Test-Path $cand) -or (Test-Path (Join-Path $d "Configuration.xml"))) { $cfgDir = $d; $binPath = $cand }
			}
			if ($elemUuid -and $cfgDir) { break }
			$parent = [System.IO.Path]::GetDirectoryName($d)
			if ($parent -eq $d) { break }
			$d = $parent
		}
		# New object (no element file): fall back to config root uuid.
		if (-not $elemUuid -and $cfgDir) { $elemUuid = Get-RootUuid (Join-Path $cfgDir "Configuration.xml") }
		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)
		$hm = [regex]::Match($text, '^\{6,(\d+),(\d+),')
		if (-not $hm.Success) { return }
		$G = [int]$hm.Groups[1].Value
		$K = [int]$hm.Groups[2].Value
		if ($K -eq 0) { return }
		$best = $null
		if ($elemUuid) {
			$u = [regex]::Escape($elemUuid.ToLower())
			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 }
			}
		}
		$blocked = $false; $code = ""; $reason = ""
		if ($G -eq 1) { $blocked = $true; $code = "capability-off"; $reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)" }
		elseif ($require -eq 'removed') {
			if ($null -ne $best -and $best -ne 2) { $blocked = $true; $code = "not-removed"; $reason = "объект не снят с поддержки — удаление сломает обновления" }
		}
		else {
			if ($null -ne $best -and $best -eq 0) { $blocked = $true; $code = "locked"; $reason = "объект на замке — редактирование сломает обновления" }
		}
		if (-not $blocked) { return }
		$mode = Get-EditMode $cfgDir
		if ($mode -eq 'off') { return }
		# Use Console.Error (not Write-Error) — under ErrorActionPreference=Stop the
		# latter throws and would be swallowed by this function's own catch.
		if ($mode -eq 'warn') { [Console]::Error.WriteLine("[support-guard] ПРЕДУПРЕЖДЕНИЕ: $reason. Цель: $rp"); return }
		$head = "[support-guard] Редактирование отклонено: это объект типовой конфигурации на поддержке поставщика, прямое редактирование молча сломает будущие обновления."
		$cfe = "Рекомендуемый путь: внести доработку в расширение (навыки cfe-borrow / cfe-patch-method) — состояние поддержки менять не нужно, обновления вендора сохраняются."
		$offNote = "Снять проверку для этой базы: editingAllowedCheck = warn|off в .v8-project.json."
		if ($code -eq "capability-off") {
			$state = "Состояние: у всей конфигурации выключена возможность изменения (режим read-only «из коробки») — поэтому объект «$rp» редактировать нельзя."
			$fix = "Либо снять защиту явно (навык support-edit, два шага):`n  1. support-edit -Path ""$cfgDir"" -Capability on — включить возможность изменения (объекты пока остаются на замке);`n  2. support-edit -Path ""$rp"" -Set editable — открыть этот объект для редактирования.`n  Изменение применяется в базу полной загрузкой выгрузки и обходит механизм обновлений вендора."
		} elseif ($code -eq "not-removed") {
			$state = "Состояние: объект «$rp» на поддержке (не снят с поддержки) — его удаление разорвёт обновления вендора."
			$fix = "Либо сначала снять объект с поддержки, затем удалять:`n  support-edit -Path ""$rp"" -Set off-support — объект уходит из-под обновлений, после этого удаление безопасно."
		} else {
			$state = "Состояние: объект «$rp» на замке (возможность изменения конфигурации включена, но сам объект не редактируется)."
			$fix = "Либо разрешить редактирование этого объекта (навык support-edit, выбрать одно):`n  support-edit -Path ""$rp"" -Set editable — редактировать и дальше получать обновления вендора (возможны конфликты слияния);`n  support-edit -Path ""$rp"" -Set off-support — снять с поддержки: обновления по объекту больше не приходят."
		}
		[Console]::Error.WriteLine("$head`n$state`n$cfe`n$fix`n$offNote")
		exit 1
	} catch { return }
}

Assert-EditAllowed $resolvedPath 'editable'

# --- Load XML with PreserveWhitespace ---
# NB: парсер XML по спецификации схлопывает CRLF в LF, а вставки ниже собираются с
# явным CRLF — поэтому EOL приводится к целевому в точке записи (см. финализацию).
$script:xmlDoc = New-Object System.Xml.XmlDocument
$script:xmlDoc.PreserveWhitespace = $true
$script:xmlDoc.Load($resolvedPath)

# Версия формата редактируемой конфигурации — создаваемые рядом файлы (Ext/HomePageWorkArea.xml)
# должны нести ту же версию, иначе в проекте окажутся файлы разных версий формата.
$script:formatVersion = $script:xmlDoc.DocumentElement.GetAttribute("version")
if (-not $script:formatVersion) { $script:formatVersion = "2.17" }

$script:addCount = 0
$script:removeCount = 0
$script:modifyCount = 0

function Info([string]$msg) { Write-Host "[INFO] $msg" }
function Warn([string]$msg) { Write-Host "[WARN] $msg" }

# --- Detect structure ---
$root = $script:xmlDoc.DocumentElement
$script:mdNs = "http://v8.1c.ru/8.3/MDClasses"
$script:xrNs = "http://v8.1c.ru/8.3/xcf/readable"
$script:xsiNs = "http://www.w3.org/2001/XMLSchema-instance"
$script:v8Ns = "http://v8.1c.ru/8.1/data/core"

$script:cfgEl = $null
foreach ($child in $root.ChildNodes) {
	if ($child.NodeType -eq 'Element' -and $child.LocalName -eq "Configuration") {
		$script:cfgEl = $child; break
	}
}
if (-not $script:cfgEl) { Write-Error "No <Configuration> element found"; exit 1 }

$script:propsEl = $null
$script:childObjsEl = $null
foreach ($child in $script:cfgEl.ChildNodes) {
	if ($child.NodeType -ne 'Element') { continue }
	if ($child.LocalName -eq "Properties") { $script:propsEl = $child }
	if ($child.LocalName -eq "ChildObjects") { $script:childObjsEl = $child }
}

$script:objName = ""
foreach ($child in $script:propsEl.ChildNodes) {
	if ($child.NodeType -eq 'Element' -and $child.LocalName -eq "Name") {
		$script:objName = $child.InnerText.Trim(); break
	}
}
Info "Configuration: $($script:objName)"

# --- Canonical type order for ChildObjects (46 types) ---
$script:typeOrder = @(
	"Language","Subsystem","StyleItem","Style",
	"CommonPicture","SessionParameter","Role","CommonTemplate",
	"FilterCriterion","CommonModule","CommonAttribute","ExchangePlan",
	"XDTOPackage","WebService","HTTPService","WSReference",
	"EventSubscription","ScheduledJob","SettingsStorage","FunctionalOption",
	"FunctionalOptionsParameter","DefinedType","Bot","PaletteColor","CommonCommand","CommandGroup",
	"Constant","CommonForm","Catalog","Document",
	"DocumentNumerator","Sequence","DocumentJournal","Enum",
	"Report","DataProcessor","InformationRegister","AccumulationRegister",
	"ChartOfCharacteristicTypes","ChartOfAccounts","AccountingRegister",
	"ChartOfCalculationTypes","CalculationRegister",
	"BusinessProcess","Task","IntegrationService"
)

# --- Type → on-disk directory name (plural) ---
$script:typeToDir = @{
	"Language"="Languages"; "Subsystem"="Subsystems"; "StyleItem"="StyleItems"; "Style"="Styles"
	"CommonPicture"="CommonPictures"; "SessionParameter"="SessionParameters"; "Role"="Roles"; "CommonTemplate"="CommonTemplates"
	"FilterCriterion"="FilterCriteria"; "CommonModule"="CommonModules"; "Bot"="Bots"; "PaletteColor"="PaletteColors"; "CommonAttribute"="CommonAttributes"; "ExchangePlan"="ExchangePlans"
	"XDTOPackage"="XDTOPackages"; "WebService"="WebServices"; "HTTPService"="HTTPServices"; "WSReference"="WSReferences"
	"EventSubscription"="EventSubscriptions"; "ScheduledJob"="ScheduledJobs"; "SettingsStorage"="SettingsStorages"; "FunctionalOption"="FunctionalOptions"
	"FunctionalOptionsParameter"="FunctionalOptionsParameters"; "DefinedType"="DefinedTypes"; "CommonCommand"="CommonCommands"; "CommandGroup"="CommandGroups"
	"Constant"="Constants"; "CommonForm"="CommonForms"; "Catalog"="Catalogs"; "Document"="Documents"
	"DocumentNumerator"="DocumentNumerators"; "Sequence"="Sequences"; "DocumentJournal"="DocumentJournals"; "Enum"="Enums"
	"Report"="Reports"; "DataProcessor"="DataProcessors"; "InformationRegister"="InformationRegisters"; "AccumulationRegister"="AccumulationRegisters"
	"ChartOfCharacteristicTypes"="ChartsOfCharacteristicTypes"; "ChartOfAccounts"="ChartsOfAccounts"; "AccountingRegister"="AccountingRegisters"
	"ChartOfCalculationTypes"="ChartsOfCalculationTypes"; "CalculationRegister"="CalculationRegisters"
	"BusinessProcess"="BusinessProcesses"; "Task"="Tasks"; "IntegrationService"="IntegrationServices"
}

# --- XML manipulation helpers (from subsystem-edit pattern) ---
function Get-ChildIndent($container) {
	foreach ($child in $container.ChildNodes) {
		if ($child.NodeType -eq 'Whitespace' -or $child.NodeType -eq 'SignificantWhitespace') {
			if ($child.Value -match '^\r?\n(\t+)$') { return $Matches[1] }
			if ($child.Value -match '^\r?\n(\t+)') { return $Matches[1] }
		}
	}
	$depth = 0; $current = $container
	while ($current -and $current -ne $script:xmlDoc.DocumentElement) { $depth++; $current = $current.ParentNode }
	return "`t" * ($depth + 1)
}

function Insert-BeforeElement($container, $newNode, $refNode, $childIndent) {
	$ws = $script:xmlDoc.CreateWhitespace("`r`n$childIndent")
	if ($refNode) {
		$container.InsertBefore($ws, $refNode) | Out-Null
		$container.InsertBefore($newNode, $ws) | Out-Null
	} else {
		$trailing = $container.LastChild
		if ($trailing -and ($trailing.NodeType -eq 'Whitespace' -or $trailing.NodeType -eq 'SignificantWhitespace')) {
			$container.InsertBefore($ws, $trailing) | Out-Null
			$container.InsertBefore($newNode, $trailing) | Out-Null
		} else {
			$container.AppendChild($ws) | Out-Null
			$container.AppendChild($newNode) | Out-Null
			$parentIndent = if ($childIndent.Length -gt 1) { $childIndent.Substring(0, $childIndent.Length - 1) } else { "" }
			$closeWs = $script:xmlDoc.CreateWhitespace("`r`n$parentIndent")
			$container.AppendChild($closeWs) | Out-Null
		}
	}
}

function Remove-NodeWithWhitespace($node) {
	$parent = $node.ParentNode
	$prev = $node.PreviousSibling
	$next = $node.NextSibling
	if ($prev -and ($prev.NodeType -eq 'Whitespace' -or $prev.NodeType -eq 'SignificantWhitespace')) {
		$parent.RemoveChild($prev) | Out-Null
	} elseif ($next -and ($next.NodeType -eq 'Whitespace' -or $next.NodeType -eq 'SignificantWhitespace')) {
		$parent.RemoveChild($next) | Out-Null
	}
	$parent.RemoveChild($node) | Out-Null
}

function Expand-SelfClosingElement($container, $parentIndent) {
	if (-not $container.HasChildNodes -or $container.IsEmpty) {
		$closeWs = $script:xmlDoc.CreateWhitespace("`r`n$parentIndent")
		$container.AppendChild($closeWs) | Out-Null
	}
}

function Import-Fragment([string]$xmlString) {
	$wrapper = "<_W xmlns=`"$($script:mdNs)`" xmlns:xsi=`"$($script:xsiNs)`" xmlns:v8=`"$($script:v8Ns)`" xmlns:xr=`"$($script:xrNs)`" xmlns:xs=`"http://www.w3.org/2001/XMLSchema`">$xmlString</_W>"
	$frag = New-Object System.Xml.XmlDocument
	$frag.PreserveWhitespace = $true
	$frag.LoadXml($wrapper)
	$nodes = @()
	foreach ($child in $frag.DocumentElement.ChildNodes) {
		if ($child.NodeType -eq 'Element') {
			$nodes += $script:xmlDoc.ImportNode($child, $true)
		}
	}
	return ,$nodes
}

# --- Parse batch value (split by ;;) ---

# Имя вида из пользовательского ввода → каноническое имя или $null.
# Ввод прощающий: регистр не важен, принимается имя каталога выгрузки (Catalogs → Catalog)
# и русское имя вида в единственном и множественном числе.
function Resolve-TypeName([string]$token) {
	$key = "$token".Trim()
	if (-not $key) { return $null }
	foreach ($canon in $script:typeOrder) { if ($canon -eq $key) { return $canon } }
	$byDir = $script:dirToType[$key.ToLowerInvariant()]
	if ($byDir) { return $byDir }
	$ru = $script:ruTypeMap[$key.ToLowerInvariant()]
	if ($ru) { return $ru }
	return $null
}

function Parse-BatchValue([string]$val) {
	$items = @()
	foreach ($part in $val.Split(";;")) {
		$trimmed = $part.Trim()
		if ($trimmed) { $items += $trimmed }
	}
	return ,$items
}

# --- LocalString properties ---
$mlProps = @("Synonym","BriefInformation","DetailedInformation","Copyright","VendorInformationAddress","ConfigurationInformationAddress")
# Scalar properties
$scalarProps = @("Name","Version","Vendor","Comment","NamePrefix","UpdateCatalogAddress")
# Ref properties
$refProps = @("DefaultLanguage")

# --- Operation: modify-property ---
function Do-ModifyProperty([string]$batchVal) {
	$items = Parse-BatchValue $batchVal
	foreach ($item in $items) {
		$eqIdx = $item.IndexOf("=")
		if ($eqIdx -lt 1) {
			Write-Error "Invalid property format '$item', expected 'Key=Value'"
			exit 1
		}
		$propName = $item.Substring(0, $eqIdx).Trim()
		$propValue = $item.Substring($eqIdx + 1).Trim()

		# Find property element
		$propEl = $null
		foreach ($child in $script:propsEl.ChildNodes) {
			if ($child.NodeType -eq 'Element' -and $child.LocalName -eq $propName) {
				$propEl = $child; break
			}
		}
		if (-not $propEl) {
			Write-Error "Property '$propName' not found in Properties"
			exit 1
		}

		if ($mlProps -contains $propName) {
			# LocalString
			if (-not $propValue) {
				$propEl.InnerXml = ""
			} else {
				$indent = Get-ChildIndent $script:propsEl
				$escaped = [System.Security.SecurityElement]::Escape($propValue)
				$mlXml = "`r`n$indent`t<v8:item>`r`n$indent`t`t<v8:lang>ru</v8:lang>`r`n$indent`t`t<v8:content>$escaped</v8:content>`r`n$indent`t</v8:item>`r`n$indent"
				$propEl.InnerXml = $mlXml
			}
		} elseif ($scalarProps -contains $propName -or $refProps -contains $propName) {
			# Simple text
			if (-not $propValue) { $propEl.InnerXml = "" }
			else { $propEl.InnerText = $propValue }
		} else {
			# Enum or other — just set text
			$propEl.InnerText = $propValue
		}

		$script:modifyCount++
		Info "Set $propName = `"$propValue`""
	}
}

# --- Operation: add-childObject ---
# Куда навык ставит новую запись в <ChildObjects> — настройка newObjectPosition.
# databases[].newObjectPosition базы, чей configSrc охватывает каталог родительского XML,
# иначе корневое поле, иначе end. Значения: end — после последнего объекта того же вида
# (так дописывает Конфигуратор); byName — по имени среди объектов того же вида.
# Файл ищем от рабочего каталога вверх, каталог конфигурации — запасной путь: так же
# его ищут support-guard и группа db-*, а скрипт навыка зовут по абсолютному пути, и cwd
# остаётся рабочим каталогом проекта.
# configSrc считается от каталога .v8-project.json, как задокументировано в
# docs/v8-project-guide.md. Реестр семьи: tests/skills/check-inline-drift.mjs.
function Get-NewObjectPosition([string]$cfgDir) {
	try {
		if (-not $cfgDir) { $cfgDir = "." }
		$pj = Find-V8Project (Get-Location).Path
		if (-not $pj) { $pj = Find-V8Project ([System.IO.Path]::GetFullPath($cfgDir)) }
		if (-not $pj) { return "end" }
		$proj = Get-Content -Raw $pj | ConvertFrom-Json
		$projDir = [System.IO.Path]::GetDirectoryName($pj)
		$cfgFull = [System.IO.Path]::GetFullPath($cfgDir).TrimEnd('\', '/')
		if ($proj.databases) {
			foreach ($db in $proj.databases) {
				if ($db.configSrc -and $db.newObjectPosition) {
					$src = [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($projDir, $db.configSrc)).TrimEnd('\', '/')
					if ($cfgFull -eq $src -or $cfgFull.StartsWith($src + [System.IO.Path]::DirectorySeparatorChar)) {
						if ("$($db.newObjectPosition)" -eq "byName") { return "byName" }
						return "end"
					}
				}
			}
		}
		if ("$($proj.newObjectPosition)" -eq "byName") { return "byName" }
		return "end"
	} catch { return "end" }
}

# Виды, у которых порядок в дереве несёт смысл: автоматически их не упорядочиваем.
# CommonAttribute — исключение самого стандарта (#std467): у общих реквизитов-разделителей
# порядок в дереве задаёт порядок установки параметров сеанса. Subsystem и CommandGroup:
# пока они не перечислены в <SubsystemsOrder> / <GroupsOrder> файла Ext/CommandInterface.xml,
# порядок дерева задаёт порядок в интерфейсе, а платформа эти списки сама не заводит
# (в выгрузке ACC вне GroupsOrder 15 живых групп из 39). Language исключён из осторожности,
# без замера: языков обычно один-два, и в типовых их порядок не алфавитный.
# Явно названный вид сортируется в любом случае.
# Реестр семьи: tests/skills/check-inline-drift.mjs.
function Test-OrderSensitiveType([string]$typeName) {
	return @("CommonAttribute", "Subsystem", "CommandGroup", "Language") -ccontains $typeName
}

# Порядок имён объектов метаданных, как в дереве Конфигуратора.
# Ключ — пары «ранг+символ»: регистр не учитывается, подчёркивание раньше цифр, цифры раньше
# букв, буквы по кодам (латиница раньше кириллицы), ё на месте е. Культурные таблицы не
# используются — они разные на разных ОС и в разных рантаймах, а так оба порта сравнивают
# одинаково везде. Равные ключи разводит ordinal-сравнение исходных строк.
# Возвращает -1 | 0 | 1. Реестр семьи: tests/skills/check-inline-drift.mjs.
function Compare-MetadataNames([string]$a, [string]$b) {
	$keys = @("", "")
	$names = @($a, $b)
	for ($i = 0; $i -lt 2; $i++) {
		$sb = New-Object System.Text.StringBuilder
		foreach ($ch in $names[$i].ToLowerInvariant().ToCharArray()) {
			if ($ch -eq [char]0x0451) { $ch = [char]0x0435 }
			if ([char]::IsDigit($ch)) { [void]$sb.Append('1') }
			elseif ([char]::IsLetter($ch)) { [void]$sb.Append('2') }
			else { [void]$sb.Append('0') }
			[void]$sb.Append($ch)
		}
		$keys[$i] = $sb.ToString()
	}
	$r = [string]::CompareOrdinal($keys[0], $keys[1])
	if ($r -eq 0) { $r = [string]::CompareOrdinal($a, $b) }
	if ($r -lt 0) { return -1 }
	if ($r -gt 0) { return 1 }
	return 0
}

# Сортировка имён компаратором Compare-MetadataNames. В py-порту ту же роль играет
# functools.cmp_to_key — штатный способ отсортировать компаратором; в PS 5.1 его нет,
# поэтому слияние вручную. Порядок обоих портов задаёт один и тот же компаратор.
function Sort-MetadataNames([string[]]$names) {
	# Возврат без запятой-обёртки: приёмная сторона всегда пишет @(...), и одноэлементный
	# результат остаётся массивом. С `return ,@(...)` @() собрал бы ОДИН объект-массив.
	if ($names.Count -le 1) { return $names }
	$mid = [int]($names.Count / 2)
	$left = @(Sort-MetadataNames $names[0..($mid - 1)])
	$right = @(Sort-MetadataNames $names[$mid..($names.Count - 1)])
	$out = New-Object System.Collections.ArrayList
	$i = 0; $j = 0
	while ($i -lt $left.Count -and $j -lt $right.Count) {
		if ((Compare-MetadataNames $left[$i] $right[$j]) -le 0) { [void]$out.Add($left[$i]); $i++ }
		else { [void]$out.Add($right[$j]); $j++ }
	}
	while ($i -lt $left.Count) { [void]$out.Add($left[$i]); $i++ }
	while ($j -lt $right.Count) { [void]$out.Add($right[$j]); $j++ }
	return $out.ToArray()
}

# Упорядочить <ChildObjects>: имена внутри вида, а без аргумента — и группы видов.
# Виды из Test-OrderSensitiveType по имени не сортируются, пока не названы явно.
# Вызов без значения дополнительно ставит группы видов в канонический порядок: платформа
# починила бы его только при загрузке-выгрузке, то есть неканоничный файл даёт диф на
# ровном месте. Переставляем ЗНАЧЕНИЯ узлов, а не сами узлы — отступы и структура файла
# остаются как были, в дифе только перестановка строк.
function Do-SortChildObjects([string]$batchVal) {
	if (-not $script:childObjsEl) { Write-Error "No <ChildObjects> element found"; exit 1 }

	# Ввод прощающий: регистр не важен, принимается и имя каталога (Catalogs → Catalog) —
	# в дереве выгрузки виды видны именно во множественном числе.
	# Без @(...) на приёме: Parse-BatchValue возвращает ,$items — обёртка, которую @()
	# собрал бы как ОДИН объект-массив, и вид не нашёлся бы в $script:typeOrder.
	$tokens = @()
	if ("$batchVal".Trim()) { $tokens = Parse-BatchValue $batchVal }
	$requested = @()
	foreach ($token in $tokens) {
		$canon = Resolve-TypeName $token
		if (-not $canon) { Write-Error "Unknown type '$token'. Valid: $($script:typeOrder -join ', ')"; exit 1 }
		$requested += $canon
	}

	$groups = New-Object System.Collections.Specialized.OrderedDictionary
	foreach ($child in $script:childObjsEl.ChildNodes) {
		if ($child.NodeType -ne 'Element') { continue }
		$ln = $child.get_LocalName()
		if (-not $groups.Contains($ln)) { $groups[$ln] = New-Object System.Collections.ArrayList }
		[void]$groups[$ln].Add($child)
	}

	$targets = if ($requested.Count -gt 0) { $requested } else { @($groups.Keys | Where-Object { -not (Test-OrderSensitiveType $_) }) }
	foreach ($typeName in $targets) {
		if (-not $groups.Contains($typeName)) { continue }
		$els = $groups[$typeName]
		if ($els.Count -lt 2) { continue }
		$names = @(foreach ($e in $els) { $e.InnerText })
		$ordered = @(Sort-MetadataNames $names)
		$same = $true
		for ($i = 0; $i -lt $names.Count; $i++) { if ($names[$i] -cne $ordered[$i]) { $same = $false; break } }
		if ($same) { continue }
		for ($i = 0; $i -lt $els.Count; $i++) { $els[$i].InnerText = $ordered[$i] }
		$script:modifyCount++
		Info "Sorted: $typeName ($($els.Count))"
	}

	if ($requested.Count -gt 0) { return }

	# Без аргумента приводим в порядок и сами группы видов: собранная навыками конфигурация
	# может держать их не в каноне, и первая же выгрузка платформы даст диф. Переставляем
	# содержимое существующих узлов, а не узлы, поэтому отступы и структура файла не меняются —
	# в дифе только перестановка строк. Имя тега у XmlElement неизменяемо, поэтому там, где вид
	# меняется, узел заменяется через ReplaceChild: он сохраняет окружающие пробельные узлы.
	$elems = @()
	foreach ($child in $script:childObjsEl.ChildNodes) {
		if ($child.NodeType -eq 'Element') { $elems += $child }
	}
	$tags = @(); $texts = @()
	foreach ($e in $elems) { $tags += $e.get_LocalName(); $texts += $e.InnerText }
	$rank = @()
	for ($i = 0; $i -lt $tags.Count; $i++) {
		$r = $script:typeOrder.IndexOf($tags[$i])
		if ($r -lt 0) { $r = $script:typeOrder.Count }
		$rank += $r
	}
	# Порядок стабильный: вторым ключом идёт исходная позиция
	$order = @(0..($tags.Count - 1) | Sort-Object @{e={$rank[$_]}}, @{e={$_}})
	$same = $true
	for ($i = 0; $i -lt $order.Count; $i++) { if ($order[$i] -ne $i) { $same = $false; break } }
	if ($same) { return }

	for ($i = 0; $i -lt $elems.Count; $i++) {
		$srcIdx = $order[$i]
		if ($tags[$i] -ceq $tags[$srcIdx]) {
			$elems[$i].InnerText = $texts[$srcIdx]
			continue
		}
		$newEl = $script:xmlDoc.CreateElement($tags[$srcIdx], $script:mdNs)
		$newEl.InnerText = $texts[$srcIdx]
		[void]$script:childObjsEl.ReplaceChild($newEl, $elems[$i])
	}
	$script:modifyCount++
	Info "Reordered type groups: $($elems.Count) entries"
}

# Стиль существующего файла для round-trip-сохранения: BOM / EOL / регистр encoding /
# финальный перенос. $null → файл новый (сохранить текущее поведение).
# Реестр семьи: tests/skills/check-inline-drift.mjs.
function Detect-XmlStyle([string]$path) {
	if (-not (Test-Path -LiteralPath $path)) { return $null }
	$raw = [System.IO.File]::ReadAllBytes($path)
	$bom = ($raw.Length -ge 3 -and $raw[0] -eq 0xEF -and $raw[1] -eq 0xBB -and $raw[2] -eq 0xBF)
	$body = if ($bom) { [System.Text.Encoding]::UTF8.GetString($raw, 3, $raw.Length - 3) } else { [System.Text.Encoding]::UTF8.GetString($raw) }
	$head = if ($body.Length -gt 200) { $body.Substring(0, 200) } else { $body }
	$m = [regex]::Match($head, 'encoding="([^"]+)"')
	return @{
		bom = $bom
		crlf = $body.Contains("`r`n")
		enc = $(if ($m.Success) { $m.Groups[1].Value } else { "utf-8" })
		finalNl = $body.EndsWith("`n")
	}
}

# Привести текст XmlWriter к стилю оригинала; для НОВОГО файла ($null) — к канону выгрузки
# Конфигуратора: encoding="UTF-8", CRLF, без перевода строки в конце.
# Реестр семьи: tests/skills/check-inline-drift.mjs.
function Finalize-XmlText([string]$text, $style) {
	if ($text.Length -gt 0 -and $text[0] -eq [char]0xFEFF) { $text = $text.Substring(1) }
	$encDecl = $(if ($style) { $style.enc } else { "UTF-8" })
	$text = $text.Replace('encoding="utf-8"', 'encoding="' + $encDecl + '"')
	# Пустой элемент: XmlWriter отдаёт `<a />`, Конфигуратор пишет `<a/>`. Внутри
	# CDATA/комментария ` />` может быть содержимым (там `>` не экранируется),
	# поэтому они идут первыми ветками альтернации и возвращаются как есть.
	$text = [regex]::Replace($text, '(?s)<!\[CDATA\[.*?\]\]>|<!--.*?-->|(?<=\S) />', { param($m) if ($m.Value -eq ' />') { '/>' } else { $m.Value } })
	$text = ($text -replace "`r`n", "`n").TrimEnd("`n")
	if ($style -and $style.finalNl) { $text += "`n" }
	if (-not $style -or $style.crlf) { $text = $text -replace "`n", "`r`n" }
	return $text
}

function Do-AddChildObject([string]$batchVal) {
	if (-not $script:childObjsEl) { Write-Error "No <ChildObjects> element found"; exit 1 }

	$items = Parse-BatchValue $batchVal
	$cfgIndent = Get-ChildIndent $script:cfgEl

	# Expand self-closing if needed
	if (-not $script:childObjsEl.HasChildNodes -or $script:childObjsEl.IsEmpty) {
		Expand-SelfClosingElement $script:childObjsEl $cfgIndent
	}
	$childIndent = Get-ChildIndent $script:childObjsEl

	foreach ($item in $items) {
		$dotIdx = $item.IndexOf(".")
		if ($dotIdx -lt 1) {
			Write-Error "Invalid format '$item', expected 'Type.Name'"
			exit 1
		}
		$typeName = $item.Substring(0, $dotIdx)
		$canonType = Resolve-TypeName $typeName
		if ($canonType) { $typeName = $canonType }
		$objNameVal = $item.Substring($dotIdx + 1)

		# Check type is valid
		$typeIdx = $script:typeOrder.IndexOf($typeName)
		if ($typeIdx -lt 0) {
			Write-Error "Unknown type '$typeName'"
			exit 1
		}

		# Check that the referenced object actually exists on disk.
		# cf-edit add-childObject is a low-level operation for rare scenarios
		# (e.g. restoring a rolled-back Configuration.xml when object files are intact).
		# For creating NEW objects, meta-compile/role-compile/subsystem-compile already
		# auto-register in Configuration.xml — calling cf-edit add-childObject there is
		# unnecessary and error-prone.
		$typeDir = $script:typeToDir[$typeName]
		$objFile = Join-Path (Join-Path $script:configDir $typeDir) "$objNameVal.xml"
		if (-not (Test-Path $objFile)) {
			$hintSkill = switch ($typeName) {
				"Subsystem" { "subsystem-compile" }
				"Role"      { "role-compile" }
				default     { "meta-compile" }
			}
			Write-Error @"
Object file not found: $typeDir/$objNameVal.xml
cf-edit add-childObject only references objects that already exist on disk.
To create a new $typeName, use $hintSkill (auto-registers in Configuration.xml):
  /$hintSkill with {"type":"$typeName","name":"$objNameVal"}
"@
			exit 1
		}

		# Dedup check
		$existing = $false
		foreach ($child in $script:childObjsEl.ChildNodes) {
			if ($child.NodeType -eq 'Element' -and $child.LocalName -eq $typeName -and $child.InnerText -eq $objNameVal) {
				$existing = $true; break
			}
		}
		if ($existing) {
			Warn "Already exists: $typeName.$objNameVal"
			continue
		}

		# Место вставки. Вид — по $script:typeOrder; внутри вида — по newObjectPosition.
		$byName = (-not (Test-OrderSensitiveType $typeName) -and (Get-NewObjectPosition $script:configDir) -eq "byName")
		$insertBefore = $null
		$lastSameType = $null
		$firstLaterType = $null

		foreach ($child in $script:childObjsEl.ChildNodes) {
			if ($child.NodeType -ne 'Element') { continue }
			$childTypeIdx = $script:typeOrder.IndexOf($child.LocalName)
			if ($childTypeIdx -lt 0) { continue }

			if ($child.LocalName -eq $typeName) {
				# Внутри вида — по newObjectPosition: end (по умолчанию) кладёт после последнего
				# объекта того же вида, byName — по имени. Subsystem по имени не упорядочиваем
				# никогда: порядок подсистем в дереве задаёт порядок разделов в панели.
				$lastSameType = $child
				if ($byName -and -not $insertBefore -and (Compare-MetadataNames $child.InnerText $objNameVal) -gt 0) {
					$insertBefore = $child
				}
			} elseif ($childTypeIdx -gt $typeIdx -and -not $firstLaterType) {
				$firstLaterType = $child
			}
		}

		if (-not $insertBefore) {
			# Место не выбрано именем — ставим сразу за последним объектом того же вида,
			# то есть перед его следующим соседом. Через $firstLaterType этого не сделать:
			# если видов старше в файле нет, запись уехала бы в самый конец блока,
			# за пределы своей группы.
			if ($lastSameType) {
				$next = $lastSameType.NextSibling
				while ($next -and $next.NodeType -ne 'Element') { $next = $next.NextSibling }
				$insertBefore = $next
			} else {
				$insertBefore = $firstLaterType
			}
		}

		# Create element
		$newEl = $script:xmlDoc.CreateElement($typeName, $script:mdNs)
		$newEl.InnerText = $objNameVal

		if ($insertBefore) {
			Insert-BeforeElement $script:childObjsEl $newEl $insertBefore $childIndent
		} else {
			# Append at end (or after last same/preceding type)
			Insert-BeforeElement $script:childObjsEl $newEl $null $childIndent
		}

		$script:addCount++
		Info "Added: $typeName.$objNameVal"
	}
}

# --- Operation: remove-childObject ---
function Do-RemoveChildObject([string]$batchVal) {
	if (-not $script:childObjsEl) { Write-Error "No <ChildObjects> element found"; exit 1 }

	$items = Parse-BatchValue $batchVal
	foreach ($item in $items) {
		$dotIdx = $item.IndexOf(".")
		if ($dotIdx -lt 1) {
			Write-Error "Invalid format '$item', expected 'Type.Name'"
			exit 1
		}
		$typeName = $item.Substring(0, $dotIdx)
		$canonType = Resolve-TypeName $typeName
		if ($canonType) { $typeName = $canonType }
		$objNameVal = $item.Substring($dotIdx + 1)

		$found = $false
		foreach ($child in @($script:childObjsEl.ChildNodes)) {
			if ($child.NodeType -eq 'Element' -and $child.LocalName -eq $typeName -and $child.InnerText -eq $objNameVal) {
				Remove-NodeWithWhitespace $child
				$script:removeCount++
				Info "Removed: $typeName.$objNameVal"
				$found = $true
				break
			}
		}
		if (-not $found) { Warn "Not found: $typeName.$objNameVal" }
	}
}

# --- Operation: add-defaultRole ---
function Do-AddDefaultRole([string]$batchVal) {
	$items = Parse-BatchValue $batchVal

	# Find DefaultRoles element
	$rolesEl = $null
	foreach ($child in $script:propsEl.ChildNodes) {
		if ($child.NodeType -eq 'Element' -and $child.LocalName -eq "DefaultRoles") {
			$rolesEl = $child; break
		}
	}
	if (-not $rolesEl) { Write-Error "No <DefaultRoles> element found in Properties"; exit 1 }

	$propsIndent = Get-ChildIndent $script:propsEl
	if (-not $rolesEl.HasChildNodes -or $rolesEl.IsEmpty) {
		Expand-SelfClosingElement $rolesEl $propsIndent
	}
	$roleIndent = Get-ChildIndent $rolesEl

	foreach ($item in $items) {
		$roleName = $item
		if (-not $roleName.StartsWith("Role.")) { $roleName = "Role.$roleName" }

		# Dedup
		$existing = $false
		foreach ($child in $rolesEl.ChildNodes) {
			if ($child.NodeType -eq 'Element' -and $child.InnerText.Trim() -eq $roleName) {
				$existing = $true; break
			}
		}
		if ($existing) {
			Warn "DefaultRole already exists: $roleName"
			continue
		}

		$fragXml = "<xr:Item xsi:type=`"xr:MDObjectRef`">$roleName</xr:Item>"
		$nodes = Import-Fragment $fragXml
		if ($nodes.Count -gt 0) {
			Insert-BeforeElement $rolesEl $nodes[0] $null $roleIndent
			$script:addCount++
			Info "Added DefaultRole: $roleName"
		}
	}
}

# --- Operation: remove-defaultRole ---
function Do-RemoveDefaultRole([string]$batchVal) {
	$items = Parse-BatchValue $batchVal

	$rolesEl = $null
	foreach ($child in $script:propsEl.ChildNodes) {
		if ($child.NodeType -eq 'Element' -and $child.LocalName -eq "DefaultRoles") {
			$rolesEl = $child; break
		}
	}
	if (-not $rolesEl) { Write-Error "No <DefaultRoles> element found"; exit 1 }

	foreach ($item in $items) {
		$roleName = $item
		if (-not $roleName.StartsWith("Role.")) { $roleName = "Role.$roleName" }

		$found = $false
		foreach ($child in @($rolesEl.ChildNodes)) {
			if ($child.NodeType -eq 'Element' -and $child.InnerText.Trim() -eq $roleName) {
				Remove-NodeWithWhitespace $child
				$script:removeCount++
				Info "Removed DefaultRole: $roleName"
				$found = $true
				break
			}
		}
		if (-not $found) { Warn "DefaultRole not found: $roleName" }
	}
}

# --- Operation: set-panels ---
# Canonical English aliases — preferred form, used in docs and error messages.
$script:panelUuids = @{
	"sections"  = "b553047f-c9aa-4157-978d-448ecad24248"
	"open"      = "cbab57f2-a0f3-4f0a-89ea-4cb19570ab75"
	"favorites" = "13322b22-3960-4d68-93a6-fe2dd7f28ca3"
	"history"   = "c933ac92-92cd-459d-81cc-e0c8a83ced99"
	"functions" = "b2735bd3-d822-4430-ba59-c9e869693b24"
}
# Russian synonyms — silently accepted (cf-info displays Russian names; users
# may copy them straight into cf-edit value).
$script:panelSynonyms = @{
	"разделов"   = "sections"; "разделы"   = "sections"
	"открытых"   = "open";     "открытые"  = "open"
	"избранного" = "favorites";"избранное" = "favorites"
	"истории"    = "history";  "история"   = "history"
	"функций"    = "functions";"функции"   = "functions"
}

function Build-PanelEntryXml($entry, [string]$indent) {
	# String alias -> <panel><uuid>...</uuid></panel>
	if ($entry -is [string]) {
		$key = $entry.ToLowerInvariant()
		if ($script:panelSynonyms.ContainsKey($key)) { $key = $script:panelSynonyms[$key] }
		if (-not $script:panelUuids.ContainsKey($key)) {
			Write-Error "Unknown panel alias '$entry'. Allowed: $(($script:panelUuids.Keys | Sort-Object) -join ', ')"
			exit 1
		}
		$u = $script:panelUuids[$key]
		$instId = [guid]::NewGuid().ToString()
		return "$indent<panel id=`"$instId`">`r`n$indent`t<uuid>$u</uuid>`r`n$indent</panel>"
	}
	# Object {group: [...]} -> <group id=""><group><panel/></group>...</group> (stack)
	if ($entry.PSObject.Properties['group']) {
		$children = $entry.group
		if (-not $children -or $children.Count -eq 0) {
			Write-Error "group must contain at least one entry"
			exit 1
		}
		$gid = [guid]::NewGuid().ToString()
		$inner = ""
		foreach ($child in $children) {
			$childXml = Build-PanelEntryXml $child "$indent`t`t"
			$inner += "$indent`t<group>`r`n$childXml`r`n$indent`t</group>`r`n"
		}
		return "$indent<group id=`"$gid`">`r`n$inner$indent</group>"
	}
	Write-Error "Panel entry must be a string alias or object {group:[...]}, got: $($entry | ConvertTo-Json -Compress)"
	exit 1
}

function Do-SetPanels($valArg) {
	# Accept string (JSON), PSCustomObject, or hashtable
	$layout = $valArg
	if ($layout -is [string]) {
		$layout = ConvertFrom-JsonInput $layout "-Value for operation 'set-panels'" "a JSON object with panel layout" -Inline
	}
	if (-not $layout) {
		Write-Error "set-panels value is empty"
		exit 1
	}

	$sides = @("top","left","right","bottom")
	$bodyParts = @()
	foreach ($side in $sides) {
		$entries = $null
		if ($layout.PSObject.Properties[$side]) { $entries = $layout.$side }
		if ($null -eq $entries) { continue }
		# Normalize to array
		if ($entries -isnot [System.Array] -and $entries -isnot [System.Collections.IList]) {
			$entries = @($entries)
		}
		foreach ($entry in $entries) {
			$entryXml = Build-PanelEntryXml $entry "`t`t"
			$bodyParts += "`t<$side>`r`n$entryXml`r`n`t</$side>"
		}
	}

	# Reject unknown side keys (catches typos like "Top" vs "top")
	foreach ($prop in $layout.PSObject.Properties) {
		if ($sides -notcontains $prop.Name) {
			Write-Error "Unknown side '$($prop.Name)'. Allowed: $($sides -join ', ')"
			exit 1
		}
	}

	$body = $bodyParts -join "`r`n"
	$declarations = @"
	<panelDef id="b553047f-c9aa-4157-978d-448ecad24248"/>
	<panelDef id="13322b22-3960-4d68-93a6-fe2dd7f28ca3"/>
	<panelDef id="c933ac92-92cd-459d-81cc-e0c8a83ced99"/>
	<panelDef id="cbab57f2-a0f3-4f0a-89ea-4cb19570ab75"/>
	<panelDef id="b2735bd3-d822-4430-ba59-c9e869693b24"/>
"@
	$bodyBlock = if ($body) { "$body`r`n" } else { "" }
	$caiXml = @"
<?xml version="1.0" encoding="UTF-8"?>
<ClientApplicationInterface xmlns="http://v8.1c.ru/8.2/managed-application/core" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="InterfaceLayouter">
$bodyBlock$declarations
</ClientApplicationInterface>
"@

	$extDir = Join-Path $script:configDir "Ext"
	if (-not (Test-Path $extDir)) { New-Item -ItemType Directory -Path $extDir -Force | Out-Null }
	$caiPath = Join-Path $extDir "ClientApplicationInterface.xml"
	$utf8Bom = New-Object System.Text.UTF8Encoding($true)
	# Файл создаём мы — канон: CRLF в разделителях, без перевода строки в конце.
	$caiXml = ($caiXml -replace "`r`n", "`n") -replace "`n", "`r`n"
	[System.IO.File]::WriteAllText($caiPath, $caiXml.TrimEnd("`r", "`n"), $utf8Bom)
	$script:modifyCount++
	Info "Wrote panel layout: $caiPath"
}

# --- Operation: set-home-page ---
# Russian → English type aliases for form-ref normalization
$script:ruTypeMap = @{
	"справочник"               = "Catalog"
	"документ"                 = "Document"
	"перечисление"             = "Enum"
	"отчёт"                    = "Report"
	"отчет"                    = "Report"
	"обработка"                = "DataProcessor"
	"общаяформа"               = "CommonForm"
	"журналдокументов"         = "DocumentJournal"
	"планвидовхарактеристик"   = "ChartOfCharacteristicTypes"
	"плансчетов"               = "ChartOfAccounts"
	"планвидоврасчета"         = "ChartOfCalculationTypes"
	"планвидоврасчёта"         = "ChartOfCalculationTypes"
	"регистрсведений"          = "InformationRegister"
	"регистрнакопления"        = "AccumulationRegister"
	"регистрбухгалтерии"       = "AccountingRegister"
	"регистррасчета"           = "CalculationRegister"
	"регистррасчёта"           = "CalculationRegister"
	"бизнеспроцесс"            = "BusinessProcess"
	"задача"                   = "Task"
	"бот"                      = "Bot"
	"планобмена"               = "ExchangePlan"
	"хранилищенастроек"        = "SettingsStorage"
	# Множественное число: в дереве конфигурации виды подписаны именно так.
	"справочники"              = "Catalog"
	"документы"                = "Document"
	"перечисления"             = "Enum"
	"отчёты"                   = "Report"
	"отчеты"                   = "Report"
	"обработки"                = "DataProcessor"
	"общиеформы"               = "CommonForm"
	"журналыдокументов"        = "DocumentJournal"
	"планывидовхарактеристик"  = "ChartOfCharacteristicTypes"
	"планысчетов"              = "ChartOfAccounts"
	"планывидоврасчета"        = "ChartOfCalculationTypes"
	"планывидоврасчёта"        = "ChartOfCalculationTypes"
	"регистрысведений"         = "InformationRegister"
	"регистрынакопления"       = "AccumulationRegister"
	"регистрыбухгалтерии"      = "AccountingRegister"
	"регистрырасчета"          = "CalculationRegister"
	"регистрырасчёта"          = "CalculationRegister"
	"бизнеспроцессы"           = "BusinessProcess"
	"задачи"                   = "Task"
	"боты"                     = "Bot"
	"планыобмена"              = "ExchangePlan"
	"хранилищанастроек"        = "SettingsStorage"
}
# plural folder → singular type
$script:dirToType = @{}
foreach ($k in $script:typeToDir.Keys) { $script:dirToType[$script:typeToDir[$k].ToLowerInvariant()] = $k }

function Normalize-FormRef([string]$s) {
	$s = $s.Trim()
	if (-not $s) { return $s }
	# UUID — leave as-is
	if ($s -match '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') { return $s }
	# Path form?
	if ($s.Contains("/") -or $s.Contains("\")) {
		$parts = $s.Replace("\","/").Split("/") | Where-Object { $_ -ne "" -and $_.ToLowerInvariant() -ne "ext" }
		# Strip trailing Form.xml
		if ($parts.Count -gt 0 -and $parts[-1].ToLowerInvariant() -eq "form.xml") {
			$parts = @($parts[0..($parts.Count - 2)])
		}
		if ($parts.Count -ge 2) {
			$typeDir = $parts[0]
			$typeSingular = $script:dirToType[$typeDir.ToLowerInvariant()]
			if ($typeSingular) {
				if ($typeSingular -eq "CommonForm" -and $parts.Count -ge 2) {
					return "CommonForm.$($parts[1])"
				}
				if ($parts.Count -ge 4 -and $parts[2].ToLowerInvariant() -eq "forms") {
					return "$typeSingular.$($parts[1]).Form.$($parts[3])"
				}
			}
		}
		return $s
	}
	# Dot form — translate Russian head and 'Форма' segment, auto-insert 'Form'
	$segs = $s.Split(".")
	if ($segs.Count -ge 1) {
		$head = $segs[0].ToLowerInvariant()
		if ($script:ruTypeMap.ContainsKey($head)) { $segs[0] = $script:ruTypeMap[$head] }
		for ($i = 1; $i -lt $segs.Count; $i++) {
			if ($segs[$i] -eq "Форма") { $segs[$i] = "Form" }
		}
		# Auto-insert Form: for object types with 3 segments (Type.Object.FormName)
		if ($segs.Count -eq 3 -and $script:typeOrder -contains $segs[0] -and $segs[0] -ne "CommonForm") {
			$segs = @($segs[0], $segs[1], "Form", $segs[2])
		}
	}
	return ($segs -join ".")
}

# Accept short DSL or canonical XML keys (silently)
function Get-FieldValue($obj, [string[]]$keys) {
	foreach ($k in $keys) {
		if ($obj.PSObject.Properties[$k]) { return $obj.PSObject.Properties[$k].Value }
	}
	return $null
}

function Build-HomePageItemXml($entry, [string]$indent) {
	# Resolve fields
	if ($entry -is [string]) {
		$formRef = Normalize-FormRef $entry
		$height = 10
		$common = $true
		$roles = $null
	} else {
		$formRaw = Get-FieldValue $entry @("form","Form")
		if (-not $formRaw) { Write-Error "Home page item: 'form' is required, got: $($entry | ConvertTo-Json -Compress)"; exit 1 }
		$formRef = Normalize-FormRef ([string]$formRaw)
		$h = Get-FieldValue $entry @("height","Height")
		$height = if ($null -ne $h) { [int]$h } else { 10 }
		$vis = Get-FieldValue $entry @("visibility","Visibility")
		$common = if ($null -ne $vis) { [bool]$vis } else { $true }
		$roles = Get-FieldValue $entry @("roles")
	}

	$visParts = @()
	$visParts += "$indent`t`t<xr:Common>$($common.ToString().ToLower())</xr:Common>"
	if ($roles) {
		# roles is PSCustomObject {Role.X: bool, ...}
		foreach ($prop in $roles.PSObject.Properties) {
			$rname = $prop.Name
			if (-not $rname.StartsWith("Role.") -and -not ($rname -match '^[0-9a-fA-F]{8}-')) { $rname = "Role.$rname" }
			$rval = ([bool]$prop.Value).ToString().ToLower()
			$escName = [System.Security.SecurityElement]::Escape($rname)
			$visParts += "$indent`t`t<xr:Value name=`"$escName`">$rval</xr:Value>"
		}
	}
	$visBlock = $visParts -join "`r`n"
	$escForm = [System.Security.SecurityElement]::Escape($formRef)
	return @"
$indent<Item>
$indent`t<Form>$escForm</Form>
$indent`t<Height>$height</Height>
$indent`t<Visibility>
$visBlock
$indent`t</Visibility>
$indent</Item>
"@
}

function Do-SetHomePage($valArg) {
	$layout = $valArg
	if ($layout -is [string]) {
		$layout = ConvertFrom-JsonInput $layout "-Value for operation 'set-home-page'" "a JSON object with home page layout" -Inline
	}
	if (-not $layout) { Write-Error "set-home-page value is empty"; exit 1 }

	$allowedTemplates = @("OneColumn","TwoColumnsEqualWidth","TwoColumnsVariableWidth")
	$tmpl = Get-FieldValue $layout @("template","WorkingAreaTemplate")
	if (-not $tmpl) { $tmpl = "TwoColumnsEqualWidth" }
	if ($allowedTemplates -notcontains $tmpl) {
		Write-Error "Unknown template '$tmpl'. Allowed: $($allowedTemplates -join ', ')"; exit 1
	}

	$leftItems = Get-FieldValue $layout @("left","LeftColumn")
	$rightItems = Get-FieldValue $layout @("right","RightColumn")

	# Reject unknown keys
	$known = @("template","WorkingAreaTemplate","left","LeftColumn","right","RightColumn")
	foreach ($prop in $layout.PSObject.Properties) {
		if ($known -notcontains $prop.Name) {
			Write-Error "Unknown key '$($prop.Name)'. Allowed: template, left, right"; exit 1
		}
	}

	if ($tmpl -eq "OneColumn" -and $rightItems) {
		Write-Error "Template 'OneColumn' cannot have items in 'right' column"; exit 1
	}

	function Build-Column([string]$tag, $items) {
		if (-not $items) { return "`t<$tag/>" }
		if ($items -isnot [System.Array] -and $items -isnot [System.Collections.IList]) {
			$items = @($items)
		}
		if ($items.Count -eq 0) { return "`t<$tag/>" }
		$itemBlocks = @()
		foreach ($it in $items) {
			$itemBlocks += Build-HomePageItemXml $it "`t`t"
		}
		$body = $itemBlocks -join "`r`n"
		return "`t<$tag>`r`n$body`r`n`t</$tag>"
	}

	$leftXml = Build-Column "LeftColumn" $leftItems
	$rightXml = Build-Column "RightColumn" $rightItems

	$hpXml = @"
<?xml version="1.0" encoding="UTF-8"?>
<HomePageWorkArea xmlns="http://v8.1c.ru/8.3/xcf/extrnprops" xmlns:xr="http://v8.1c.ru/8.3/xcf/readable" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="$($script:formatVersion)">
	<WorkingAreaTemplate>$tmpl</WorkingAreaTemplate>
$leftXml
$rightXml
</HomePageWorkArea>
"@

	$extDir = Join-Path $script:configDir "Ext"
	if (-not (Test-Path $extDir)) { New-Item -ItemType Directory -Path $extDir -Force | Out-Null }
	$hpPath = Join-Path $extDir "HomePageWorkArea.xml"
	$utf8Bom = New-Object System.Text.UTF8Encoding($true)
	# Файл создаём мы — канон: CRLF в разделителях, без перевода строки в конце.
	$hpXml = ($hpXml -replace "`r`n", "`n") -replace "`n", "`r`n"
	[System.IO.File]::WriteAllText($hpPath, $hpXml.TrimEnd("`r", "`n"), $utf8Bom)
	$script:modifyCount++
	Info "Wrote home page layout: $hpPath"
}

# --- Operation: set-defaultRoles ---
function Do-SetDefaultRoles([string]$batchVal) {
	$items = Parse-BatchValue $batchVal

	$rolesEl = $null
	foreach ($child in $script:propsEl.ChildNodes) {
		if ($child.NodeType -eq 'Element' -and $child.LocalName -eq "DefaultRoles") {
			$rolesEl = $child; break
		}
	}
	if (-not $rolesEl) { Write-Error "No <DefaultRoles> element found"; exit 1 }

	# Clear all existing children
	while ($rolesEl.HasChildNodes) {
		$rolesEl.RemoveChild($rolesEl.FirstChild) | Out-Null
	}

	if ($items.Count -eq 0) {
		$script:modifyCount++
		Info "Cleared DefaultRoles"
		return
	}

	$propsIndent = Get-ChildIndent $script:propsEl
	$roleIndent = "$propsIndent`t"

	# Add closing whitespace
	$closeWs = $script:xmlDoc.CreateWhitespace("`r`n$propsIndent")
	$rolesEl.AppendChild($closeWs) | Out-Null

	foreach ($item in $items) {
		$roleName = $item
		if (-not $roleName.StartsWith("Role.")) { $roleName = "Role.$roleName" }

		$fragXml = "<xr:Item xsi:type=`"xr:MDObjectRef`">$roleName</xr:Item>"
		$nodes = Import-Fragment $fragXml
		if ($nodes.Count -gt 0) {
			Insert-BeforeElement $rolesEl $nodes[0] $null $roleIndent
		}
	}

	$script:modifyCount++
	Info "Set DefaultRoles: $($items.Count) roles"
}

# --- Execute operations ---
$operations = @()
if ($DefinitionFile) {
	if (-not [System.IO.Path]::IsPathRooted($DefinitionFile)) {
		$DefinitionFile = Join-Path (Get-Location).Path $DefinitionFile
	}
	$jsonText = Read-JsonInputFile $DefinitionFile
	$ops = ConvertFrom-JsonInput $jsonText $DefinitionFile
	if ($ops -is [System.Array]) {
		foreach ($op in $ops) { $operations += $op }
	} else {
		$operations += $ops
	}
} else {
	$operations += @{ operation = $Operation; value = $Value }
}

foreach ($op in $operations) {
	$opName = if ($op.operation) { "$($op.operation)" } else { "$Operation" }
	# Pass value through as-is (object or string); set-panels needs object form
	$opValue = if ($null -ne $op.value) { $op.value } else { $Value }
	$opValueStr = if ($opValue -is [string]) { $opValue } else { "$opValue" }

	switch ($opName) {
		"modify-property"    { Do-ModifyProperty $opValueStr }
		"add-childObject"    { Do-AddChildObject $opValueStr }
		"remove-childObject" { Do-RemoveChildObject $opValueStr }
		"add-defaultRole"    { Do-AddDefaultRole $opValueStr }
		"remove-defaultRole" { Do-RemoveDefaultRole $opValueStr }
		"set-defaultRoles"   { Do-SetDefaultRoles $opValueStr }
		"set-panels"         { Do-SetPanels $opValue }
		"set-home-page"      { Do-SetHomePage $opValue }
		"sort-childObjects"  { Do-SortChildObjects $opValueStr }
		default              { Write-Error "Unknown operation: $opName"; exit 1 }
	}
}

# --- Save ---
# Стиль исходника снимаем ДО записи: правка чужого файла наследует его BOM/EOL/заголовок
# (#44/#46/#47), новый файл получает канон выгрузки. Зеркало _detect_xml_style в py-порту.
$xmlStyle = Detect-XmlStyle $resolvedPath

$settings = New-Object System.Xml.XmlWriterSettings
$settings.Encoding = New-Object System.Text.UTF8Encoding($true)
$settings.Indent = $false
$settings.NewLineHandling = [System.Xml.NewLineHandling]::None

$memStream = New-Object System.IO.MemoryStream
$writer = [System.Xml.XmlWriter]::Create($memStream, $settings)
$script:xmlDoc.Save($writer)
$writer.Flush(); $writer.Close()

$text = [System.Text.Encoding]::UTF8.GetString($memStream.ToArray())
$memStream.Close()
$text = Finalize-XmlText $text $xmlStyle

$writeBom = ($null -eq $xmlStyle) -or $xmlStyle.bom
[System.IO.File]::WriteAllText($resolvedPath, $text, (New-Object System.Text.UTF8Encoding($writeBom)))
Info "Saved: $resolvedPath"

# --- Auto-validate ---
if (-not $NoValidate) {
	$validateScript = Join-Path (Join-Path $PSScriptRoot "..\..\cf-validate") "scripts\cf-validate.ps1"
	$validateScript = [System.IO.Path]::GetFullPath($validateScript)
	if (Test-Path $validateScript) {
		Write-Host ""
		Write-Host "--- Running cf-validate ---"
		& powershell.exe -NoProfile -File $validateScript -ConfigPath $resolvedPath
	}
}

# --- Summary ---
Write-Host ""
Write-Host "=== cf-edit summary ==="
Write-Host "  Configuration: $($script:objName)"
Write-Host "  Added:         $($script:addCount)"
Write-Host "  Removed:       $($script:removeCount)"
Write-Host "  Modified:      $($script:modifyCount)"
exit 0
