#!/usr/bin/env python3
# db-load-xml v1.28 — Load 1C configuration from XML files
# Source: https://github.com/Nikolay-Shirokov/cc-1c-skills

import argparse
import atexit
import glob
import json
import os
import random
import re
import shutil
import subprocess
import sys
import tempfile

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



def _find_project_v8path():
    """Walk up from CWD to find .v8-project.json and read its v8path."""
    d = os.getcwd()
    while True:
        pf = os.path.join(d, ".v8-project.json")
        if os.path.isfile(pf):
            try:
                with open(pf, encoding="utf-8-sig") as f:
                    data = json.load(f)
                v = data.get("v8path")
                if v:
                    return v
            except Exception:
                pass
            return None
        parent = os.path.dirname(d)
        if parent == d:
            return None
        d = parent


# --- Additional platform arguments ---
V8_OWNED_KEYS = [
    "DESIGNER", "ENTERPRISE", "CREATEINFOBASE", "CONFIG",
    "/F", "/S", "/N", "/P", "/Out", "/DisableStartupDialogs",
    "/UseTemplate", "/AddToList", "/Execute", "/C", "/URL", "/UC",
    "/DumpIB", "/RestoreIB", "/DumpCfg", "/LoadCfg",
    "/DumpConfigToFiles", "/LoadConfigFromFiles", "/UpdateDBCfg",
    "/DumpExternalDataProcessorOrReportToFiles", "/LoadExternalDataProcessorOrReportFromFiles",
]
# Пакетные команды платформы. В одной командной строке DESIGNER выполняет ТОЛЬКО ПОСЛЕДНЮЮ,
# остальные молча отбрасывает (проверено на 8.3.24: /LoadConfigFromFiles вместе с
# /CheckCanApplyConfigurationExtensions завершились кодом 0 с пустым логом, и загрузка НЕ
# состоялась). Такая команда в дополнительных аргументах подменяет собой операцию навыка, а навык
# отчитывается успехом. Дополнительные аргументы — это опции, а не режимы.
V8_BATCH_KEYS = [
    "/CheckConfig", "/CheckModules", "/CheckCanApplyConfigurationExtensions",
    "/DumpDBCfgList", "/DeleteCfg", "/UpdateCfg", "/CompareCfg", "/MergeCfg",
    "/ManageCfgSupport", "/RollbackCfg", "/ConvertFiles",
]

IBCMD_OWNED_KEYS = [
    "--db-path", "--data", "--out", "--file", "--load", "--restore",
    "--import", "--export", "--apply", "--force", "--create-database",
    "--user", "--password",
]
V8_SECRET_KEYS = ["/P", "/UC", "/WSP", "/AWSP", "/ConfigurationRepositoryP"]
IBCMD_SECRET_KEYS = ["--password", "--token", "--db-pwd"]


# --- Реквизиты хранилища из .v8-project.json ---
# Модель их не передаёт: скрипт сопоставляет параметры соединения с записью в databases[]
# и берёт repository оттуда. Тот же приём, что в cf-edit.py (сопоставление по configSrc).
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 same_path(a, b):
    if not a or not b:
        return False
    try:
        return os.path.abspath(a).rstrip("\\/").lower() == os.path.abspath(b).rstrip("\\/").lower()
    except Exception:
        return False


def find_project_database(args):
    """Запись базы в реестре, соответствующая переданному соединению. None, если не найдена."""
    pf = _sg_find_v8project(os.getcwd())
    if not pf:
        return None
    try:
        with open(pf, encoding="utf-8-sig") as f:
            proj = json.load(f)
    except Exception:
        return None
    for db in proj.get("databases") or []:
        if args.InfoBasePath and db.get("path") and same_path(db["path"], args.InfoBasePath):
            return db
        if args.InfoBaseServer and args.InfoBaseRef and db.get("server") and db.get("ref"):
            if (db["server"].lower() == args.InfoBaseServer.lower()
                    and db["ref"].lower() == args.InfoBaseRef.lower()):
                return db
    return None


def resolve_repository_settings(args):
    """Возвращает dict path/user/password/from_registry. Явные -Repository* сильнее реестра."""
    db_rec = find_project_database(args)
    rec = None
    if db_rec:
        if args.Extension:
            # У расширения СВОЁ хранилище со своим путём (проверено): выбирается парой
            # /ConfigurationRepositoryF"<путь расширения>" + -Extension "<Имя>".
            for ext in db_rec.get("extensions") or []:
                if (ext.get("name") or "").lower() == args.Extension.lower():
                    rec = ext.get("repository")
                    break
        else:
            rec = db_rec.get("repository")
    path = args.RepositoryPath or ((rec or {}).get("path") or None)
    user = args.RepositoryUser or ((rec or {}).get("user") or None)
    # Пустой пароль = отсутствующий: 1С требует опускать ключ целиком, а не передавать пустое значение.
    pwd = args.RepositoryPassword or ((rec or {}).get("password") or None)
    return {
        "path": path.strip().strip('"') if path else None,
        "user": user,
        "password": pwd,
        "from_registry": bool(rec and rec.get("path")),
    }


def repository_args(repo):
    """Ключи доступа к хранилищу. Форма — кавычки ВНУТРИ токена, как у /N и /P."""
    a = []
    if not repo or not repo.get("path"):
        return a
    a.append('/ConfigurationRepositoryF"%s"' % repo["path"])
    if repo.get("user"):
        a.append('/ConfigurationRepositoryN"%s"' % repo["user"])
    if repo.get("password"):
        a.append('/ConfigurationRepositoryP"%s"' % repo["password"])
    return a


# Сообщения платформы про хранилище конфигурации называют причину, но не действие. Действие
# дописываем сами: без него модель упирается в отказ и не знает, чем его лечить.
def write_repository_hints(log_text):
    if not log_text:
        return
    if "текущая конфигурация помещена в хранилище" in log_text:
        print("[hint] полная загрузка в базу, подключённую к хранилищу, невозможна.")
        print("       Используйте -Mode Partial, предварительно захватив объекты: /db-repo lock")
    for m in re.finditer(r"объект метаданных (\S+) не захвачен в хранилище", log_text):
        obj = m.group(1)
        if obj == "Configuration":
            print("[hint] не захвачен корень конфигурации — он нужен, чтобы добавить или удалить объект:")
            print('       /db-repo lock <база> -Objects "Конфигурация"')
        else:
            print('[hint] объект не захвачен в хранилище: /db-repo lock <база> -Objects "%s"' % obj)
    if "Соединение с хранилищем конфигурации не установлено" in log_text:
        print("[hint] соединение с хранилищем не установлено. Две причины:")
        print('       реквизиты неизвестны — добавьте "repository" в запись базы в .v8-project.json (см. /db-list);')
        print("       либо хранилище недоступно — для сетевого проверьте сервер хранилища и порт.")


def arg_key_match(token, key):
    """Token matches a key when it equals it, or starts with it and the next character
    is not a letter — catches glued /N"user" and --password=x, while keeping
    /ClearCache distinct from /C."""
    if len(token) < len(key):
        return False
    if token[: len(key)].lower() != key.lower():
        return False
    if len(token) == len(key):
        return True
    return not token[len(key)].isalpha()


def project_extra_args(name):
    """v8args / ibcmdargs from .v8-project.json — same upward walk as v8path."""
    d = os.getcwd()
    while True:
        pf = os.path.join(d, ".v8-project.json")
        if os.path.isfile(pf):
            try:
                with open(pf, encoding="utf-8-sig") as f:
                    data = json.load(f)
                v = data.get(name)
                if v:
                    return [str(x) for x in v]
            except Exception:
                pass
            return []
        parent = os.path.dirname(d)
        if parent == d:
            return []
        d = parent


def assert_extra_args(extra, engine, hints):
    """The platform accepts only one batch operation, and a duplicate connection or
    output key fails with an opaque 1C error — reject what the skill owns itself."""
    param = "-AdditionalIbcmdArguments" if engine == "ibcmd" else "-AdditionalV8Arguments"
    owned = IBCMD_OWNED_KEYS if engine == "ibcmd" else V8_OWNED_KEYS
    for tok in extra:
        if engine == "ibcmd" and not tok.startswith("-"):
            print(
                f"Error: '{tok}' is a positional token — pass values as --key=value "
                f"({param} cannot extend the ibcmd command)",
            )
            sys.exit(1)
        if engine != "ibcmd":
            for b in V8_BATCH_KEYS:
                if arg_key_match(tok, b):
                    print(
                        f"Error: {b} is a batch command; passed via {param} it would replace "
                        f"the skill's own operation (a command line runs only its last batch command)",
                    )
                    sys.exit(1)
        for k in owned:
            if arg_key_match(tok, k):
                hint = f" (use {hints[k]})" if hints and k in hints else ""
                print(
                    f"Error: {k} is controlled by the skill and cannot be passed via {param}{hint}",
                )
                sys.exit(1)


def format_args_for_display(arglist, engine):
    """Redact values of secret-prone keys in glued, =-joined and separate forms.
    Matching here is a plain prefix (no letter rule): over-masking costs nothing,
    a leaked password does."""
    keys = IBCMD_SECRET_KEYS if engine == "ibcmd" else V8_SECRET_KEYS
    res = []
    mask_next = False
    for tok in arglist:
        if mask_next:
            res.append("***")
            mask_next = False
            continue
        hit = None
        for k in keys:
            if tok[: len(k)].lower() == k.lower():
                hit = k
                break
        if hit is None:
            res.append(tok)
        elif len(tok) == len(hit):
            res.append(tok)
            mask_next = True
        elif tok[len(hit)] == "=":
            res.append(hit + "=***")
        else:
            res.append(hit + "***")
    return res


def extract_extra_args(argv, known_opts):
    """argparse refuses values that start with '-' (every ibcmd key does), so pull the two
    escape-hatch lists out of argv by hand: after the flag, take everything up to the next
    declared skill option. Returns (remaining_argv, v8_extra, ibcmd_extra)."""
    rest, v8, ibcmd = [], [], []
    i = 0
    while i < len(argv):
        low = argv[i].lower()
        if low in ("-additionalv8arguments", "-additionalibcmdarguments"):
            target = v8 if low == "-additionalv8arguments" else ibcmd
            i += 1
            while i < len(argv) and argv[i].lower() not in known_opts:
                target.append(argv[i])
                i += 1
            continue
        rest.append(argv[i])
        i += 1
    return rest, v8, ibcmd


def resolve_extra_args(engine, v8_extra, ibcmd_extra, hints):
    """Pick the argument list for the selected engine and validate it. An explicitly
    passed parameter for the other engine is an error; the same keys coming from
    .v8-project.json simply do not apply — a project may describe both engines.

    Comma-separated elements are split apart: PowerShell's -File cannot bind an array,
    so that form is the documented one and both ports must accept it. A value containing
    a comma is not supported."""
    v8_extra = [p for tok in v8_extra for p in str(tok).split(",") if p]
    ibcmd_extra = [p for tok in ibcmd_extra for p in str(tok).split(",") if p]
    if engine == "ibcmd" and v8_extra:
        print(
            "Error: -AdditionalV8Arguments applies to 1cv8 only; the selected engine is ibcmd "
            "(use -AdditionalIbcmdArguments)",
        )
        sys.exit(1)
    if engine != "ibcmd" and ibcmd_extra:
        print(
            "Error: -AdditionalIbcmdArguments applies to ibcmd only; the selected engine is 1cv8 "
            "(use -AdditionalV8Arguments)",
        )
        sys.exit(1)
    if engine == "ibcmd":
        extra = project_extra_args("ibcmdargs") + list(ibcmd_extra)
    else:
        extra = project_extra_args("v8args") + list(v8_extra)
    if extra:
        assert_extra_args(extra, engine, hints)
    return extra


def _version_dir(p):
    """Version dir for both Windows (.../1cv8/<ver>/bin/1cv8.exe) and *nix (.../1cv8/<ver>/1cv8)."""
    parent = os.path.dirname(p)
    if os.path.basename(parent).lower() == "bin":
        parent = os.path.dirname(parent)
    return os.path.basename(parent)


def _version_key(p):
    """Numeric sort key from version dir name."""
    return [int(x) for x in re.findall(r"\d+", _version_dir(p))]


def resolve_v8path(v8path):
    """Resolve path to a 1C executable (1cv8; ibcmd only when given explicitly)."""
    if not v8path:
        v8path = _find_project_v8path()
    if not v8path:
        if os.name == "nt":
            candidates = (
                glob.glob(r"C:\Program Files\1cv8\*\bin\1cv8.exe")
                + glob.glob(r"C:\Program Files (x86)\1cv8\*\bin\1cv8.exe")
            )
        else:
            # PY-only: PS-порт на *nix не исполняется, поэтому *nix-раскладки нет в .ps1.
            candidates = glob.glob("/opt/1cv8/*/1cv8")
        if candidates:
            v8path = max(candidates, key=_version_key)
            print(f"Auto-selected platform {_version_dir(v8path)}: {v8path}")
        else:
            print("Error: 1C executable not found. Specify -V8Path")
            sys.exit(1)
    if os.path.isdir(v8path):
        # PY-only: на *nix исполняемый называется "1cv8" (без .exe); ibcmd — только явным путём.
        exe = "1cv8.exe" if os.name == "nt" else "1cv8"
        v8path = os.path.join(v8path, exe)
    if not os.path.isfile(v8path):
        print(f"Error: 1C executable not found at {v8path}")
        sys.exit(1)
    return v8path


IBCMD_NOUSER_HINT = (
    "[ibcmd] No -UserName/-Password given; the infobase may require authentication. "
    "On Windows ibcmd reads credentials from the console (stdin is ignored), so this "
    "call may block instead of failing. If it does not return promptly, abort and "
    "re-run with -UserName and -Password.\n"
)


def decode_platform_bytes(data):
    """ibcmd writes UTF-8 (checked on 8.3.24, 8.3.27, 8.5), a crashing 1cv8 may still emit
    OEM text. Decode strictly as UTF-8 and fall back to cp866 on invalid bytes — the locale
    code page (what text=True uses) mangles both."""
    if not data:
        return ""
    try:
        return data.decode("utf-8")
    except UnicodeDecodeError:
        return data.decode("cp866", errors="replace")


def assert_infobase_exists(path):
    """These skills work on a ready infobase. Saying so up front beats the platform's
    "Неверные или отсутствующие параметры соединения" after a launch."""
    if not path:
        return
    if not os.path.isfile(os.path.join(path, "1Cv8.1CD")):
        print(f"Error: information base not found at {path} (no 1Cv8.1CD)")
        sys.exit(1)


def clean_path(value, param=""):
    """Forgive what is unambiguous in a path the caller passed: surrounding whitespace,
    surrounding quotes that survived shell parsing, a trailing separator. A quote left
    inside afterwards cannot be part of a real path — reject it by name instead of letting
    1C answer with its opaque "Неверные или отсутствующие параметры соединения"."""
    if not value:
        return value
    v = value.strip()
    if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
        v = v[1:-1].strip()
    if len(v) > 3 and v[-1] in "\\/":
        v = v[:-1]
    if '"' in v:
        print(f"Error: {param or 'path'} contains a quote character: {value}")
        sys.exit(1)
    return v


def quote_if_needed(token):
    """Extra arguments come from the caller unquoted; the 1cv8 command line is joined
    verbatim, so a token with a space needs quotes of its own."""
    if token and (" " in token or "\t" in token) and '"' not in token:
        return f'"{token}"'
    return token


def run_v8(v8path, arguments):
    """Run 1cv8 in batch mode and capture its console output.

    The arguments carry their own quotes inside the value (File="C:\\a b") — that is where
    1C's parser expects them, on Windows and on *nix alike. Windows list2cmdline would
    escape those quotes, so there the command line is handed over ready-made.

    На POSIX аргументы уходят СПИСКОМ, и кавычки, нужные для склейки на Windows, стали бы
    частью значения: путь с пробелом платформа не находит («Неопределена информационная
    база»), многословный -comment теряет молча. Поэтому здесь снимается ОДИН слой
    обрамляющих кавычек. Склеенные ключи (/N"user", /ConfigurationRepositoryF"путь",
    File="…") не задеты: у них кавычки внутри токена, а не по краям.
    """
    if os.name == "nt":
        cmd = '"' + v8path + '" ' + " ".join(arguments)
    else:
        def strip_framing_quotes(a):
            # Кавычки, которыми мы обрамляем значения ради склейки на Windows, на POSIX
            # становятся ЧАСТЬЮ значения. Проверено на darwin: путь с пробелом отдельным
            # токеном даёт «Неопределена информационная база», а склеенный
            # /ConfigurationRepositoryF"путь с пробелом" — «завершилось с ошибкой»;
            # без кавычек обе формы работают.
            if len(a) > 1 and a[0] == '"' and a[-1] == '"':
                return a[1:-1]                       # "значение" отдельным токеном
            if a[0:1] == "/" and a[-1:] == '"' and '"' in a[:-1]:
                i = a.index('"')
                return a[:i] + a[i + 1:-1]           # /N"имя" -> /Nимя
            return a                                 # File="…" не трогаем: там кавычки —
                                                     # часть синтаксиса строки соединения,
                                                     # и с ними на POSIX всё работает
        cmd = [v8path] + [strip_framing_quotes(a) for a in arguments]
    r = subprocess.run(cmd, input=b"", capture_output=True)
    r.stdout = decode_platform_bytes(r.stdout)
    r.stderr = decode_platform_bytes(r.stderr)
    return r


def print_platform_output(result):
    """Print what the platform wrote to the console as its own labelled block. Silence stays
    silent: in batch mode 1cv8 reports through /Out and prints nothing here."""
    text = ((result.stdout or "") + (result.stderr or "")).rstrip()
    if not text:
        return
    limit = 65536
    if len(text) > limit:
        text = f"[... обрезано, показаны последние {limit} символов ...]\n" + text[-limit:]
    print("--- Вывод платформы ---")
    print(text)
    print("--- End ---")


def find_silent_rejections(log_text):
    """Строки лога, о которых платформа сообщает, НЕ поднимая код возврата.

    Метаданные отброшены или конфигурация нерабочая, а операция при этом «успешна».
    Возвращает подошедшие строки.

    Копия этой функции есть в каждом навыке, который читает /Out-лог загрузки (навыки
    автономны). Держать копии одинаковыми — сознательно: разошедшиеся копии сводят на нет
    весь смысл.
    """
    patterns = [
        "Неверное свойство объекта метаданных",
        "не входит в состав объекта метаданных",
        "Неизвестное имя типа",
        "Неизвестный объект метаданных",
        "Ни один из документов не является регистратором для регистра",
        "Неверное значение перечисления",
        "не может быть приведен к типу",
        # Режим совместимости выше платформы: объекты в базу не попадают, отказ приходит в
        # рантайме. Обрезано до инвариантной части — конкретная версия в сообщении меняется.
        "Для работы с конфигурацией необходима версия платформы не меньше",
    ]
    found = []
    if log_text:
        for line in log_text.splitlines():
            for pat in patterns:
                if pat in line:
                    found.append(line.strip())
                    break
    return found


def run_apply_check(exe, conn_args, extension, extra_args):
    """Постусловие применимости расширения: платформа отчитывается успехом и о расширении,
    которое не применит — отказ всплывает лениво, при первом вызове метода, записью в журнал
    регистрации.

    Запуск ОБЯЗАТЕЛЬНО отдельный. Дописать эту команду в строку операции нельзя: в одной
    командной строке DESIGNER выполняет только ПОСЛЕДНЮЮ пакетную команду, остальные молча
    отбрасывает — проверено на 8.3.24, /LoadConfigFromFiles вместе с
    /CheckCanApplyConfigurationExtensions завершились кодом 0 с пустым логом, и загрузка не
    состоялась.

    Проверку умеет только 1cv8; если навык работал через ibcmd, берём соседний файл.
    """
    exe_dir = os.path.dirname(exe)
    leaf = os.path.basename(exe)
    if leaf.lower().startswith("ibcmd"):
        v8 = os.path.join(exe_dir, "1cv8" + os.path.splitext(leaf)[1])
    else:
        v8 = exe
    if not os.path.isfile(v8):
        return {"skipped": True, "reason": f"1cv8 not found at {v8}", "exit": 0, "lines": []}
    temp_dir = tempfile.mkdtemp(prefix="apply_check_")
    try:
        a = ["DESIGNER"] + list(conn_args) + ["/CheckCanApplyConfigurationExtensions"]
        if extension:
            a += ["-Extension", f'"{extension}"']
        out_file = os.path.join(temp_dir, "check_log.txt")
        a += ["/Out", f'"{out_file}"', "/DisableStartupDialogs"]
        a += list(extra_args)
        r = run_v8(v8, a)
        lines = []
        if os.path.isfile(out_file):
            with open(out_file, encoding="utf-8-sig", errors="replace") as f:
                lines = [x.strip() for x in f.read().splitlines() if x.strip()]
        return {"skipped": False, "reason": "", "exit": r.returncode, "lines": lines}
    finally:
        shutil.rmtree(temp_dir, ignore_errors=True)


def apply_check_report(exe, conn_args, extension, extra_args):
    """Проверить и напечатать. True, если платформа расширение не применит — вызывающий решает,
    поднимать ли код возврата (строгий режим)."""
    ac = run_apply_check(exe, conn_args, extension, extra_args)
    if ac["skipped"]:
        print(f"[note] applicability check skipped: {ac['reason']}")
        return False
    if ac["exit"] != 0 or ac["lines"]:
        print("[warning] the extension is loaded, but the platform will not apply it:")
        for line in ac["lines"]:
            print(f"  {line}")
        return True
    return False


def apply_check_enabled(disabled):
    """Проверять ли применимость: -NoApplyCheck сильнее настройки проекта."""
    if disabled:
        return False
    pf = _sg_find_v8project(os.getcwd())
    if pf:
        try:
            with open(pf, encoding="utf-8-sig") as f:
                proj = json.load(f)
            if proj.get("extensionApplyCheck") is not None:
                return bool(proj.get("extensionApplyCheck"))
        except Exception:
            pass
    return True


def run_ibcmd(cmd, has_username=False, warn_no_user=True):
    """Run an ibcmd command non-interactively.

    input="" closes stdin (EOF) so ibcmd's auth prompt fast-fails instead of hanging.
    On Windows without -UserName ibcmd reads the console directly and may still block —
    that residual case is flagged via IBCMD_NOUSER_HINT (model-facing).
    """
    if warn_no_user and os.name == "nt" and not has_username:
        sys.stdout.write(IBCMD_NOUSER_HINT)
        sys.stderr.flush()
    r = subprocess.run(cmd, input=b"", capture_output=True)
    r.stdout = decode_platform_bytes(r.stdout)
    r.stderr = decode_platform_bytes(r.stderr)
    return r


def describe_exit(code):
    """Annotate an abnormal process exit code so a crash isn't reported as a bare number.
    Batch 1C in a broken/headless environment (no GUI session, no license) can crash mid-run
    instead of returning a clean error, possibly leaving the infobase locked or half-mutated."""
    if code is None:
        return ""
    win = {
        3221225477: "0xC0000005 (access violation)", -1073741819: "0xC0000005 (access violation)",
        3221225781: "0xC0000135 (missing DLL)", -1073741515: "0xC0000135 (missing DLL)",
        3221226505: "0xC0000409 (stack overrun)", -1073740791: "0xC0000409 (stack overrun)",
    }
    if code in win:
        return f" — abnormal termination, exception {win[code]}; the infobase may be left in an inconsistent state; verify it before retrying"
    if -64 <= code < 0:
        try:
            import signal
            name = signal.Signals(-code).name
        except (ValueError, AttributeError):
            name = f"signal {-code}"
        return (f" — process terminated by {name} (abnormal termination, not a normal exit); "
                "the infobase may be left in an inconsistent state; verify it before retrying")
    return ""


def _redact(text, *secrets):
    """Redact literal secret values (password, user) from a display string —
    precise, never touches lookalike paths."""
    for s in secrets:
        if s:
            text = text.replace(s, "***")
    return text


def main():
    sys.stdout.reconfigure(encoding="utf-8")
    sys.stderr.reconfigure(encoding="utf-8")
    parser = argparse.ArgumentParser(
        description="Load 1C configuration from XML files",
        allow_abbrev=False,
    )
    parser.add_argument("-V8Path", default="", help="Path to 1cv8.exe or its bin directory")
    parser.add_argument("-InfoBasePath", default="", help="Path to file infobase")
    parser.add_argument("-InfoBaseServer", default="", help="1C server (for server infobase)")
    parser.add_argument("-InfoBaseRef", default="", help="Infobase name on server")
    parser.add_argument("-UserName", default="", help="1C user name")
    parser.add_argument("-Password", default="", help="1C user password")
    parser.add_argument("-RepositoryPath", default="")
    parser.add_argument("-RepositoryUser", default="")
    parser.add_argument("-RepositoryPassword", default="")
    parser.add_argument("-ConfigDir", required=True, help="Directory with XML configuration sources")
    parser.add_argument(
        "-Mode",
        default="",
        choices=["", "Full", "Partial"],
        help="Load mode (default: Full)",
    )
    parser.add_argument("-Files", default="", help="Comma-separated relative file paths (for Partial mode)")
    parser.add_argument("-ListFile", default="", help="Path to file list (alternative to -Files, for Partial mode)")
    parser.add_argument("-Extension", default="", help="Extension name to load")
    parser.add_argument("-AllExtensions", action="store_true", help="Load all extensions")
    parser.add_argument(
        "-Format",
        default="Hierarchical",
        choices=["Hierarchical", "Plain"],
        help="File format (default: Hierarchical)",
    )
    parser.add_argument("-UpdateDB", action="store_true", help="Also update database configuration after load")
    parser.add_argument(
        "-StrictLog",
        action="store_true",
        help="Treat silent rejection warnings in the log as errors (elevate exit code to 1)",
    )
    # Пропустить проверку применимости расширения после загрузки.
    parser.add_argument("-NoApplyCheck", action="store_true")
    parser.add_argument("-AdditionalV8Arguments", nargs="*", default=[],
                        help="Extra 1cv8 arguments, e.g. /UseHwLicenses+")
    parser.add_argument("-AdditionalIbcmdArguments", nargs="*", default=[],
                        help="Extra ibcmd arguments in --key=value form")
    known_opts = {s.lower() for a in parser._actions for s in a.option_strings}
    argv, v8_extra, ibcmd_extra = extract_extra_args(sys.argv[1:], known_opts)
    args = ci_parse_args(parser, argv)

    args.V8Path = clean_path(args.V8Path, "-V8Path")
    args.InfoBasePath = clean_path(args.InfoBasePath, "-InfoBasePath")
    assert_infobase_exists(args.InfoBasePath)
    args.ConfigDir = clean_path(args.ConfigDir, "-ConfigDir")
    args.ListFile = clean_path(args.ListFile, "-ListFile")

    # --- Resolve V8Path ---
    v8path = resolve_v8path(args.V8Path)

    engine = "ibcmd" if os.path.basename(v8path).lower().startswith("ibcmd") else "1cv8"

    # --- Resolve additional arguments for the selected engine ---
    arg_hints = {
        "/F": "-InfoBasePath",
        "/S": "-InfoBaseServer + -InfoBaseRef",
        "/N": "-UserName",
        "/P": "-Password",
        "--db-path": "-InfoBasePath",
        "--user": "-UserName",
        "--password": "-Password",
    }
    extra_args = resolve_extra_args(engine, v8_extra, ibcmd_extra, arg_hints)

    # --- Validate connection ---
    if engine == "ibcmd":
        if not args.InfoBasePath:
            print("Error: ibcmd supports file infobases only (use -InfoBasePath)")
            sys.exit(1)
    elif not args.InfoBasePath and (not args.InfoBaseServer or not args.InfoBaseRef):
        print("Error: specify -InfoBasePath or -InfoBaseServer + -InfoBaseRef")
        sys.exit(1)

    # --- Validate config dir ---
    if not os.path.exists(args.ConfigDir):
        print(f"Error: config directory not found: {args.ConfigDir}")
        sys.exit(1)

    # --- Validate Partial mode ---
    # Перечислены файлы — загрузка частичная. Иначе список молча игнорировался бы, а умолчание
    # Full заменило бы всю конфигурацию базы.
    if args.Files or args.ListFile:
        if args.Mode == "Full":
            print("[note] перечислены файлы — загружаются только они; -Mode Full не применён")
        args.Mode = "Partial"
    if not args.Mode:
        args.Mode = "Full"
    if args.Mode == "Partial" and not args.Files and not args.ListFile:
        print("Error: -Files or -ListFile required for Partial mode")
        sys.exit(1)

    # --- ibcmd branch (file infobase only; hierarchical full-directory import) ---
    if engine == "ibcmd":
        if args.Format == "Plain":
            print("Error: ibcmd config import supports hierarchical format only (use -Format Hierarchical or 1cv8)")
            sys.exit(1)
        if args.AllExtensions:
            arguments = ["infobase", "config", "import", "all-extensions", args.ConfigDir, f"--db-path={args.InfoBasePath}"]
        elif args.Mode == "Partial":
            # partial: import specific files (relative to ConfigDir)
            if args.ListFile:
                if not os.path.isfile(args.ListFile):
                    print(f"Error: list file not found: {args.ListFile}")
                    sys.exit(1)
                with open(args.ListFile, encoding="utf-8-sig") as f:
                    file_list = [ln.strip() for ln in f if ln.strip()]
            elif args.Files:
                file_list = [p.strip() for p in args.Files.split(",") if p.strip()]
            else:
                file_list = []
            if not file_list:
                print("Error: -Files or -ListFile required for partial import")
                sys.exit(1)
            arguments = ["infobase", "config", "import", "files"] + file_list
            arguments += [f"--base-dir={args.ConfigDir}", f"--db-path={args.InfoBasePath}"]
            if args.Extension:
                arguments.append(f"--extension={args.Extension}")
        else:
            arguments = ["infobase", "config", "import", f"--db-path={args.InfoBasePath}"]
            if args.Extension:
                arguments.append(f"--extension={args.Extension}")
            arguments.append(args.ConfigDir)
        ib_data = tempfile.mkdtemp(prefix="ibcmd_data_")
        atexit.register(shutil.rmtree, ib_data, ignore_errors=True)
        if args.UserName:
            arguments.append(f"--user={args.UserName}")
        if args.Password:
            arguments.append(f"--password={args.Password}")
        arguments.append(f"--data={ib_data}")
        arguments.extend(extra_args)
        print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName)}")
        result = run_ibcmd([v8path] + arguments, bool(args.UserName))
        if result.returncode != 0:
            print(f"Error loading configuration from files (code: {result.returncode}){describe_exit(result.returncode)}")
            sys.exit(result.returncode)
        print(f"Configuration loaded successfully from: {args.ConfigDir}")
        exit_code = 0
        if args.UpdateDB:
            apply_args = ["infobase", "config", "apply", f"--db-path={args.InfoBasePath}", "--force"]
            if args.UserName:
                apply_args.append(f"--user={args.UserName}")
            if args.Password:
                apply_args.append(f"--password={args.Password}")
            apply_args.append(f"--data={ib_data}")
            apply_args.extend(extra_args)
            print(f"Running: ibcmd {_redact(' '.join(format_args_for_display(apply_args, engine)), args.Password, args.UserName)}")
            ar = run_ibcmd([v8path] + apply_args, bool(args.UserName))
            exit_code = ar.returncode
            if exit_code == 0:
                print("Database configuration updated successfully")
            else:
                print(f"Error updating database configuration (code: {exit_code}){describe_exit(exit_code)}")
            print_platform_output(ar)
        # Проверку применимости умеет только 1cv8 — соединение для неё собираем в его форме.
        if (exit_code == 0 and (args.Extension or args.AllExtensions)
                and apply_check_enabled(args.NoApplyCheck)):
            ac_conn = ["/F", f'"{args.InfoBasePath}"']
            if args.UserName:
                ac_conn.append(f'/N"{args.UserName}"')
            if args.Password:
                ac_conn.append(f'/P"{args.Password}"')
            if apply_check_report(v8path, ac_conn, args.Extension, []) and args.StrictLog:
                exit_code = 1

        sys.exit(exit_code)

    # --- Temp dir ---
    temp_dir = os.path.join(tempfile.gettempdir(), f"db_load_xml_{random.randint(0, 999999)}")
    os.makedirs(temp_dir, exist_ok=True)

    try:
        # --- Build arguments ---
        # Аргументы соединения собираем отдельно: тем же набором пойдёт проверка применимости.
        conn_args = []

        if args.InfoBaseServer and args.InfoBaseRef:
            conn_args += ["/S", f'"{args.InfoBaseServer}/{args.InfoBaseRef}"']
        else:
            conn_args += ["/F", f'"{args.InfoBasePath}"']

        if args.UserName:
            conn_args.append(f'/N"{args.UserName}"')
        if args.Password:
            conn_args.append(f'/P"{args.Password}"')

        # База под хранилищем не примет НИ ОДНОЙ операции конфигуратора без этих реквизитов, а для
        # базы вне хранилища они безвредны — поэтому подставляем всегда, когда они известны.
        repo = resolve_repository_settings(args)
        conn_args.extend(repository_args(repo))

        arguments = ["DESIGNER"] + conn_args

        arguments += ["/LoadConfigFromFiles", f'"{args.ConfigDir}"']

        if args.Mode == "Full":
            print("Executing full configuration load...")
        else:
            print("Executing partial configuration load...")

            # Build list file
            if args.ListFile:
                if not os.path.isfile(args.ListFile):
                    print(f"Error: list file not found: {args.ListFile}")
                    sys.exit(1)
                with open(args.ListFile, encoding="utf-8-sig") as f:
                    raw_list = [ln.strip() for ln in f if ln.strip()]
            else:
                raw_list = [f.strip() for f in args.Files.split(",") if f.strip()]

            # Support-state service files are NOT partially loadable — exclude with a hint.
            support_re = re.compile(r"ParentConfigurations\.bin$|(^|[\\/])ConfigDumpInfo\.xml$")
            support_files = [x for x in raw_list if support_re.search(x)]
            file_list = [x for x in raw_list if not support_re.search(x)]
            if support_files:
                print("[ВНИМАНИЕ] Служебные файлы состояния поддержки исключены из частичной загрузки (частично не грузятся):")
                for sf in support_files:
                    print(f"  - {sf}")
                print("  Смена состояния поддержки применяется только полной загрузкой: -Mode Full.")
            if not file_list:
                print("Error: после исключения служебных файлов поддержки загружать нечего. Для смены поддержки используйте -Mode Full.")
                sys.exit(1)
            generated_list_file = os.path.join(temp_dir, "load_list.txt")
            with open(generated_list_file, "w", encoding="utf-8-sig") as f:
                f.write("\n".join(file_list))
            print(f"Files to load: {len(file_list)}")
            for fl in file_list:
                print(f"  {fl}")

            arguments += ["-listFile", f'"{generated_list_file}"']
            arguments.append("-partial")
            arguments.append("-updateConfigDumpInfo")

        arguments += ["-Format", args.Format]

        # --- Extensions ---
        if args.Extension:
            arguments += ["-Extension", f'"{args.Extension}"']
        elif args.AllExtensions:
            arguments.append("-AllExtensions")

        # --- UpdateDB ---
        if args.UpdateDB:
            arguments.append("/UpdateDBCfg")

        # --- Output ---
        out_file = os.path.join(temp_dir, "load_log.txt")
        arguments += ["/Out", f'"{out_file}"']
        arguments.append("/DisableStartupDialogs")
        arguments.extend(quote_if_needed(a) for a in extra_args)

        # --- Execute ---
        print(f"Running: 1cv8.exe {_redact(' '.join(format_args_for_display(arguments, engine)), args.Password, args.UserName, repo['password'])}")
        result = run_v8(v8path, arguments)
        exit_code = result.returncode

        # --- Read log ---
        log_content = ""
        if os.path.isfile(out_file):
            try:
                with open(out_file, "r", encoding="utf-8-sig") as f:
                    log_content = f.read()
            except Exception:
                log_content = ""

        # --- Scan log for silent rejections ---
        # Platform often writes load-time rejections into /Out but exits with code 0.
        # These patterns flag cases where metadata was dropped or rejected silently.
        silent_failures = find_silent_rejections(log_content)

        # --- Result ---
        # Default: mirror platform's verdict via exit code. Log content (including any
        # rejection warnings) is always printed to stdout for visibility. With -StrictLog,
        # elevate exit code to 1 when rejection patterns are found even if platform said 0.
        if exit_code == 0:
            print("Load completed successfully")
        else:
            print(f"Error loading configuration (code: {exit_code}){describe_exit(exit_code)}")

        if log_content:
            print("--- Log ---")
            print(log_content)
            print("--- End ---")

        print_platform_output(result)
        write_repository_hints(log_content)
        # Причину не называем: строки лога печатаются следом и говорят за себя, а класс проблемы
        # разный — от отброшенного свойства до нерабочей на этой платформе конфигурации. Подсказку
        # про -StrictLog не даём: загрузка уже выполнена, повторять её ради того же текста незачем.
        # Поток — stdout, как у PS1-порта: предупреждение относится к содержимому загрузки, а не к
        # отказу навыка, и при code 0 остаётся предупреждением. Раньше py писал его в stderr —
        # наблюдаемое поведение портов расходилось, и один кейс не мог проверить оба.
        if silent_failures:
            print(
                f"[warning] platform reported success, but the log contains "
                f"{len(silent_failures)} problem(s):"
            )
            for f in silent_failures:
                print(f"  {f}")
            if args.StrictLog and exit_code == 0:
                exit_code = 1

        # Расширение могло загрузиться «успешно» и остаться неприменимым — спрашиваем платформу.
        if (exit_code == 0 and (args.Extension or args.AllExtensions)
                and apply_check_enabled(args.NoApplyCheck)):
            if apply_check_report(v8path, conn_args, args.Extension, extra_args) and args.StrictLog:
                exit_code = 1

        sys.exit(exit_code)

    finally:
        if os.path.exists(temp_dir):
            shutil.rmtree(temp_dir, ignore_errors=True)


if __name__ == "__main__":
    main()
