#!/usr/bin/env python3
"""L0.5+ — сверка имён метаданных из текстов запросов с конфигурацией.

Закрывает дыру, которую не видит ни BSL LS, ни /CheckModules: текст запроса
для платформы — обычная строка, поэтому «Документ.РеализацияТоварвоУслуг» или
переименованный реквизит (СчетУчетаРасчетовСАгентом → СчетРасчетов) всплывают
только в рантайме у пользователя.

Имя таблицы, которого нет в конфигурации — блокирует.
Имя поля — предупреждение (псевдонимы и вложенные запросы разбираются
эвристически, ложные срабатывания возможны).

Использование:
  meta_check.py index [<каталог НоваяКонфа>]   — построить/обновить индекс
  meta_check.py check <каталог выгрузки>       — проверить
"""
from __future__ import annotations

import json
import os
import re
import sys
import xml.etree.ElementTree as ET
from pathlib import Path

CACHE = Path(os.environ.get(
    "ONEC_TB_CACHE", str(Path.home() / ".cache/1c-testbench")))
INDEX = CACHE / "meta_index.json"
DEFAULT_CONF = Path(os.environ.get(
    "ONEC_CONF_DIR",
    str(Path.home() / "Downloads/ОбработкиЭкспорт/НоваяКонфа")))

# каталог выгрузки → имя типа в языке запросов
COLLECTIONS = {
    "Documents": "Документ",
    "Catalogs": "Справочник",
    "InformationRegisters": "РегистрСведений",
    "AccumulationRegisters": "РегистрНакопления",
    "AccountingRegisters": "РегистрБухгалтерии",
    "CalculationRegisters": "РегистрРасчета",
    "ChartsOfAccounts": "ПланСчетов",
    "ChartsOfCharacteristicTypes": "ПланВидовХарактеристик",
    "ChartsOfCalculationTypes": "ПланВидовРасчета",
    "Enums": "Перечисление",
    "DocumentJournals": "ЖурналДокументов",
    "ExchangePlans": "ПланОбмена",
    "BusinessProcesses": "БизнесПроцесс",
    "Tasks": "Задача",
    "Constants": "Константа",
    "Sequences": "Последовательность",
    "FilterCriteria": "КритерийОтбора",
    "Reports": "Отчет",
    "DataProcessors": "Обработка",
}

# виртуальные таблицы регистров — не ТЧ, проверять состав не нужно
VIRTUAL = {
    "СрезПоследних", "СрезПервых", "Остатки", "Обороты",
    "ОстаткиИОбороты", "ДвиженияССубконто", "ОстаткиИОборотыДтКт",
    "ОборотыДтКт", "Субконто", "ФактическийПериодДействия",
    "ДанныеГрафика", "БазаДанныеГрафика", "Изменения", "Задачи",
}

STANDARD = {
    "Документ": {"Ссылка", "ВерсияДанных", "ПометкаУдаления", "Дата",
                 "Номер", "Проведен", "МоментВремени", "Представление"},
    "Справочник": {"Ссылка", "ВерсияДанных", "ПометкаУдаления", "Код",
                   "Наименование", "Родитель", "Владелец", "ЭтоГруппа",
                   "Предопределенный", "ИмяПредопределенныхДанных",
                   "Представление"},
    "ПланСчетов": {"Ссылка", "ВерсияДанных", "ПометкаУдаления", "Код",
                   "Наименование", "Родитель", "Порядок", "Вид", "Забалансовый",
                   "Предопределенный", "Представление"},
    "Перечисление": {"Ссылка", "Порядок", "Представление"},
    "РегистрБухгалтерии": {"Регистратор", "Период", "Активность", "СчетДт",
                           "СчетКт", "НомерЗаписи", "ВидДвижения"},
}


def _local(tag: str) -> str:
    return tag.rsplit("}", 1)[-1]


def parse_object(path: Path) -> dict | None:
    try:
        root = ET.parse(path).getroot()
    except ET.ParseError:
        return None
    obj = next(iter(root), None)
    if obj is None:
        return None
    name, attrs, tabs = None, set(), {}
    for node in obj:
        tag = _local(node.tag)
        if tag == "Properties":
            for p in node:
                if _local(p.tag) == "Name" and p.text:
                    name = p.text.strip()
        elif tag == "ChildObjects":
            for child in node:
                ctag = _local(child.tag)
                cname = None
                for p in child:
                    if _local(p.tag) == "Properties":
                        for q in p:
                            if _local(q.tag) == "Name" and q.text:
                                cname = q.text.strip()
                if not cname:
                    continue
                if ctag in ("Attribute", "Dimension", "Resource",
                            "AccountingFlag", "ExtDimensionAccountingFlag"):
                    attrs.add(cname)
                elif ctag == "TabularSection":
                    ts_attrs = set()
                    for p in child:
                        if _local(p.tag) == "ChildObjects":
                            for a in p:
                                for q in a:
                                    if _local(q.tag) == "Properties":
                                        for r in q:
                                            if (_local(r.tag) == "Name"
                                                    and r.text):
                                                ts_attrs.add(r.text.strip())
                    tabs[cname] = sorted(ts_attrs)
    if not name:
        return None
    return {"name": name, "attributes": sorted(attrs), "tabular": tabs}


def build_index(conf: Path) -> dict:
    index: dict[str, dict] = {}
    for folder, kind in COLLECTIONS.items():
        d = conf / folder
        if not d.is_dir():
            continue
        bucket: dict[str, dict] = {}
        for xml in sorted(d.glob("*.xml")):
            if xml.stat().st_size > 20 * 1024 * 1024:
                continue
            info = parse_object(xml)
            if info:
                bucket[info["name"]] = {"attributes": info["attributes"],
                                        "tabular": info["tabular"]}
        index[kind] = bucket
        print(f"  {kind}: {len(bucket)}", file=sys.stderr)
    CACHE.mkdir(parents=True, exist_ok=True)
    INDEX.write_text(json.dumps(index, ensure_ascii=False), encoding="utf-8")
    return index


def load_index() -> dict:
    if not INDEX.exists():
        raise SystemExit("индекса нет — сначала: meta_check.py index")
    return json.loads(INDEX.read_text(encoding="utf-8"))


QUERY_HINT = re.compile(r"\b(ВЫБРАТЬ|SELECT)\b", re.I)
TABLE_RE = re.compile(
    r"\b(" + "|".join(sorted(set(COLLECTIONS.values()), key=len, reverse=True))
    + r")\.([A-Za-zА-Яа-яЁё_][\w]*)(?:\.([A-Za-zА-Яа-яЁё_][\w]*))?")
ALIAS_RE = re.compile(
    r"\b(" + "|".join(sorted(set(COLLECTIONS.values()), key=len, reverse=True))
    + r")\.([\w]+)(?:\.([\w]+))?\s+(?:КАК|AS)\s+([\w]+)", re.I)
FIELD_RE = re.compile(r"\b([A-Za-zА-Яа-яЁё_][\w]*)\.([A-Za-zА-Яа-яЁё_][\w]*)\b")


def extract_queries(text: str) -> list[tuple[int, str]]:
    """Многострочные литералы, похожие на текст запроса: (номер строки, текст)."""
    out, lines = [], text.split("\n")
    buf, start = [], 0
    for ln, line in enumerate(lines, 1):
        stripped = line.strip()
        if buf:
            if stripped.startswith("|"):
                buf.append(stripped[1:])
                continue
            joined = "\n".join(buf)
            if QUERY_HINT.search(joined):
                out.append((start, joined))
            buf = []
        if '"' in line and QUERY_HINT.search(line):
            buf = [line[line.index('"') + 1:]]
            start = ln
    if buf:
        joined = "\n".join(buf)
        if QUERY_HINT.search(joined):
            out.append((start, joined))
    return out


VALUE_RE = re.compile(r"\b(?:ЗНАЧЕНИЕ|VALUE)\s*\(([^)]*)\)", re.I)


def value_spans(query: str) -> list[tuple[int, int]]:
    """Границы конструкций ЗНАЧЕНИЕ(...): внутри них третий компонент — это
    предопределённое значение или ПустаяСсылка, а не табличная часть."""
    return [m.span(1) for m in VALUE_RE.finditer(query)]


def inside(pos: int, spans: list[tuple[int, int]]) -> bool:
    return any(a <= pos < b for a, b in spans)


def check_query(query: str, index: dict) -> list[dict]:
    findings = []
    spans = value_spans(query)
    aliases: dict[str, tuple[str, str, str | None]] = {}
    for m in ALIAS_RE.finditer(query):
        kind, name, sub, alias = m.groups()
        aliases[alias.lower()] = (kind, name, sub)
    for m in TABLE_RE.finditer(query):
        kind, name, sub = m.groups()
        bucket = index.get(kind, {})
        if name not in bucket:
            findings.append({
                "severity": "block", "kind": "table",
                "message": f"{kind}.{name} — такого объекта нет в конфигурации",
            })
            continue
        if (sub and not inside(m.start(3), spans) and sub not in VIRTUAL
                and sub not in bucket[name]["tabular"]):
            if sub not in bucket[name]["attributes"]:
                findings.append({
                    "severity": "warn", "kind": "tabular",
                    "message": f"{kind}.{name}.{sub} — нет ни такой табличной "
                               f"части, ни реквизита",
                })
    for m in FIELD_RE.finditer(query):
        alias, field = m.group(1), m.group(2)
        key = alias.lower()
        if key not in aliases:
            continue
        kind, name, sub = aliases[key]
        bucket = index.get(kind, {}).get(name)
        if not bucket:
            continue
        if sub in VIRTUAL:
            continue
        known = set(bucket["attributes"]) | set(bucket["tabular"])
        known |= STANDARD.get(kind, {"Ссылка", "ПометкаУдаления"})
        if sub and sub in bucket["tabular"]:
            known = set(bucket["tabular"][sub]) | {"Ссылка", "НомерСтроки"}
        # BSL/язык запросов регистронезависимы (Т.ссылка == Т.Ссылка) —
        # сверяем по нижнему регистру, иначе живой код с "ссылка" в нижнем
        # регистре (реальный случай ОбщийАкт_Новый) даёт ложный варнинг.
        known_lower = {k.lower() for k in known}
        if field.lower() not in known_lower:
            findings.append({
                "severity": "warn", "kind": "field",
                "message": f"{alias}.{field} — у {kind}.{name}"
                           f"{'.' + sub if sub else ''} такого поля не видно "
                           f"(проверь переименования)",
            })
    return findings


DCS_SCHEMA_TAG = "DataCompositionSchema"


def find_dcs_queries(xml_path: Path) -> list[tuple[str, str]]:
    """(имя набора данных, текст запроса) для каждого <query> схемы СКД.

    /CheckModules схему СКД НЕ проверяет вообще (проверено эмпирически:
    опечатка в имени документа внутри <query> проходит чисто) — запрос
    здесь для платформы просто текст в XML-настройках, не BSL-модуль.
    Набор может содержать НЕСКОЛЬКО <query> (UNION-объединение).
    """
    try:
        root = ET.parse(xml_path).getroot()
    except ET.ParseError:
        return []
    if _local(root.tag) != DCS_SCHEMA_TAG:
        return []
    out = []
    for ds in root:
        if _local(ds.tag) != "dataSet":
            continue
        name = None
        for p in ds:
            if _local(p.tag) == "name":
                name = p.text
        for q in ds:
            if _local(q.tag) == "query" and q.text:
                out.append((name or "?", q.text))
    return out


def run(root: Path) -> dict:
    index = load_index()
    results: dict[str, list[dict]] = {}
    for bsl in sorted(root.rglob("*.bsl")):
        text = bsl.read_text(encoding="utf-8-sig", errors="replace")
        hits = []
        for line_no, query in extract_queries(text):
            for f in check_query(query, index):
                hits.append({**f, "line": line_no})
        if hits:
            seen, uniq = set(), []
            for h in hits:
                key = (h["message"], h["line"])
                if key not in seen:
                    seen.add(key)
                    uniq.append(h)
            results[str(bsl.relative_to(root))] = uniq
    for xml in sorted(root.rglob("*.xml")):
        if xml.stat().st_size > 5 * 1024 * 1024:
            continue
        hits = []
        for ds_name, query in find_dcs_queries(xml):
            for f in check_query(query, index):
                hits.append({**f, "line": 0, "dataset": ds_name})
        if hits:
            seen, uniq = set(), []
            for h in hits:
                key = (h["message"], h["dataset"])
                if key not in seen:
                    seen.add(key)
                    uniq.append(h)
            results[str(xml.relative_to(root)) + " (СКД)"] = uniq
    blocking = sum(1 for v in results.values()
                   for f in v if f["severity"] == "block")
    return {"results": results, "blocking": blocking}


def main() -> int:
    if len(sys.argv) < 2:
        raise SystemExit(__doc__)
    if sys.argv[1] == "index":
        conf = Path(sys.argv[2]) if len(sys.argv) > 2 else DEFAULT_CONF
        print(f"индексирую {conf}", file=sys.stderr)
        idx = build_index(conf)
        print(f"индекс: {INDEX} ({sum(len(v) for v in idx.values())} объектов)")
        return 0
    res = run(Path(sys.argv[2]).resolve())
    if not res["results"]:
        print("L0.5+ OK — имена метаданных в запросах сходятся")
        return 0
    print(f"L0.5+ {'FAIL' if res['blocking'] else 'OK (предупреждения)'} — "
          f"блокирующих: {res['blocking']}")
    for file, findings in res["results"].items():
        for f in findings:
            tag = "БЛОК" if f["severity"] == "block" else "пред"
            where = (f"набор данных «{f['dataset']}»" if "dataset" in f
                     else f"запрос со строки {f['line']}")
            print(f"  [{tag}] {file}: {where} — {f['message']}")
    return 1 if res["blocking"] else 0


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