#!/usr/bin/env python3
# subsystem-edit v1.25 — Edit existing 1C subsystem XML (+тип Bot; cfe-diff/cfe-borrow: недостающие типы)
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills

import argparse
import json
import os
import re
import subprocess
import sys
import uuid
from lxml import etree

# Регистронезависимый ввод — паритет с PS1: в PowerShell имена параметров и [ValidateSet]
# регистр не различают, в argparse совпадение точное.

def parse_json_input(text, source, expected=None, inline=False):
    """Разбор пользовательского JSON: одна строка в stderr вместо traceback (issue #80).

    expected заполняем только для полиморфного входа: у файла подсказка
    была бы наполнителем — имя файла и текст парсера самодостаточны. inline печатает ещё и то,
    что доехало: у файла такого вопроса нет, он лежит на диске и его видно целиком.

    Импорты внутри тела: копия функции живёт в навыках с разными именами модулей
    (skd-decompile импортирует json локально как _json), а тело обязано быть одинаковым.
    """
    import json as _pj
    import sys as _psys
    try:
        if not str(text).strip():
            raise ValueError("input is empty")
        return _pj.loads(text)
    except ValueError as exc:
        what = "%s expects %s" % (source, expected) if expected else "Invalid JSON in %s" % source
        if inline:
            got = " ".join(str(text).split())
            label = "got"
            if not got:
                got = "(empty)"
            elif len(got) > 60:
                label = "got (first 60 chars)"
                got = got[:60]
            what = "%s, %s: %s" % (what, label, got)
        print("[ERROR] %s (%s)" % (what, exc), file=_psys.stderr)
        _psys.exit(1)


def read_json_file(path):
    """Чтение входного JSON-файла с кодировкой из BOM (issue #80).

    BOM — объявление самого файла, поэтому ему верим; без BOM ждём строгий UTF-8. Кодовую
    страницу не подбираем: угаданное имя уехало бы в метаданные молча.
    """
    import os as _pos
    import sys as _psys
    if not _pos.path.exists(path):
        print("[ERROR] File not found: %s" % path, file=_psys.stderr)
        _psys.exit(1)
    if _pos.path.isdir(path):
        print("[ERROR] Expected a JSON file, got a directory: %s" % path, file=_psys.stderr)
        _psys.exit(1)
    with open(path, "rb") as _fh:
        data = _fh.read()
    if data[:3] == b"\xef\xbb\xbf":
        return data[3:].decode("utf-8")
    if data[:2] == b"\xff\xfe":
        return data[2:].decode("utf-16-le")
    if data[:2] == b"\xfe\xff":
        return data[2:].decode("utf-16-be")
    try:
        return data.decode("utf-8")
    except UnicodeDecodeError as exc:
        print("[ERROR] %s is not valid UTF-8: %s - save the file as UTF-8, or add a BOM if it is UTF-16"
              % (path, exc), file=_psys.stderr)
        _psys.exit(1)


class CIDict(dict):
    # Ключи храним КАК ЕСТЬ: часть из них — имена объектов (табличные части, стандартные
    # реквизиты), они попадают в XML. Регистронезависим только поиск. Порядок вставки
    # сохраняется — от него зависит порядок эмиссии.
    def _actual(self, key):
        if not isinstance(key, str) or dict.__contains__(self, key):
            return key
        ci = self.__dict__.get('_ci')
        if ci is None or len(ci) != len(self):
            ci = {k.lower(): k for k in self if isinstance(k, str)}
            self.__dict__['_ci'] = ci
        return ci.get(key.lower(), key)

    def __getitem__(self, key):
        return dict.__getitem__(self, self._actual(key))

    def __contains__(self, key):
        return dict.__contains__(self, self._actual(key))

    def get(self, key, default=None):
        return dict.get(self, self._actual(key), default)

    def pop(self, key, *default):
        return dict.pop(self, self._actual(key), *default)

    def __setitem__(self, key, value):
        # запись по ключу, отличающемуся регистром, обновляет существующий, а не плодит дубль
        dict.__setitem__(self, self._actual(key), value)

def ci_json(obj):
    """Рекурсивно оборачивает разобранный JSON: словари → CIDict, списки обходятся."""
    if isinstance(obj, dict):
        return CIDict((k, ci_json(v)) for k, v in obj.items())
    if isinstance(obj, list):
        return [ci_json(v) for v in obj]
    return obj

def ci_parse_args(parser, argv=None):
    """parse_args по правилам PS: имена параметров и значения choices регистронезависимы."""
    argv = list(sys.argv[1:] if argv is None else argv)
    names = {s.lower(): s for a in parser._actions for s in a.option_strings}
    for i, tok in enumerate(argv):
        if tok.startswith('-') and tok.lower() in names:
            argv[i] = names[tok.lower()]
    # choices — зеркало [ValidateSet]; канонизируем ДО разбора, иначе argparse отвергнет регистр
    choice_map = {}
    for a in parser._actions:
        if a.choices:
            for s in a.option_strings:
                choice_map[s] = {str(c).lower(): c for c in a.choices}
    for i in range(len(argv) - 1):
        m = choice_map.get(argv[i])
        if m and argv[i + 1].lower() in m:
            argv[i + 1] = m[argv[i + 1].lower()]
    return parser.parse_args(argv)



# ============================================================
# Support guard (Ext/ParentConfigurations.bin) — see docs/1c-support-state-spec.md
# Blocks edits of vendor objects "на замке" / read-only configs. Trigger = bin
# present; reaction from .v8-project.json editingAllowedCheck (deny|warn|off,
# default deny). Never throws (except sys.exit on deny) — errors degrade to allow.
# ============================================================

def _sg_root_uuid(xml_path):
    if not os.path.isfile(xml_path):
        return None
    try:
        mx = etree.parse(xml_path).getroot()
        for child in mx:
            if isinstance(child.tag, str) and child.get("uuid"):
                return child.get("uuid")
    except Exception:
        return None
    return None


def _sg_is_external_root(xml_path):
    if not os.path.isfile(xml_path):
        return False
    try:
        mx = etree.parse(xml_path).getroot()
        for child in mx:
            if isinstance(child.tag, str):
                return child.tag.split("}")[-1] in ("ExternalDataProcessor", "ExternalReport")
    except Exception:
        return False
    return False

def _sg_find_v8project(start_dir):
    d = start_dir
    for _ in range(20):
        if not d:
            break
        pj = os.path.join(d, ".v8-project.json")
        if os.path.isfile(pj):
            return pj
        parent = os.path.dirname(d)
        if parent == d:
            break
        d = parent
    return None


def _sg_get_edit_mode(cfg_dir):
    try:
        pj = _sg_find_v8project(os.getcwd()) or _sg_find_v8project(cfg_dir)
        if not pj:
            return "deny"
        proj = json.loads(open(pj, encoding="utf-8-sig").read())
        cfg_full = os.path.normcase(os.path.abspath(cfg_dir)).rstrip("\\/")
        for db in proj.get("databases", []):
            src = db.get("configSrc")
            if src:
                src_full = os.path.normcase(os.path.abspath(src)).rstrip("\\/")
                if cfg_full == src_full or cfg_full.startswith(src_full + os.sep):
                    if db.get("editingAllowedCheck"):
                        return db["editingAllowedCheck"]
        if proj.get("editingAllowedCheck"):
            return proj["editingAllowedCheck"]
        return "deny"
    except Exception:
        return "deny"


def assert_edit_allowed(target_path, require):
    try:
        rp = os.path.abspath(target_path)
        # Autonomous external object (EPF/ERF): never part of a config on support (issue #39).
        if _sg_is_external_root(rp):
            return
        elem_uuid = _sg_root_uuid(rp)
        cfg_dir = None
        bin_path = None
        d = rp if os.path.isdir(rp) else os.path.dirname(rp)
        for _ in range(12):
            if not d:
                break
            if _sg_is_external_root(d + ".xml"):
                return
            if not elem_uuid:
                elem_uuid = _sg_root_uuid(d + ".xml")
            if not cfg_dir:
                cand = os.path.join(d, "Ext", "ParentConfigurations.bin")
                if os.path.exists(cand) or os.path.exists(os.path.join(d, "Configuration.xml")):
                    cfg_dir = d
                    bin_path = cand
            if elem_uuid and cfg_dir:
                break
            parent = os.path.dirname(d)
            if parent == d:
                break
            d = parent
        if not elem_uuid and cfg_dir:
            elem_uuid = _sg_root_uuid(os.path.join(cfg_dir, "Configuration.xml"))
        if not bin_path or not os.path.exists(bin_path):
            return
        data = open(bin_path, "rb").read()
        if len(data) <= 32:
            return
        if data[:3] == b"\xef\xbb\xbf":
            data = data[3:]
        text = data.decode("utf-8", "replace")
        h = re.match(r"\{6,(\d+),(\d+),", text)
        if not h:
            return
        g = int(h.group(1))
        k = int(h.group(2))
        if k == 0:
            return
        best = None
        if elem_uuid:
            for m in re.finditer(r"([0-2]),0," + re.escape(elem_uuid.lower()), text):
                f1 = int(m.group(1))
                if best is None or f1 < best:
                    best = f1
        blocked = False
        code = ""
        reason = ""
        if g == 1:
            blocked = True
            code = "capability-off"
            reason = "возможность изменения конфигурации выключена (вся конфигурация read-only)"
        elif require == "removed":
            if best is not None and best != 2:
                blocked = True
                code = "not-removed"
                reason = "объект не снят с поддержки — удаление сломает обновления"
        else:
            if best is not None and best == 0:
                blocked = True
                code = "locked"
                reason = "объект на замке — редактирование сломает обновления"
        if not blocked:
            return
        mode = _sg_get_edit_mode(cfg_dir)
        if mode == "off":
            return
        if mode == "warn":
            sys.stderr.write(f"[support-guard] ПРЕДУПРЕЖДЕНИЕ: {reason}. Цель: {rp}\n")
            return
        head = "[support-guard] Редактирование отклонено: это объект типовой конфигурации на поддержке поставщика, прямое редактирование молча сломает будущие обновления."
        cfe = "Рекомендуемый путь: внести доработку в расширение (навыки cfe-borrow / cfe-patch-method) — состояние поддержки менять не нужно, обновления вендора сохраняются."
        off_note = "Снять проверку для этой базы: editingAllowedCheck = warn|off в .v8-project.json."
        if code == "capability-off":
            state = f"Состояние: у всей конфигурации выключена возможность изменения (режим read-only «из коробки») — поэтому объект «{rp}» редактировать нельзя."
            fix = (
                "Либо снять защиту явно (навык support-edit, два шага):\n"
                f'  1. support-edit -Path "{cfg_dir}" -Capability on — включить возможность изменения (объекты пока остаются на замке);\n'
                f'  2. support-edit -Path "{rp}" -Set editable — открыть этот объект для редактирования.\n'
                "  Изменение применяется в базу полной загрузкой выгрузки и обходит механизм обновлений вендора."
            )
        elif code == "not-removed":
            state = f"Состояние: объект «{rp}» на поддержке (не снят с поддержки) — его удаление разорвёт обновления вендора."
            fix = (
                "Либо сначала снять объект с поддержки, затем удалять:\n"
                f'  support-edit -Path "{rp}" -Set off-support — объект уходит из-под обновлений, после этого удаление безопасно.'
            )
        else:
            state = f"Состояние: объект «{rp}» на замке (возможность изменения конфигурации включена, но сам объект не редактируется)."
            fix = (
                "Либо разрешить редактирование этого объекта (навык support-edit, выбрать одно):\n"
                f'  support-edit -Path "{rp}" -Set editable — редактировать и дальше получать обновления вендора (возможны конфликты слияния);\n'
                f'  support-edit -Path "{rp}" -Set off-support — снять с поддержки: обновления по объекту больше не приходят.'
            )
        sys.stderr.write(head + "\n" + state + "\n" + cfe + "\n" + fix + "\n" + off_note + "\n")
        sys.exit(1)
    except SystemExit:
        raise
    except Exception:
        return


def new_uuid():
    return str(uuid.uuid4())


def esc_xml(s):
    # Эскейп ЗНАЧЕНИЯ АТРИБУТА: & < > и кавычка — внутри "..." литеральная " невалидна.
    return s.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;').replace('"', '&quot;')


def esc_xml_text(s):
    """Экранирование ТЕКСТА элемента: только & < > . Кавычки платформа в тексте не экранирует
    (92142 сырых кавычки на корпус, ни одной &quot;); &quot; она принимает, но нормализует обратно."""
    return s.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')


def write_utf8_bom(path, content):
    # newline='' — без трансляции: иначе текстовый режим Python дал бы CRLF на Windows
    # и LF на macOS, то есть вывод навыка зависел бы от ОС.
    with open(path, 'w', encoding='utf-8-sig', newline='') as f:
        f.write(content)



# Объявления пространств имён — одной переменной: место эмиссии её только подставляет.
# Правки шапки (как xmlns:pal в формате 2.21) делаются в одном месте, в main.
XMLNS_DECL = (
    '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"'
)


def format_rank(ver):
    """"2.20" → 220, "2.9" → 209. Строковое сравнение неверно ("2.9" > "2.17")."""
    m = re.match(r'^(\d+)\.(\d+)$', ver or '')
    return int(m.group(1)) * 100 + int(m.group(2)) if m else 0


def apply_pal_ns(format_version):
    """2.21 (8.5) добавила в шапку пространство палитры — ради <Color> у значений перечисления.
    Вставляем НА МЕСТО (после lf, перед style): платформа держит объявления по алфавиту,
    дописать в конец нельзя."""
    global XMLNS_DECL
    if format_rank(format_version) >= 221:
        XMLNS_DECL = XMLNS_DECL.replace(
            ' xmlns:style=',
            ' xmlns:pal="http://v8.1c.ru/8.1/data/ui/colors/palette" xmlns:style=')


def write_child_subsystem_stub(child_path, child_name, format_version):
    child_uuid = new_uuid()
    lines = []
    lines.append('<?xml version="1.0" encoding="UTF-8"?>')
    lines.append(f'<MetaDataObject {XMLNS_DECL} version="{format_version}">')
    lines.append(f'\t<Subsystem uuid="{child_uuid}">')
    lines.append('\t\t<Properties>')
    lines.append(f'\t\t\t<Name>{esc_xml_text(child_name)}</Name>')
    lines.append('\t\t\t<Synonym/>')
    lines.append('\t\t\t<Comment/>')
    lines.append('\t\t\t<IncludeHelpInContents>true</IncludeHelpInContents>')
    lines.append('\t\t\t<IncludeInCommandInterface>true</IncludeInCommandInterface>')
    lines.append('\t\t\t<UseOneCommand>false</UseOneCommand>')
    lines.append('\t\t\t<Explanation/>')
    lines.append('\t\t\t<Picture/>')
    lines.append('\t\t\t<Content/>')
    lines.append('\t\t</Properties>')
    lines.append('\t\t<ChildObjects/>')
    lines.append('\t</Subsystem>')
    lines.append('</MetaDataObject>')
    write_utf8_bom(child_path, '\r\n'.join(lines))

MD_NS = "http://v8.1c.ru/8.3/MDClasses"
XR_NS = "http://v8.1c.ru/8.3/xcf/readable"
XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
V8_NS = "http://v8.1c.ru/8.1/data/core"
XS_NS = "http://www.w3.org/2001/XMLSchema"

NSMAP_WRAPPER = {
    None: MD_NS,
    "xsi": XSI_NS,
    "v8": V8_NS,
    "xr": XR_NS,
    "xs": XS_NS,
}


CONTENT_TYPE_MAP = {
    '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',
}


def normalize_content_ref(ref):
    if not ref or '.' not in ref:
        return ref
    dot_idx = ref.index('.')
    type_part = ref[:dot_idx]
    name_part = ref[dot_idx + 1:]
    if type_part in CONTENT_TYPE_MAP:
        type_part = CONTENT_TYPE_MAP[type_part]
    return f'{type_part}.{name_part}'


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


def info(msg):
    print(f"[INFO] {msg}")


def warn(msg):
    print(f"[WARN] {msg}")


def get_child_indent(container):
    """Detect indentation of children inside a container element."""
    if container.text and "\n" in container.text:
        after_nl = container.text.rsplit("\n", 1)[-1]
        if after_nl and not after_nl.strip():
            return after_nl
    for child in container:
        if child.tail and "\n" in child.tail:
            after_nl = child.tail.rsplit("\n", 1)[-1]
            if after_nl and not after_nl.strip():
                return after_nl
    # Fallback: count depth
    depth = 0
    current = container
    while current is not None:
        depth += 1
        current = current.getparent()
    return "\t" * depth


def insert_before_closing(container, new_el, child_indent):
    """Insert new_el before the closing tag of container, with proper indentation."""
    children = list(container)
    if len(children) == 0:
        # Empty element: set text to newline+indent, tail of new_el to newline+parent_indent
        parent_indent = child_indent[:-1] if len(child_indent) > 0 else ""
        container.text = "\r\n" + child_indent
        new_el.tail = "\r\n" + parent_indent
        container.append(new_el)
    else:
        last = children[-1]
        new_el.tail = last.tail
        last.tail = "\r\n" + child_indent
        container.append(new_el)


def remove_with_indent(el):
    """Remove element and clean up surrounding whitespace."""
    parent = el.getparent()
    prev = el.getprevious()
    if prev is not None:
        # Transfer el.tail to prev.tail
        if el.tail and el.tail.strip() == "":
            pass  # just drop extra whitespace
        prev.tail = el.tail if el.tail and el.tail.strip() else (prev.tail or "")
        # Actually try to keep the prev's tail as the closing indent
        # Better approach: set prev.tail to what el.tail was (newline+indent of next or closing)
        if el.tail:
            prev.tail = el.tail
    else:
        # First child: adjust parent.text
        if el.tail:
            parent.text = el.tail
    parent.remove(el)


def expand_self_closing(container, parent_indent):
    """If container is self-closing (no children, no text), add closing whitespace."""
    if len(container) == 0 and not (container.text and container.text.strip()):
        container.text = "\r\n" + parent_indent


def import_fragment(xml_string, doc_root):
    """Parse an XML fragment in the MD namespace context and return elements."""
    wrapper = (
        f'<_W xmlns="{MD_NS}" xmlns:xsi="{XSI_NS}" xmlns:v8="{V8_NS}" '
        f'xmlns:xr="{XR_NS}" xmlns:xs="{XS_NS}">{xml_string}</_W>'
    )
    frag = etree.fromstring(wrapper.encode("utf-8"))
    nodes = []
    for child in frag:
        nodes.append(child)
    return nodes


def parse_value_list(val, op_name):
    """Parse a string or JSON array into a list of strings."""
    val = val.strip()
    if val.startswith("["):
        arr = ci_json(parse_json_input(val, "-Value for operation '%s'" % op_name, "a JSON array of object names", inline=True))
        return [str(item) for item in arr]
    return [val]


def _detect_xml_style(path):
    """Стиль существующего файла для round-trip-сохранения: BOM / EOL / регистр encoding /
    финальный перенос. None → файл новый (сохранить текущее поведение)."""
    try:
        raw = open(path, "rb").read()
    except OSError:
        return None
    bom = raw.startswith(b"\xef\xbb\xbf")
    body = raw[3:] if bom else raw
    crlf = b"\r\n" in body
    m = re.search(rb'encoding="([^"]+)"', body[:200])
    enc = m.group(1).decode("ascii") if m else "utf-8"
    final_nl = body.endswith(b"\n")
    return {"bom": bom, "crlf": crlf, "enc": enc, "final_nl": final_nl}


def _finalize_xml_bytes(xml_bytes, style):
    """Привести байты к стилю оригинала; для НОВОГО файла (style is None) — к канону
    выгрузки Конфигуратора: encoding="UTF-8", CRLF в разделителях, без перевода в конце."""
    enc_decl = style["enc"] if style else "UTF-8"
    xml_bytes = xml_bytes.replace(
        b"<?xml version='1.0' encoding='UTF-8'?>",
        b'<?xml version="1.0" encoding="' + enc_decl.encode("ascii") + b'"?>')
    # Канонизировать переносы к LF (убирает &#13; от \r в tail'ах)
    xml_bytes = (xml_bytes.replace(b"&#13;\n", b"\n").replace(b"&#13;", b"")
                 .replace(b"\r\n", b"\n").replace(b"\r", b"\n"))
    # Финальный перенос — как в оригинале (новый файл → нет, канон #57)
    want_final_nl = style["final_nl"] if style else False
    xml_bytes = xml_bytes.rstrip(b"\n")
    if want_final_nl:
        xml_bytes += b"\n"
    # EOL — как в оригинале (новый файл → CRLF, канон #57)
    if (style["crlf"] if style else True):
        xml_bytes = xml_bytes.replace(b"\n", b"\r\n")
    return xml_bytes


def save_xml_bom(tree, path):
    style = _detect_xml_style(path)
    xml_bytes = etree.tostring(tree, xml_declaration=True, encoding="UTF-8")
    xml_bytes = _finalize_xml_bytes(xml_bytes, style)
    with open(path, "wb") as f:
        if style is None or style["bom"]:
            f.write(b"\xef\xbb\xbf")
        f.write(xml_bytes)


def main():
    sys.stdout.reconfigure(encoding="utf-8")
    sys.stderr.reconfigure(encoding="utf-8")
    parser = argparse.ArgumentParser(description="Edit existing 1C subsystem XML", allow_abbrev=False)
    parser.add_argument("-SubsystemPath", "-Path", required=True)
    parser.add_argument("-DefinitionFile", default=None)
    parser.add_argument("-Operation", default=None, choices=["add-content", "remove-content", "add-child", "remove-child", "set-property"])
    parser.add_argument("-Value", default=None)
    parser.add_argument("-NoValidate", action="store_true")
    args = ci_parse_args(parser)

    # --- Mode validation ---
    if args.DefinitionFile and args.Operation:
        print("Cannot use both -DefinitionFile and -Operation", file=sys.stderr)
        sys.exit(1)
    if not args.DefinitionFile and not args.Operation:
        print("Either -DefinitionFile or -Operation is required", file=sys.stderr)
        sys.exit(1)

    # --- Resolve path ---
    subsystem_path = args.SubsystemPath
    if not os.path.isabs(subsystem_path):
        subsystem_path = os.path.join(os.getcwd(), subsystem_path)

    if os.path.isdir(subsystem_path):
        dir_name = os.path.basename(subsystem_path)
        candidate = os.path.join(subsystem_path, f"{dir_name}.xml")
        sibling = os.path.join(os.path.dirname(subsystem_path), f"{dir_name}.xml")
        if os.path.isfile(candidate):
            subsystem_path = candidate
        elif os.path.isfile(sibling):
            subsystem_path = sibling
        else:
            print(f"No {dir_name}.xml found in directory or as sibling", file=sys.stderr)
            sys.exit(1)

    if not os.path.isfile(subsystem_path):
        fn = os.path.splitext(os.path.basename(subsystem_path))[0]
        pd = os.path.dirname(subsystem_path)
        if fn == os.path.basename(pd):
            c = os.path.join(os.path.dirname(pd), f"{fn}.xml")
            if os.path.isfile(c):
                subsystem_path = c

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

    resolved_path = os.path.abspath(subsystem_path)

    assert_edit_allowed(resolved_path, "editable")

    # --- Load XML ---
    xml_parser = etree.XMLParser(remove_blank_text=False)
    tree = etree.parse(resolved_path, xml_parser)
    xml_root = tree.getroot()
    format_version = xml_root.get("version") or "2.17"
    apply_pal_ns(format_version)

    add_count = 0
    remove_count = 0
    modify_count = 0

    # --- Detect structure ---
    sub = None
    for child in xml_root:
        if isinstance(child.tag, str) and localname(child) == "Subsystem":
            sub = child
            break
    if sub is None:
        print("No <Subsystem> element found", file=sys.stderr)
        sys.exit(1)

    props_el = None
    child_objs_el = None
    for child in sub:
        if not isinstance(child.tag, str):
            continue
        if localname(child) == "Properties":
            props_el = child
        if localname(child) == "ChildObjects":
            child_objs_el = child

    obj_name = ""
    if props_el is not None:
        for child in props_el:
            if isinstance(child.tag, str) and localname(child) == "Name":
                obj_name = (child.text or "").strip()
                break
    info(f"Subsystem: {obj_name}")

    # --- Operations ---
    def do_add_content(items):
        nonlocal add_count
        content_el = None
        for child in props_el:
            if isinstance(child.tag, str) and localname(child) == "Content":
                content_el = child
                break
        if content_el is None:
            print("No <Content> element found", file=sys.stderr)
            sys.exit(1)

        existing = set()
        for child in content_el:
            if isinstance(child.tag, str) and localname(child) == "Item":
                existing.add((child.text or "").strip())

        props_indent = get_child_indent(props_el)
        if len(content_el) == 0 and not (content_el.text and content_el.text.strip()):
            expand_self_closing(content_el, props_indent)
        content_indent = get_child_indent(content_el)

        for raw_item in items:
            item = normalize_content_ref(raw_item)
            if item != raw_item:
                print(f'[NORM] Content: {raw_item} -> {item}')
            if item in existing:
                warn(f"Content already contains: {item}")
                continue
            frag_xml = f'<xr:Item xsi:type="xr:MDObjectRef">{item}</xr:Item>'
            nodes = import_fragment(frag_xml, xml_root)
            if nodes:
                insert_before_closing(content_el, nodes[0], content_indent)
                add_count += 1
                info(f"Added content: {item}")

    def do_remove_content(items):
        nonlocal remove_count
        content_el = None
        for child in props_el:
            if isinstance(child.tag, str) and localname(child) == "Content":
                content_el = child
                break
        if content_el is None:
            print("No <Content> element found", file=sys.stderr)
            sys.exit(1)

        for item in items:
            found = False
            for child in list(content_el):
                if isinstance(child.tag, str) and localname(child) == "Item" and (child.text or "").strip() == item:
                    remove_with_indent(child)
                    remove_count += 1
                    info(f"Removed content: {item}")
                    found = True
                    break
            if not found:
                warn(f"Content item not found: {item}")

    def do_add_child(child_name):
        nonlocal add_count
        if child_objs_el is None:
            print("No <ChildObjects> element found", file=sys.stderr)
            sys.exit(1)

        for child in child_objs_el:
            if isinstance(child.tag, str) and localname(child) == "Subsystem" and (child.text or "").strip() == child_name:
                warn(f"ChildObjects already contains: {child_name}")
                return

        sub_indent = get_child_indent(sub)
        if len(child_objs_el) == 0 and not (child_objs_el.text and child_objs_el.text.strip()):
            expand_self_closing(child_objs_el, sub_indent)
        ci = get_child_indent(child_objs_el)

        new_el = etree.SubElement(child_objs_el, f"{{{MD_NS}}}Subsystem")
        # Actually we need to use insert_before_closing pattern
        child_objs_el.remove(new_el)
        new_el = etree.Element(f"{{{MD_NS}}}Subsystem")
        new_el.text = child_name
        insert_before_closing(child_objs_el, new_el, ci)
        add_count += 1
        info(f"Added child subsystem: {child_name}")

        # Write stub XML for the new child if it doesn't exist yet
        parent_dir = os.path.dirname(resolved_path)
        parent_base_name = os.path.splitext(os.path.basename(resolved_path))[0]
        child_subs_dir = os.path.join(parent_dir, parent_base_name, 'Subsystems')
        if not os.path.exists(child_subs_dir):
            os.makedirs(child_subs_dir, exist_ok=True)
            info(f"Created directory: {child_subs_dir}")
        child_xml = os.path.join(child_subs_dir, f'{child_name}.xml')
        if not os.path.exists(child_xml):
            write_child_subsystem_stub(child_xml, child_name, format_version)
            info(f"Created stub: {child_xml}")

    def do_remove_child(child_name):
        nonlocal remove_count
        if child_objs_el is None:
            print("No <ChildObjects> element found", file=sys.stderr)
            sys.exit(1)

        found = False
        for child in list(child_objs_el):
            if isinstance(child.tag, str) and localname(child) == "Subsystem" and (child.text or "").strip() == child_name:
                remove_with_indent(child)
                remove_count += 1
                info(f"Removed child subsystem: {child_name}")
                found = True
                break
        if not found:
            warn(f"Child subsystem not found: {child_name}")

    def do_set_property(json_val):
        nonlocal modify_count
        prop_def = ci_json(parse_json_input(
            json_val, "-Value for operation 'set-property'", "a JSON object {name, value}", inline=True))
        prop_name = str(prop_def["name"])
        prop_value = str(prop_def.get("value", ""))

        prop_el = None
        for child in props_el:
            if isinstance(child.tag, str) and localname(child) == prop_name:
                prop_el = child
                break
        if prop_el is None:
            print(f"Property '{prop_name}' not found in Properties", file=sys.stderr)
            sys.exit(1)

        bool_props = ["IncludeInCommandInterface", "UseOneCommand", "IncludeHelpInContents"]
        if prop_name in bool_props:
            prop_el.text = prop_value.lower()
            # Clear children
            for ch in list(prop_el):
                prop_el.remove(ch)
            modify_count += 1
            info(f"Set {prop_name} = {prop_value}")
            return

        ml_props = ["Synonym", "Explanation"]
        if prop_name in ml_props:
            if not prop_value:
                # Clear - make self-closing
                for ch in list(prop_el):
                    prop_el.remove(ch)
                prop_el.text = None
                modify_count += 1
                info(f"Cleared {prop_name}")
            else:
                for ch in list(prop_el):
                    prop_el.remove(ch)
                indent = get_child_indent(props_el)

                item_el = etree.SubElement(prop_el, f"{{{V8_NS}}}item")
                lang_el = etree.SubElement(item_el, f"{{{V8_NS}}}lang")
                lang_el.text = "ru"
                content_el = etree.SubElement(item_el, f"{{{V8_NS}}}content")
                content_el.text = prop_value

                # Set whitespace
                prop_el.text = "\r\n" + indent + "\t"
                item_el.text = "\r\n" + indent + "\t\t"
                lang_el.tail = "\r\n" + indent + "\t\t"
                content_el.tail = "\r\n" + indent + "\t"
                item_el.tail = "\r\n" + indent

                modify_count += 1
                info(f'Set {prop_name} = "{prop_value}"')
            return

        if prop_key == "comment":
            for ch in list(prop_el):
                prop_el.remove(ch)
            if not prop_value:
                prop_el.text = None
            else:
                prop_el.text = prop_value
            modify_count += 1
            info(f'Set Comment = "{prop_value}"')
            return

        if prop_key == "picture":
            for ch in list(prop_el):
                prop_el.remove(ch)
            if not prop_value:
                prop_el.text = None
            else:
                indent = get_child_indent(props_el)
                ref_el = etree.SubElement(prop_el, f"{{{XR_NS}}}Ref")
                ref_el.text = prop_value
                load_el = etree.SubElement(prop_el, f"{{{XR_NS}}}LoadTransparent")
                load_el.text = "false"
                prop_el.text = "\r\n" + indent + "\t"
                ref_el.tail = "\r\n" + indent + "\t"
                load_el.tail = "\r\n" + indent
            modify_count += 1
            info(f'Set Picture = "{prop_value}"')
            return

        # Generic text property
        for ch in list(prop_el):
            prop_el.remove(ch)
        prop_el.text = prop_value
        modify_count += 1
        info(f'Set {prop_name} = "{prop_value}"')

    # --- Execute operations ---
    operations = []
    if args.DefinitionFile:
        def_file = args.DefinitionFile
        if not os.path.isabs(def_file):
            def_file = os.path.join(os.getcwd(), def_file)
        ops = ci_json(parse_json_input(read_json_file(def_file), def_file))
        if isinstance(ops, list):
            operations = ops
        else:
            operations = [ops]
    else:
        operations = [{"operation": args.Operation, "value": args.Value or ""}]

    for op in operations:
        op_name = op.get("operation", args.Operation or "")
        # PS сравнивает имя операции через switch, а он регистронезависим.
        op_key = str(op_name).lower()
        op_value = op.get("value", args.Value or "")

        if op_key == "add-content":
            do_add_content(parse_value_list(op_value, op_name))
        elif op_key == "remove-content":
            do_remove_content(parse_value_list(op_value, op_name))
        elif op_key == "add-child":
            do_add_child(op_value)
        elif op_key == "remove-child":
            do_remove_child(op_value)
        elif op_key == "set-property":
            do_set_property(op_value)
        else:
            print(f"Unknown operation: {op_name}", file=sys.stderr)
            sys.exit(1)

    # --- Save ---
    save_xml_bom(tree, resolved_path)
    info(f"Saved: {resolved_path}")

    # --- Auto-validate ---
    if not args.NoValidate:
        validate_script = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", "subsystem-validate", "scripts", "subsystem-validate.py"))
        if os.path.isfile(validate_script):
            print()
            print("--- Running subsystem-validate ---")
            subprocess.run([sys.executable, validate_script, "-SubsystemPath", resolved_path])

    # --- Summary ---
    print()
    print("=== subsystem-edit summary ===")
    print(f"  Subsystem: {obj_name}")
    print(f"  Added:     {add_count}")
    print(f"  Removed:   {remove_count}")
    print(f"  Modified:  {modify_count}")
    sys.exit(0)


if __name__ == "__main__":
    main()
