# subsystem-edit v1.25 — Edit existing 1C subsystem XML (+тип Bot; cfe-diff/cfe-borrow: недостающие типы)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills
[CmdletBinding(PositionalBinding=$false)]
param(
	[Parameter(Mandatory)][Alias('Path')][string]$SubsystemPath,
	[string]$DefinitionFile,
	[ValidateSet("add-content","remove-content","add-child","remove-child","set-property")]
	[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

# --- Content type normalization (plural→singular, Russian→English) ---
$script:contentTypeMap = @{
	"Catalogs"="Catalog"; "Documents"="Document"; "Enums"="Enum"; "Constants"="Constant"
	"Reports"="Report"; "DataProcessors"="DataProcessor"
	"InformationRegisters"="InformationRegister"; "AccumulationRegisters"="AccumulationRegister"
	"AccountingRegisters"="AccountingRegister"; "CalculationRegisters"="CalculationRegister"
	"ChartsOfAccounts"="ChartOfAccounts"; "ChartsOfCharacteristicTypes"="ChartOfCharacteristicTypes"
	"ChartsOfCalculationTypes"="ChartOfCalculationTypes"
	"BusinessProcesses"="BusinessProcess"; "Tasks"="Task"
	"ExchangePlans"="ExchangePlan"; "DocumentJournals"="DocumentJournal"
	"CommonModules"="CommonModule"; "CommonCommands"="CommonCommand"
	"CommonForms"="CommonForm"; "CommonPictures"="CommonPicture"
	"CommonTemplates"="CommonTemplate"; "CommonAttributes"="CommonAttribute"
	"CommandGroups"="CommandGroup"; "Roles"="Role"
	"SessionParameters"="SessionParameter"; "FilterCriteria"="FilterCriterion"
	"XDTOPackages"="XDTOPackage"; "WebServices"="WebService"
	"HTTPServices"="HTTPService"; "WSReferences"="WSReference"
	"EventSubscriptions"="EventSubscription"; "ScheduledJobs"="ScheduledJob"
	"SettingsStorages"="SettingsStorage"; "FunctionalOptions"="FunctionalOption"
	"FunctionalOptionsParameters"="FunctionalOptionsParameter"
	"DefinedTypes"="DefinedType"; "DocumentNumerators"="DocumentNumerator"
	"Sequences"="Sequence"; "Subsystems"="Subsystem"
	"StyleItems"="StyleItem"; "IntegrationServices"="IntegrationService"; "Bots"="Bot"; "Bot"="Bot"
	# Russian singular
	"Справочник"="Catalog"; "Каталог"="Catalog"; "Документ"="Document"
	"Перечисление"="Enum"; "Константа"="Constant"
	"Отчёт"="Report"; "Отчет"="Report"; "Обработка"="DataProcessor"
	"РегистрСведений"="InformationRegister"; "РегистрНакопления"="AccumulationRegister"
	"РегистрБухгалтерии"="AccountingRegister"
	"РегистрРасчёта"="CalculationRegister"; "РегистрРасчета"="CalculationRegister"
	"ПланСчетов"="ChartOfAccounts"; "ПланВидовХарактеристик"="ChartOfCharacteristicTypes"
	"ПланВидовРасчёта"="ChartOfCalculationTypes"; "ПланВидовРасчета"="ChartOfCalculationTypes"
	"БизнесПроцесс"="BusinessProcess"; "Задача"="Task"
	"ПланОбмена"="ExchangePlan"; "ЖурналДокументов"="DocumentJournal"
	"ОбщийМодуль"="CommonModule"; "ОбщаяКоманда"="CommonCommand"
	"ОбщаяФорма"="CommonForm"; "ОбщаяКартинка"="CommonPicture"
	"ОбщийМакет"="CommonTemplate"; "ОбщийРеквизит"="CommonAttribute"
	"ГруппаКоманд"="CommandGroup"; "Роль"="Role"
	"ПараметрСеанса"="SessionParameter"; "КритерийОтбора"="FilterCriterion"
	"ПакетXDTO"="XDTOPackage"; "ВебСервис"="WebService"
	"HTTPСервис"="HTTPService"; "WSСсылка"="WSReference"
	"ПодпискаНаСобытие"="EventSubscription"; "РегламентноеЗадание"="ScheduledJob"
	"ХранилищеНастроек"="SettingsStorage"; "ФункциональнаяОпция"="FunctionalOption"
	"ПараметрФункциональныхОпций"="FunctionalOptionsParameter"
	"ОпределяемыйТип"="DefinedType"; "Подсистема"="Subsystem"
	"ЭлементСтиля"="StyleItem"; "СервисИнтеграции"="IntegrationService"
	# Russian plural
	"Справочники"="Catalog"; "Документы"="Document"; "Перечисления"="Enum"
	"Константы"="Constant"; "Отчёты"="Report"; "Отчеты"="Report"
	"Обработки"="DataProcessor"; "РегистрыСведений"="InformationRegister"
	"РегистрыНакопления"="AccumulationRegister"; "РегистрыБухгалтерии"="AccountingRegister"
	"РегистрыРасчёта"="CalculationRegister"; "РегистрыРасчета"="CalculationRegister"
	"ПланыСчетов"="ChartOfAccounts"; "ПланыВидовХарактеристик"="ChartOfCharacteristicTypes"
	"ПланыВидовРасчёта"="ChartOfCalculationTypes"; "ПланыВидовРасчета"="ChartOfCalculationTypes"
	"БизнесПроцессы"="BusinessProcess"; "Задачи"="Task"
	"ПланыОбмена"="ExchangePlan"; "ЖурналыДокументов"="DocumentJournal"
	"ОбщиеМодули"="CommonModule"; "ОбщиеКоманды"="CommonCommand"
	"ОбщиеФормы"="CommonForm"; "ОбщиеКартинки"="CommonPicture"
	"ОбщиеМакеты"="CommonTemplate"; "ОбщиеРеквизиты"="CommonAttribute"
	"ГруппыКоманд"="CommandGroup"; "Роли"="Role"
	"ПараметрыСеанса"="SessionParameter"; "КритерииОтбора"="FilterCriterion"
	"ПакетыXDTO"="XDTOPackage"; "ВебСервисы"="WebService"
	"HTTPСервисы"="HTTPService"; "WSСсылки"="WSReference"
	"ПодпискиНаСобытия"="EventSubscription"; "РегламентныеЗадания"="ScheduledJob"
	"ХранилищаНастроек"="SettingsStorage"; "ФункциональныеОпции"="FunctionalOption"
	"ОпределяемыеТипы"="DefinedType"; "Подсистемы"="Subsystem"
	"ЭлементыСтиля"="StyleItem"; "СервисыИнтеграции"="IntegrationService"
}

function Normalize-ContentRef([string]$ref) {
	if (-not $ref -or -not $ref.Contains('.')) { return $ref }
	$dotIdx = $ref.IndexOf('.')
	$typePart = $ref.Substring(0, $dotIdx)
	$namePart = $ref.Substring($dotIdx + 1)
	if ($script:contentTypeMap.ContainsKey($typePart)) {
		$typePart = $script:contentTypeMap[$typePart]
	}
	return "$typePart.$namePart"
}

# --- 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($SubsystemPath)) {
	$SubsystemPath = Join-Path (Get-Location).Path $SubsystemPath
}
if (Test-Path $SubsystemPath -PathType Container) {
	$dirName = Split-Path $SubsystemPath -Leaf
	$candidate = Join-Path $SubsystemPath "$dirName.xml"
	$sibling = Join-Path (Split-Path $SubsystemPath) "$dirName.xml"
	if (Test-Path $candidate) { $SubsystemPath = $candidate }
	elseif (Test-Path $sibling) { $SubsystemPath = $sibling }
	else { Write-Error "No $dirName.xml found in directory or as sibling"; exit 1 }
}
# File not found — check Dir/Name/Name.xml → Dir/Name.xml
if (-not (Test-Path $SubsystemPath)) {
	$fn = [System.IO.Path]::GetFileNameWithoutExtension($SubsystemPath)
	$pd = Split-Path $SubsystemPath
	if ($fn -eq (Split-Path $pd -Leaf)) {
		$c = Join-Path (Split-Path $pd) "$fn.xml"
		if (Test-Path $c) { $SubsystemPath = $c }
	}
}
if (-not (Test-Path $SubsystemPath)) { Write-Error "File not found: $SubsystemPath"; exit 1 }
$resolvedPath = (Resolve-Path $SubsystemPath).Path
$script:resolvedPath = $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 ---
$script:xmlDoc = New-Object System.Xml.XmlDocument
$script:xmlDoc.PreserveWhitespace = $true
$script:xmlDoc.Load($resolvedPath)

$script:formatVersion = $script:xmlDoc.DocumentElement.GetAttribute("version")
if (-not $script:formatVersion) { $script:formatVersion = "2.17" }

# Объявления пространств имён — одной переменной: место эмиссии её только интерполирует.
# Правки шапки (как xmlns:pal в формате 2.21) делаются здесь, в одном месте.
$script:xmlnsDecl = 'xmlns="http://v8.1c.ru/8.3/MDClasses" xmlns:app="http://v8.1c.ru/8.2/managed-application/core" xmlns:cfg="http://v8.1c.ru/8.1/data/enterprise/current-config" xmlns:cmi="http://v8.1c.ru/8.2/managed-application/cmi" xmlns:ent="http://v8.1c.ru/8.1/data/enterprise" xmlns:lf="http://v8.1c.ru/8.2/managed-application/logform" xmlns:style="http://v8.1c.ru/8.1/data/ui/style" xmlns:sys="http://v8.1c.ru/8.1/data/ui/fonts/system" xmlns:v8="http://v8.1c.ru/8.1/data/core" xmlns:v8ui="http://v8.1c.ru/8.1/data/ui" xmlns:web="http://v8.1c.ru/8.1/data/ui/colors/web" xmlns:win="http://v8.1c.ru/8.1/data/ui/colors/windows" xmlns:xen="http://v8.1c.ru/8.3/xcf/enums" xmlns:xpr="http://v8.1c.ru/8.3/xcf/predef" 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"'

# Версия формата как число для сравнений: "2.20" → 220, "2.9" → 209.
# Строковое сравнение здесь неверно ("2.9" > "2.17" лексикографически) — известная ловушка.
function Get-FormatRank([string]$ver) {
	if ($ver -match '^(\d+)\.(\d+)$') { return [int]$Matches[1] * 100 + [int]$Matches[2] }
	return 0
}

# 2.21 (8.5) добавила в шапку пространство палитры — ради <Color> у значений перечисления.
# Вставляем НА МЕСТО (после lf, перед style): платформа держит объявления по алфавиту,
# дописать в конец нельзя.
if ((Get-FormatRank $script:formatVersion) -ge 221) {
	$script:xmlnsDecl = $script:xmlnsDecl -replace ' xmlns:style=', ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style='
}
$script:utf8Bom = New-Object System.Text.UTF8Encoding($true)

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

$script:propsEl = $null
$script:childObjsEl = $null
foreach ($child in $script:sub.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 "Subsystem: $($script:objName)"

# --- XML manipulation helpers (from meta-edit pattern) ---
function Esc-Xml {
	param([string]$s)
	# Эскейп ЗНАЧЕНИЯ АТРИБУТА: & < > и кавычка — внутри "..." литеральная " невалидна.
	return $s.Replace('&','&amp;').Replace('<','&lt;').Replace('>','&gt;').Replace('"','&quot;')
}

function Esc-XmlText {
	param([string]$s)
	# Эскейп ТЕКСТА элемента: только & < > — кавычку и апостроф платформа держит сырыми.
	return $s.Replace('&','&amp;').Replace('<','&lt;').Replace('>','&gt;')
}

function New-Guid-String {
	return [System.Guid]::NewGuid().ToString()
}

function Write-ChildSubsystemStub([string]$childPath, [string]$childName, [string]$formatVersion, [System.Text.Encoding]$utf8Bom) {
	$childUuid = New-Guid-String
	$sb = New-Object System.Text.StringBuilder 2048
	[void]$sb.AppendLine('<?xml version="1.0" encoding="UTF-8"?>')
	[void]$sb.AppendLine("<MetaDataObject $($script:xmlnsDecl) version=`"$formatVersion`">")
	[void]$sb.AppendLine("`t<Subsystem uuid=`"$childUuid`">")
	[void]$sb.AppendLine("`t`t<Properties>")
	[void]$sb.AppendLine("`t`t`t<Name>$(Esc-XmlText $childName)</Name>")
	[void]$sb.AppendLine("`t`t`t<Synonym/>")
	[void]$sb.AppendLine("`t`t`t<Comment/>")
	[void]$sb.AppendLine("`t`t`t<IncludeHelpInContents>true</IncludeHelpInContents>")
	[void]$sb.AppendLine("`t`t`t<IncludeInCommandInterface>true</IncludeInCommandInterface>")
	[void]$sb.AppendLine("`t`t`t<UseOneCommand>false</UseOneCommand>")
	[void]$sb.AppendLine("`t`t`t<Explanation/>")
	[void]$sb.AppendLine("`t`t`t<Picture/>")
	[void]$sb.AppendLine("`t`t`t<Content/>")
	[void]$sb.AppendLine("`t`t</Properties>")
	[void]$sb.AppendLine("`t`t<ChildObjects/>")
	[void]$sb.AppendLine("`t</Subsystem>")
	[void]$sb.AppendLine('</MetaDataObject>')
	[System.IO.File]::WriteAllText($childPath, $sb.ToString().TrimEnd("`r", "`n"), $utf8Bom)
}

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
}

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 the element is self-closing (empty), add whitespace for children
	if (-not $container.HasChildNodes -or $container.IsEmpty) {
		$childIndent = "$parentIndent`t"
		# The element is self-closing; we need to add something to make it non-empty
		# Adding a whitespace node will force opening+closing tags
		$closeWs = $script:xmlDoc.CreateWhitespace("`r`n$parentIndent")
		$container.AppendChild($closeWs) | Out-Null
	}
}

# --- Parse value: string or JSON array ---
function Parse-ValueList([string]$val, [string]$opName) {
	$val = $val.Trim()
	if ($val.StartsWith("[")) {
		$arr = ConvertFrom-JsonInput $val "-Value for operation '$opName'" "a JSON array of object names" -Inline
		$result = @(); foreach ($item in $arr) { $result += "$item" }
		return ,$result
	}
	return @($val)
}

# --- Operations ---
function Do-AddContent([string[]]$items) {
	$contentEl = $null
	foreach ($child in $script:propsEl.ChildNodes) {
		if ($child.NodeType -eq 'Element' -and $child.LocalName -eq "Content") {
			$contentEl = $child; break
		}
	}
	if (-not $contentEl) { Write-Error "No <Content> element found"; exit 1 }

	# Get existing items for dedup
	$existing = @()
	foreach ($child in $contentEl.ChildNodes) {
		if ($child.NodeType -eq 'Element' -and $child.LocalName -eq "Item") {
			$existing += $child.InnerText.Trim()
		}
	}

	# Determine indentation
	$propsIndent = Get-ChildIndent $script:propsEl
	$contentIndent = "$propsIndent`t"

	# Expand self-closing if needed
	if (-not $contentEl.HasChildNodes -or $contentEl.IsEmpty) {
		Expand-SelfClosingElement $contentEl $propsIndent
		$contentIndent = "$propsIndent`t"
	} else {
		$contentIndent = Get-ChildIndent $contentEl
	}

	foreach ($rawItem in $items) {
		$item = Normalize-ContentRef $rawItem
		if ($item -ne $rawItem) { Write-Host "[NORM] Content: $rawItem -> $item" }
		if ($item -in $existing) {
			Warn "Content already contains: $item"
			continue
		}
		$fragXml = "<xr:Item xsi:type=`"xr:MDObjectRef`">$item</xr:Item>"
		$nodes = Import-Fragment $fragXml
		if ($nodes.Count -gt 0) {
			Insert-BeforeElement $contentEl $nodes[0] $null $contentIndent
			$script:addCount++
			Info "Added content: $item"
		}
	}
}

function Do-RemoveContent([string[]]$items) {
	$contentEl = $null
	foreach ($child in $script:propsEl.ChildNodes) {
		if ($child.NodeType -eq 'Element' -and $child.LocalName -eq "Content") {
			$contentEl = $child; break
		}
	}
	if (-not $contentEl) { Write-Error "No <Content> element found"; exit 1 }

	foreach ($item in $items) {
		$found = $false
		foreach ($child in @($contentEl.ChildNodes)) {
			if ($child.NodeType -eq 'Element' -and $child.LocalName -eq "Item" -and $child.InnerText.Trim() -eq $item) {
				Remove-NodeWithWhitespace $child
				$script:removeCount++
				Info "Removed content: $item"
				$found = $true
				break
			}
		}
		if (-not $found) { Warn "Content item not found: $item" }
	}
}

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

	# Dedup check
	foreach ($child in $script:childObjsEl.ChildNodes) {
		if ($child.NodeType -eq 'Element' -and $child.LocalName -eq "Subsystem" -and $child.InnerText.Trim() -eq $childName) {
			Warn "ChildObjects already contains: $childName"
			return
		}
	}

	$subIndent = Get-ChildIndent $script:sub
	if (-not $script:childObjsEl.HasChildNodes -or $script:childObjsEl.IsEmpty) {
		Expand-SelfClosingElement $script:childObjsEl $subIndent
	}
	$childIndent = Get-ChildIndent $script:childObjsEl

	$newEl = $script:xmlDoc.CreateElement("Subsystem", $script:mdNs)
	$newEl.InnerText = $childName
	Insert-BeforeElement $script:childObjsEl $newEl $null $childIndent
	$script:addCount++
	Info "Added child subsystem: $childName"

	# Write stub XML for the new child if it doesn't exist yet
	$parentDir = [System.IO.Path]::GetDirectoryName($script:resolvedPath)
	$parentBaseName = [System.IO.Path]::GetFileNameWithoutExtension($script:resolvedPath)
	$childSubsDir = Join-Path (Join-Path $parentDir $parentBaseName) "Subsystems"
	if (-not (Test-Path $childSubsDir)) {
		New-Item -ItemType Directory -Path $childSubsDir -Force | Out-Null
		Info "Created directory: $childSubsDir"
	}
	$childXml = Join-Path $childSubsDir "$childName.xml"
	if (-not (Test-Path $childXml)) {
		Write-ChildSubsystemStub $childXml $childName $script:formatVersion $script:utf8Bom
		Info "Created stub: $childXml"
	}
}

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

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

function Do-SetProperty([string]$jsonVal) {
	$propDef = ConvertFrom-JsonInput $jsonVal "-Value for operation 'set-property'" "a JSON object {name, value}" -Inline
	$propName = "$($propDef.name)"
	$propValue = "$($propDef.value)"

	$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
	}

	$boolProps = @("IncludeInCommandInterface","UseOneCommand","IncludeHelpInContents")
	if ($propName -in $boolProps) {
		$propEl.InnerText = $propValue.ToLower()
		$script:modifyCount++
		Info "Set $propName = $propValue"
		return
	}

	$mlProps = @("Synonym","Explanation")
	if ($propName -in $mlProps) {
		if (-not $propValue) {
			# Clear - make self-closing
			$propEl.InnerXml = ""
			$script:modifyCount++
			Info "Cleared $propName"
		} else {
			$indent = Get-ChildIndent $script:propsEl
			$mlXml = "`r`n$indent`t<v8:item>`r`n$indent`t`t<v8:lang>ru</v8:lang>`r`n$indent`t`t<v8:content>$([System.Security.SecurityElement]::Escape($propValue))</v8:content>`r`n$indent`t</v8:item>`r`n$indent"
			$propEl.InnerXml = $mlXml
			$script:modifyCount++
			Info "Set $propName = `"$propValue`""
		}
		return
	}

	if ($propName -eq "Comment") {
		if (-not $propValue) { $propEl.InnerXml = "" }
		else { $propEl.InnerText = $propValue }
		$script:modifyCount++
		Info "Set Comment = `"$propValue`""
		return
	}

	if ($propName -eq "Picture") {
		if (-not $propValue) {
			$propEl.InnerXml = ""
		} else {
			$indent = Get-ChildIndent $script:propsEl
			$picXml = "`r`n$indent`t<xr:Ref>$propValue</xr:Ref>`r`n$indent`t<xr:LoadTransparent>false</xr:LoadTransparent>`r`n$indent"
			$propEl.InnerXml = $picXml
		}
		$script:modifyCount++
		Info "Set Picture = `"$propValue`""
		return
	}

	# Generic text property
	$propEl.InnerText = $propValue
	$script:modifyCount++
	Info "Set $propName = `"$propValue`""
}

# --- 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" }
	$opValue = if ($op.value) { "$($op.value)" } else { "$Value" }

	switch ($opName) {
		"add-content"    { Do-AddContent (Parse-ValueList $opValue $opName) }
		"remove-content" { Do-RemoveContent (Parse-ValueList $opValue $opName) }
		"add-child"      { Do-AddChild $opValue }
		"remove-child"   { Do-RemoveChild $opValue }
		"set-property"   { Do-SetProperty $opValue }
		default          { Write-Error "Unknown operation: $opName"; exit 1 }
	}
}

# --- Save ---
$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()

$bytes = $memStream.ToArray()
$memStream.Close()
$text = [System.Text.Encoding]::UTF8.GetString($bytes)
if ($text.Length -gt 0 -and $text[0] -eq [char]0xFEFF) { $text = $text.Substring(1) }
$text = $text.Replace('encoding="utf-8"', 'encoding="UTF-8"')
# Пустой элемент: XmlWriter отдаёт `<a />`, Конфигуратор пишет `<a/>`. Внутри
# CDATA/комментария ` />` может быть содержимым (там `>` не экранируется),
# поэтому они идут первыми ветками альтернации и возвращаются как есть.
$text = [regex]::Replace($text, '(?s)<!\[CDATA\[.*?\]\]>|<!--.*?-->|(?<=\S) />', { param($m) if ($m.Value -eq ' />') { '/>' } else { $m.Value } })

$utf8Bom = New-Object System.Text.UTF8Encoding($true)
# Целевой перевод строки: стиль файла-назначения — правка наследует его (#44/#46/#47),
# новый файл получает канон выгрузки CRLF. Зеркало _detect_xml_style в py-порту.
$targetEol = if ((Test-Path -LiteralPath $resolvedPath) -and ([System.IO.File]::ReadAllText($resolvedPath) -notmatch "`r`n")) { "`n" } else { "`r`n" }
$text = ($text -replace "`r`n", "`n") -replace "`n", $targetEol
[System.IO.File]::WriteAllText($resolvedPath, $text, $utf8Bom)
Info "Saved: $resolvedPath"

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

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