#!/usr/bin/env python3
"""L0.5 — гейты на конкретные грабли, на которых уже обжигались.

Каждый гейт здесь появился из реального инцидента, а не из общих
соображений. Работает на лексере строковых литералов BSL: платформа и
BSL LS дают сухое «ожидается КонецФункции», а тут — что именно удвоить.

Использование: gates.py <каталог выгрузки>
Код возврата: 0 — блокирующих нет, 1 — есть.
"""
from __future__ import annotations

import sys
import unicodedata
from pathlib import Path

Finding = dict


def lex_strings(text: str) -> tuple[list[tuple[int, int]], list[Finding]]:
    """Разбирает BSL по строковым литералам. Возвращает (маска кода, находки).

    Маска кода — список (строка, столбец) позиций, которые НЕ внутри
    литерала и не в комментарии; нужна гейту баланса, чтобы не считать
    ключевые слова внутри текстов запросов.
    """
    findings: list[Finding] = []
    lines = text.split("\n")
    in_str = False
    str_start_line = 0
    code_mask: list[tuple[int, int]] = []
    for ln, line in enumerate(lines, 1):
        i = 0
        # Внутри многострочного литерала продолжением считается строка,
        # начинающаяся с '|' (правило BSL) — ИЛИ строка-комментарий (маркер
        # правки вида «//+ Бурдуков ...» посреди текста запроса: легальная и
        # частая практика этого разработчика, платформа и BSL LS компилируют
        # её чисто — проверено на реальном ОбщийАкт_Новый, 23 ложных
        # срабатывания до этого фикса). Комментарий целиком пропускаем, не
        # сканируя на кавычки — это не часть литерала.
        stripped = line.lstrip()
        if in_str and stripped.startswith("//"):
            continue
        if in_str and line.strip() and not stripped.startswith("|"):
            findings.append({
                "severity": "block", "line": str_start_line,
                "gate": "unclosed_string",
                "message": f"строковый литерал, открытый на строке "
                           f"{str_start_line}, не закрыт: строка {ln} не "
                           f"начинается с '|'",
            })
            in_str = False
        while i < len(line):
            ch = line[i]
            if in_str:
                if ch == '"':
                    if i + 1 < len(line) and line[i + 1] == '"':
                        i += 2       # удвоенная кавычка — это данные
                        continue
                    in_str = False
                    # Литерал закрылся посреди продолжения текста запроса —
                    # почти всегда неудвоенная кавычка внутри запроса
                    # (реальный дефект DEVACC-780).
                    if line.lstrip().startswith("|"):
                        rest = line[i + 1:].strip()
                        if rest and rest[0] not in ';,)+':
                            findings.append({
                                "severity": "block", "line": ln,
                                "gate": "quote_in_query",
                                "message": f'одиночная " внутри текста '
                                           f'запроса рвёт литерал — удвой её '
                                           f'("") или используй «ёлочки»: '
                                           f'…{line.strip()[:70]}',
                            })
                i += 1
                continue
            if ch == '/' and i + 1 < len(line) and line[i + 1] == '/':
                break                # комментарий до конца строки
            if ch == '"':
                in_str = True
                str_start_line = ln
                i += 1
                continue
            code_mask.append((ln, i))
            i += 1
    if in_str:
        findings.append({
            "severity": "block", "line": str_start_line,
            "gate": "unclosed_string",
            "message": f"строковый литерал со строки {str_start_line} не "
                       f"закрыт до конца файла",
        })
    return code_mask, findings


def check_balance(text: str, mask: set[tuple[int, int]]) -> list[Finding]:
    """Баланс Функция/КонецФункции и Процедура/КонецПроцедуры вне литералов."""
    pairs = [("функция", "конецфункции"), ("процедура", "конецпроцедуры")]
    counts = {k: 0 for pair in pairs for k in pair}
    for ln, line in enumerate(text.split("\n"), 1):
        low = line.lower()
        pos = 0
        while pos < len(low):
            if not low[pos].isalpha():
                pos += 1
                continue
            end = pos
            while end < len(low) and (low[end].isalpha()):
                end += 1
            word = low[pos:end]
            if word in counts and (ln, pos) in mask:
                counts[word] += 1
            pos = end
    out: list[Finding] = []
    for start, stop in pairs:
        if counts[start] != counts[stop]:
            out.append({
                "severity": "block", "line": 0, "gate": "balance",
                "message": f"{start.capitalize()}: {counts[start]}, "
                           f"{stop.capitalize()}: {counts[stop]} — не сходится",
            })
    return out


def check_file(path: Path) -> list[Finding]:
    raw = path.read_bytes()
    findings: list[Finding] = []
    if not raw.startswith(b"\xef\xbb\xbf"):
        findings.append({
            "severity": "warn", "line": 1, "gate": "bom",
            "message": "нет UTF-8 BOM — платформа пишет модули с BOM, "
                       "проверь, чем сохранял",
        })
    text = raw.decode("utf-8-sig", errors="replace")
    mask, found = lex_strings(text)
    findings += found
    findings += check_balance(text, set(mask))
    return findings


def check_names(root: Path) -> list[Finding]:
    """Имена файлов должны быть в NFC: macOS пишет NFD, и корневой XML потом
    не находит свой каталог — 1С отвечает 422 «Файл объекта не существует»."""
    out: list[Finding] = []
    for p in root.rglob("*"):
        name = p.name
        if unicodedata.normalize("NFC", name) != name:
            out.append({
                "severity": "warn", "line": 0, "gate": "nfc",
                "message": f"имя в NFD, при упаковке нормализуй в NFC: {name}",
            })
    return out


def run(root: Path) -> dict:
    results: dict[str, list[Finding]] = {}
    for bsl in sorted(root.rglob("*.bsl")):
        f = check_file(bsl)
        if f:
            results[str(bsl.relative_to(root))] = f
    name_findings = check_names(root)
    if name_findings:
        results["(имена файлов)"] = name_findings
    blocking = sum(1 for v in results.values()
                   for f in v if f["severity"] == "block")
    return {"results": results, "blocking": blocking}


def main() -> int:
    root = Path(sys.argv[1]).resolve()
    res = run(root)
    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['line']}" if f["line"] else ""
            print(f"  [{tag}] {file}{where} {f['gate']}: {f['message']}")
    return 1 if res["blocking"] else 0


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