#!/usr/bin/env python3
"""Регрессионный корпус стенда: каждый кейс — реальная грабля.

Стенд проверяет обработки, а корпус проверяет стенд: берём заведомо рабочую
обработку, вносим ровно один известный дефект и требуем, чтобы сработал
именно тот слой, который обязан. Плюс контрольный кейс без дефекта — чтобы
поймать ложные срабатывания.

  run_corpus.py                 полный прогон (с платформой, ~5 мин)
  run_corpus.py --fast          без платформы (~2 мин)
  run_corpus.py --case quote_object
"""
from __future__ import annotations

import json
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path

HERE = Path(__file__).resolve().parent
TOOLS = HERE.parent / "tools"
TB = TOOLS / "tb.py"
PY = sys.executable or "/usr/bin/python3"

# Эталоны: рабочие файлы из прода, проходящие все слои чисто.
BASE_EPF = Path(os.environ.get(
    "ONEC_TB_BASE_EPF",
    str(Path.home() / "Downloads/ОбработкиЭкспорт/КомиссияКлиентов.epf")))
BASE_ERF = Path(os.environ.get(
    "ONEC_TB_BASE_ERF",
    str(Path.home() / "Desktop/ПродажиПоКонтрагентам.erf")))
CACHE_ROOT = Path(os.environ.get(
    "ONEC_TB_CACHE", str(Path.home() / ".cache/1c-testbench")))
CACHE = CACHE_ROOT / "corpus_base"
CACHE_ERF = CACHE_ROOT / "corpus_base_erf"

OBJ_MODULE = "src/Ext/ObjectModule.bsl"
FORM_MODULE = "src/Forms/Форма/Ext/Form/Module.bsl"
DCS_TEMPLATE = ("src/Templates/ОсновнаяСхемаКомпоновкиДанных/"
                "Ext/Template.xml")


def append(root: Path, rel: str, code: str) -> None:
    p = root / rel
    p.write_text(p.read_text(encoding="utf-8-sig") + code, encoding="utf-8-sig")


# ---- дефекты ----

QUERY_WITH_QUOTE = '''
Функция аКорпусКавычкаВЗапросе() Экспорт
	Запрос = Новый Запрос;
	Запрос.Текст = "ВЫБРАТЬ
	               |	Т.Ссылка КАК Ссылка
	               |ИЗ
	               |	Документ.РеализацияТоваровУслуг КАК Т
	               |// только "промежуточные" строки
	               |ГДЕ
	               |	Т.Проведен";
	Возврат Запрос.Выполнить();
КонецФункции
'''


def case_quote_object(root: Path) -> None:
    """DEVACC-780: одиночная " в тексте запроса модуля объекта."""
    append(root, OBJ_MODULE, QUERY_WITH_QUOTE)


def case_quote_form(root: Path) -> None:
    """То же в модуле формы — компилируется в другом контексте."""
    append(root, FORM_MODULE, "\n&НаСервере" + QUERY_WITH_QUOTE)


def case_unbalanced(root: Path) -> None:
    """Потерянный КонецФункции."""
    append(root, OBJ_MODULE, '''
Функция аКорпусБезКонца() Экспорт
	Возврат 1;
''')


def case_typo_table(root: Path) -> None:
    """Опечатка в имени документа: платформа этого не видит — текст запроса
    для неё просто строка."""
    append(root, OBJ_MODULE, '''
Функция аКорпусОпечаткаВТаблице() Экспорт
	Запрос = Новый Запрос;
	Запрос.Текст = "ВЫБРАТЬ
	               |	Т.Ссылка КАК Ссылка
	               |ИЗ
	               |	Документ.РеализацияТоварвоУслуг КАК Т";
	Возврат Запрос.Выполнить();
КонецФункции
''')


def case_renamed_attribute(root: Path) -> None:
    """Реквизит переименован в конфигурации (СчетУчетаРасчетовСАгентом →
    СчетРасчетов) — старый код по нему больше не работает."""
    append(root, OBJ_MODULE, '''
Функция аКорпусПереименованныйРеквизит() Экспорт
	Запрос = Новый Запрос;
	Запрос.Текст = "ВЫБРАТЬ
	               |	Т.СчетУчетаРасчетовСАгентом КАК Счет
	               |ИЗ
	               |	Документ.РеализацияТоваровУслуг КАК Т";
	Возврат Запрос.Выполнить();
КонецФункции
''')


def case_extra_arg(root: Path) -> None:
    """Лишний фактический параметр — ловит только платформа."""
    append(root, OBJ_MODULE, '''
Функция аКорпусЛишнийАргумент() Экспорт
	Возврат аКорпусЛишнийАргумент(1, 2, 3);
КонецФункции
''')


def case_comment_marker_in_query(root: Path) -> None:
    """Маркер правки //+ Бурдуков.../ //- ВНУТРИ многострочного текста
    запроса, между |-продолжениями — легальная и частая практика этого
    разработчика (реальный кейс из ОбщийАкт_Новый_13.01.2026.epf, 2026-07-30:
    23 ложных срабатывания gates.py до фикса — комментарий трактовался как
    «строка не начинается с |» и рвал состояние лексера)."""
    append(root, OBJ_MODULE, '''
Функция аКорпусКомментарийВЗапросе() Экспорт
	Запрос = Новый Запрос;
	Запрос.Текст = "ВЫБРАТЬ
	               |	Т.Ссылка КАК Ссылка,
	//+ Бурдуков С.Ю 18.12.2025 ACCOUNTING-
	               |	Т.Дата КАК Дата,
	//- Бурдуков С.Ю 18.12.2025 ACCOUNTING-
	               |	Т.Номер КАК Номер
	               |ИЗ
	               |	Документ.РеализацияТоваровУслуг КАК Т";
	Возврат Запрос.Выполнить();
КонецФункции
''')


def case_clean(root: Path) -> None:
    """Контроль: дефекта нет, все слои обязаны молчать."""


def case_mxl_typo(root: Path) -> None:
    """Опечатка в имени области макета — платформа `ПолучитьОбласть("X")`
    компилирует синтаксически валидным независимо от того, существует ли
    область; ловит только mxl_check."""
    append(root, FORM_MODULE, '''
Процедура аКорпусОпечаткаВОбласти() Экспорт
	Обработка = РеквизитФормыВЗначение("Объект");
	МакетТест = Обработка.ПолучитьМакет("МакетДляПервойФормы");
	ОбластьТест = МакетТест.ПолучитьОбласть("ШапкаОпечатка");
КонецПроцедуры
''')


def case_mxl_case_insensitive_ok(root: Path) -> None:
    """Контроль: BSL регистронезависим — вызов ПолучитьОбласть("шапка")
    (строчными) для реально существующей области "Шапка" НЕ дефект (реальный
    случай из ОбщийАкт_Новый_13.01.2026.epf, 2026-07-30: область "подвал"
    в XML макета vs "Подвал" в коде — ложный блок до фикса)."""
    append(root, FORM_MODULE, '''
Процедура аКорпусРегистрОбласти() Экспорт
	Обработка = РеквизитФормыВЗначение("Объект");
	МакетТест2 = Обработка.ПолучитьМакет("МакетДляПервойФормы");
	ОбластьТест2 = МакетТест2.ПолучитьОбласть("шапка");
КонецПроцедуры
''')


def case_mxl_commented_ok(root: Path) -> None:
    """Контроль: закомментированный вызов ПолучитьОбласть с несуществующим
    именем — не дефект, код не исполняется (реальный случай из
    ОбщийАкт_Новый: 2 закомментированные строки ложно ловились до фикса)."""
    append(root, FORM_MODULE, '''
Процедура аКорпусЗакомментированнаяОбласть() Экспорт
	Обработка = РеквизитФормыВЗначение("Объект");
	МакетТест3 = Обработка.ПолучитьМакет("МакетДляПервойФормы");
	// ОбластьТест3 = МакетТест3.ПолучитьОбласть("НесуществующаяОбласть");
КонецПроцедуры
''')


def case_dcs_typo(root: Path) -> None:
    """Опечатка в имени таблицы ВНУТРИ схемы СКД (не BSL) — /CheckModules
    это не проверяет вообще (проверено эмпирически на реальном отчёте:
    опечатка в имени документа внутри <query> проходит платформой чисто),
    ловит только meta_check.find_dcs_queries."""
    p = root / DCS_TEMPLATE
    t = p.read_text(encoding="utf-8-sig")
    marker = "Документ.ЗаказПокупателя2 КАК ЗаказПокупателя"
    assert marker in t, "эталон СКД изменился — обнови кейс"
    p.write_text(t.replace(marker, "Документ.ЗаказПокупателяОпечатка КАК "
                                    "ЗаказПокупателя"), encoding="utf-8-sig")


def case_clean_erf(root: Path) -> None:
    """Контроль для ветки .erf: дефекта нет, все слои обязаны молчать."""


CASES = {
    # имя: (мутация, ожидания по слоям, база)
    "quote_object": (case_quote_object,
                     {"gates": "block", "lint": "block", "platform": "fail"},
                     "epf"),
    "quote_form": (case_quote_form,
                   {"gates": "block", "lint": "block", "platform": "fail"},
                   "epf"),
    "unbalanced": (case_unbalanced,
                   {"gates": "block", "platform": "fail"}, "epf"),
    "typo_table": (case_typo_table, {"meta": "block"}, "epf"),
    "renamed_attribute": (case_renamed_attribute, {"meta": "warn"}, "epf"),
    "extra_arg": (case_extra_arg, {"platform": "fail"}, "epf"),
    "clean": (case_clean, {}, "epf"),
    "comment_marker_in_query": (case_comment_marker_in_query, {}, "epf"),
    "mxl_typo": (case_mxl_typo, {"mxl": "block"}, "epf"),
    "mxl_case_ok": (case_mxl_case_insensitive_ok, {}, "epf"),
    "mxl_comment_ok": (case_mxl_commented_ok, {}, "epf"),
    "dcs_typo": (case_dcs_typo, {"meta": "block"}, "erf"),
    "clean_erf": (case_clean_erf, {}, "erf"),
}


def base_dump(base_epf: Path, cache: Path) -> Path:
    """XML-выгрузка эталона (кэшируется — Конфигуратор небыстрый).

    Для .erf с Desktop реальный файл на 2026-07-30 сам ссылается на
    устаревшее имя документа (ЗаказПокупателя вместо переименованного в
    конфигурации ЗаказПокупателя2) — переименовываем в кэше на актуальное,
    иначе база для корпуса была бы «грязной» ещё до вставки тестового
    дефекта. Сам факт бага в реальном файле — находка стенда, а не сбой.
    """
    if (cache / ".dumped").exists():
        return cache
    if not base_epf.exists():
        raise SystemExit(f"нет эталонного файла {base_epf}")
    work = Path(tempfile.mkdtemp(prefix="corpus_", dir="/tmp"))
    sys.path.insert(0, str(TOOLS))
    import tb  # noqa: PLC0415
    src = tb.dump_epf(base_epf, work)
    if base_epf == BASE_ERF:
        tpl = src / DCS_TEMPLATE
        text = tpl.read_text(encoding="utf-8-sig")
        fixed = text.replace("Документ.ЗаказПокупателя КАК ЗаказПокупателя",
                             "Документ.ЗаказПокупателя2 КАК ЗаказПокупателя")
        if fixed == text:
            raise SystemExit("реальный .erf больше не содержит известный "
                             "баг ЗаказПокупателя — обнови fixup в base_dump")
        tpl.write_text(fixed, encoding="utf-8-sig")
    cache.parent.mkdir(parents=True, exist_ok=True)
    if cache.exists():
        shutil.rmtree(cache)
    shutil.copytree(src, cache)
    (cache / ".dumped").touch()
    shutil.rmtree(work, ignore_errors=True)
    return cache


def layer_verdict(report: dict, layer: str) -> str:
    data = report["layers"].get(layer, {})
    if layer == "platform":
        return data.get("status", "skip")
    if data.get("blocking"):
        return "block"
    if layer == "lint" and data.get("blocking"):
        return "block"
    if layer == "lint":
        return "block" if data.get("blocking") else "ok"
    if data.get("results"):
        return "warn"
    return "ok"


def run_case(name: str, base: Path, fast: bool) -> tuple[bool | None, str]:
    mutate, expect, _base_key = CASES[name]
    if fast and expect and set(expect) == {"platform"}:
        return None, "пропуск: ловится только платформой"
    work = Path(tempfile.mkdtemp(prefix=f"case_{name}_", dir="/tmp"))
    src = work / "src_case"
    shutil.copytree(base, src)
    mutate(src)
    out = work / "report.json"
    cmd = [PY, str(TB), "check", str(src), "--json", str(out), "--no-render"]
    if fast:
        cmd.append("--fast")
    proc = subprocess.run(cmd, capture_output=True, text=True, check=False)
    if not out.exists():
        shutil.rmtree(work, ignore_errors=True)
        return False, f"стенд не дал отчёт: {proc.stdout[-400:]}"
    report = json.loads(out.read_text(encoding="utf-8"))
    # lint хранит блокирующие списком, приведём к общему виду
    lint = report["layers"].get("lint", {})
    report["layers"]["lint"] = {"blocking": len(lint.get("blocking", []))}
    problems = []
    for layer, want in expect.items():
        if fast and layer == "platform":
            continue
        got = layer_verdict(report, layer)
        if want == "block" and got != "block":
            problems.append(f"{layer}: ждал block, получил {got}")
        elif want == "fail" and got != "fail":
            problems.append(f"{layer}: ждал fail, получил {got}")
        elif want == "warn" and got not in ("warn", "block"):
            problems.append(f"{layer}: ждал предупреждение, получил {got}")
    if name in ("clean", "clean_erf", "comment_marker_in_query",
                "mxl_case_ok", "mxl_comment_ok"):
        if report.get("blocking"):
            problems.append(f"ложное срабатывание: blocking="
                            f"{report['blocking']}")
    elif not report.get("blocking") and any(
            v in ("block", "fail") for v in expect.values()):
        problems.append("дефект не заблокирован")
    shutil.rmtree(work, ignore_errors=True)
    return (not problems), "; ".join(problems) or "ок"


def main() -> int:
    fast = "--fast" in sys.argv
    only = None
    if "--case" in sys.argv:
        only = sys.argv[sys.argv.index("--case") + 1]
    print(f"эталоны: {BASE_EPF.name}, {BASE_ERF.name}"
          f"{'  (режим --fast, без платформы)' if fast else ''}")
    bases = {"epf": base_dump(BASE_EPF, CACHE),
             "erf": base_dump(BASE_ERF, CACHE_ERF)}
    failed = 0
    for name in CASES:
        if only and name != only:
            continue
        _, _, base_key = CASES[name]
        ok, detail = run_case(name, bases[base_key], fast)
        mark = "SKIP" if ok is None else ("PASS" if ok else "FAIL")
        print(f"  {mark}  {name:20s} {detail}")
        failed += 1 if ok is False else 0
    print(f"\n{'все кейсы прошли' if not failed else f'провалено: {failed}'}")
    return 1 if failed else 0


if __name__ == "__main__":
    sys.exit(main())
