#!/usr/bin/env python3
"""Детерминированный гейт ревью.

Прогоняет всё, у чего есть объективный оракул, и печатает сводку. В отличие от
`task test`/`task lint` не останавливается на первом отказе — ревьюверу нужна
полная картина, а не первая упавшая команда.

Использование: scripts/gate.py [<base-rev>]
  base-rev — база для диффа. По умолчанию merge-base с master; на самом
  master — HEAD~1.

Шаги выбираются по изменённым файлам: правка документации не гоняет тесты и
линтеры. Пропущенный шаг всегда виден в сводке с причиной — молча пропущенная
проверка даёт ложное ощущение проверенности, а это ровно то, ради чего гейт и
заводился.

Коды возврата: 0 — красных шагов нет, 1 — есть.
Статусы: OK, FAIL (краснит гейт), WARN (виден, но не блокирует), SKIP.
"""

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

OUT_DIR = Path("tmp/gate")

# Скрипты трёх плагинов av-dev. Каждый проверяет своё и ни один не тянет
# остальных: без своего шага дрейф соответствующего каталога перестаёт ловиться
# молча.
#   docs.py     — раскладка docs/ против канона (av-dev-docs:canon)
#   tasks.py    — согласованность индексов каталога задач (av-dev-tasks:tasks)
#   openspec.py — форма openspec/config.yaml (av-dev-code:openspec)
# Путь берётся из каталога маркетплейса (он без хеша версии, в отличие от
# cache/), переопределяется переменной: переустановка плагина не должна
# требовать правки гейта. Если скрипта нет — шаг краснеет, а не пропускается:
# молча пропущенная проверка даёт ложное ощущение проверенности.
MARKETPLACE = Path.home() / ".claude/plugins/marketplaces/av-dev-skills"
DOCS_PY_DEFAULT = MARKETPLACE / "av-dev-docs/skills/canon/scripts/docs.py"
TASKS_PY_DEFAULT = MARKETPLACE / "av-dev-tasks/skills/tasks/scripts/tasks.py"
OPENSPEC_PY_DEFAULT = MARKETPLACE / "av-dev-code/skills/openspec/scripts/openspec.py"

OK, FAIL, WARN, SKIP = "OK", "FAIL", "WARN", "SKIP"

summary: list[tuple[str, str, str]] = []


def record(status: str, name: str, hint: str = "") -> None:
    summary.append((status, name, hint))


def git(*args: str) -> str:
    return subprocess.run(
        ["git", *args], capture_output=True, text=True, check=True
    ).stdout.strip()


def base_rev(argv: list[str]) -> str:
    if len(argv) > 1:
        return argv[1]
    on_master = git("rev-parse", "--abbrev-ref", "HEAD") == "master"
    has_master = subprocess.run(
        ["git", "rev-parse", "--verify", "-q", "master"], capture_output=True
    ).returncode == 0
    if has_master and not on_master:
        return git("merge-base", "HEAD", "master")
    return "HEAD~1"


def changed_files(base: str) -> list[str]:
    """Изменённые файлы: закоммиченное относительно базы + рабочее дерево + новые.

    Берём объединение намеренно: гейт гоняют и до коммита, и после, и лишний
    прогон шага дешевле пропущенного.
    """
    files = set(git("diff", "--name-only", base).splitlines())
    files |= set(git("ls-files", "--others", "--exclude-standard").splitlines())
    return sorted(f for f in files if f)


def run(name: str, cmd: list[str], env: dict[str, str] | None = None) -> bool:
    """Выполняет шаг, складывает вывод в tmp/gate/<name>.log."""
    log = OUT_DIR / f"{name}.log"
    full_env = {**os.environ, **(env or {})}
    proc = subprocess.run(cmd, capture_output=True, text=True, env=full_env)
    log.write_text(proc.stdout + proc.stderr, encoding="utf-8")
    return proc.returncode == 0


def step(name: str, cmd: list[str], hint: str = "", env: dict[str, str] | None = None) -> bool:
    ok = run(name, cmd, env)
    record(OK, name) if ok else record(
        FAIL, name, f"{hint + ' → ' if hint else ''}{OUT_DIR}/{name}.log"
    )
    return ok


def plugin_step(
    name: str, var: str, default: Path, plugin: str, args: list[str], hint: str
) -> None:
    """Шаг, который гоняет скрипт плагина av-dev.

    Отсутствие скрипта краснеет, а не пропускается: молча пропущенная проверка
    неотличима от пройденной. Коды выхода у всех трёх скриптов общие: 0 сошлось,
    1 дрейф, 2 ошибка употребления, 3 окружение, 4 внутренний сбой. Различать 1
    и остальные обязательно — дрейф чинится правкой проекта, прочее нет.
    """
    script = Path(os.environ.get(var) or default)
    if not script.is_file():
        record(FAIL, name, f"{script.name} не найден: {script} — переустанови"
                           f" плагин {plugin} или задай путь переменной {var}")
        return
    log = OUT_DIR / f"{name}.log"
    proc = subprocess.run(
        ["python3", str(script), *args], capture_output=True, text=True,
    )
    log.write_text(proc.stdout + proc.stderr, encoding="utf-8")
    if proc.returncode == 0:
        record(OK, name)
    elif proc.returncode == 1:
        record(FAIL, name, f"{hint} → {log}")
    else:
        record(FAIL, name, f"{script.name} не отработал (код {proc.returncode}) → {log}")


def main() -> int:
    OUT_DIR.mkdir(parents=True, exist_ok=True)
    base = base_rev(sys.argv)
    changed = changed_files(base)

    go_changed = any(f.endswith(".go") for f in changed)
    deps_changed = any(f in ("go.mod", "go.sum") for f in changed)
    migrations_changed = any(f.startswith("internal/store/migrations/") for f in changed)
    code_changed = go_changed or deps_changed
    no_code = "нет изменений в .go/go.mod — код не трогали"

    print(f"== gate: база диффа {base}, изменённых файлов {len(changed)} ==")
    if not code_changed:
        print("   код не менялся — go-шаги пропускаются, см. сводку")

    # --- Компиляция и статика ---
    if code_changed:
        step("build", ["go", "build", "./..."], "не собирается")
        step("vet", ["go", "vet", "./..."])
        if shutil.which("golangci-lint"):
            step("lint", ["golangci-lint", "run"])
        else:
            record(SKIP, "lint", "golangci-lint не установлен (task setup)")

        unformatted = [
            f for f in subprocess.run(
                ["gofmt", "-l", "."], capture_output=True, text=True
            ).stdout.split()
            if not f.startswith("tmp/")
        ]
        if unformatted:
            record(FAIL, "gofmt", "не отформатировано: " + " ".join(unformatted))
        else:
            record(OK, "gofmt")
    else:
        for name in ("build", "vet", "lint", "gofmt"):
            record(SKIP, name, no_code)

    # --- Тесты ---
    if code_changed:
        tests_ok = step("test", ["go", "test", "-count=1", "./..."])
        # Флаки: повторный прогон. Тест, который иногда зелёный, не является
        # оракулом ни для чего, поэтому расхождение — находка не ниже major.
        # Стабильно красный набор флаки не проверяем: его разбирает шаг test.
        if tests_ok:
            if run("test-repeat", ["go", "test", "-count=1", "./..."]):
                record(OK, "flaky")
            else:
                record(FAIL, "flaky", "прогон 1 зелёный, прогон 2 красный — флаки-тест")
        else:
            record(SKIP, "flaky", "набор красный — сперва чиним test")
    else:
        record(SKIP, "test", no_code)
        record(SKIP, "flaky", no_code)

    # --- Гонки ---
    if not code_changed:
        record(SKIP, "race", no_code)
    elif shutil.which("gcc"):
        step(
            "race",
            ["go", "test", "-race", "-count=1", "./..."],
            "гонка либо сборка тестов — смотри лог",
            env={"CGO_ENABLED": "1"},
        )
    else:
        record(SKIP, "race", "нет gcc: -race требует cgo. Гонки НЕ проверены")

    # --- Покрытие изменённых строк ---
    if not go_changed:
        record(SKIP, "diff-coverage", "нет изменений в .go")
    elif run("cover", ["go", "test", "-count=1", f"-coverprofile={OUT_DIR}/cover.out", "./..."]):
        if run("diff-coverage", ["python3", "scripts/diff-coverage.py", f"{OUT_DIR}/cover.out", base]):
            record(OK, "diff-coverage", (OUT_DIR / "diff-coverage.log").read_text().splitlines()[0])
        else:
            record(SKIP, "diff-coverage", f"не удалось посчитать → {OUT_DIR}/diff-coverage.log")
    else:
        record(SKIP, "diff-coverage", f"прогон с профилем не собрался → {OUT_DIR}/cover.log")

    # --- Миграции на чистой схеме ---
    if migrations_changed or go_changed:
        step(
            "migrations",
            ["go", "test", "-count=1", "-run", "Migration", "./internal/store/..."],
            "миграции не накатываются с нуля",
        )
    else:
        record(SKIP, "migrations", "миграции и код не менялись")

    # --- Канон документации ---
    # Раскладка docs/ против канона av-dev, битые ссылки, версия канона,
    # нетронутые плейсхолдеры и две сверки с кодом — включая «миграция изменена,
    # а database.md нет» (отдельного шага er-schema поэтому больше нет).
    # База диффа обязательна: без неё сверка миграций со схемой не гоняется.
    plugin_step(
        "canon", "DOCS_PY", DOCS_PY_DEFAULT, "av-dev-docs",
        ["check", "--dir", ".", "--base", base],
        "раскладка docs/ разошлась с каноном",
    )

    # --- Каталог задач ---
    # Согласованность индексов с файлами items/: бездомная запись, строка без
    # файла, порядок секций роадмапа, сырьё не в конце секции. docs.py этого не
    # смотрит — каталог принадлежит другому плагину.
    plugin_step(
        "tasks", "TASKS_PY", TASKS_PY_DEFAULT, "av-dev-tasks",
        ["check", "--dir", "tasks"],
        "индексы задач разошлись с items/",
    )

    # --- Форма настройки OpenSpec ---
    # Незаменённый пример в openspec/config.yaml читается как настроенный файл,
    # а работает как пустой. Ловит это только свой шаг: docs.py про config.yaml
    # молчит с канона 10.
    plugin_step(
        "openspec", "OPENSPEC_PY", OPENSPEC_PY_DEFAULT, "av-dev-code",
        ["check", "--dir", "."],
        "openspec/config.yaml разошёлся с канонической формой",
    )

    # --- Секреты ---
    # Гоняем всегда: секрет утекает из любого файла, не только из кода.
    if shutil.which("gitleaks"):
        step("gitleaks", ["gitleaks", "git", "--no-banner"], "возможен секрет в истории/индексе")
    else:
        record(SKIP, "gitleaks", "gitleaks не установлен")

    # --- Уязвимости зависимостей ---
    # Не блокирует: находка тут — состояние зависимостей и тулчейна, а не диффа.
    # Уязвимость, приехавшую с новой зависимостью change, разбирает агент по
    # трассам вызовов.
    if not code_changed:
        record(SKIP, "govulncheck", no_code)
    elif shutil.which("govulncheck"):
        if run("govulncheck", ["govulncheck", "./..."]):
            record(OK, "govulncheck")
        else:
            # Ненулевой код возврата означает и найденные уязвимости, и отказ
            # самого инструмента (чаще всего код не собирается). Различаем: без
            # этого «уязвимостей: 0» выглядит как проверка, которой не было.
            found = (OUT_DIR / "govulncheck.log").read_text().count("\nVulnerability #")
            if found:
                record(WARN, "govulncheck",
                       f"достижимо из кода уязвимостей: {found} → {OUT_DIR}/govulncheck.log")
            else:
                record(SKIP, "govulncheck",
                       f"не отработал (обычно код не собирается) → {OUT_DIR}/govulncheck.log")
    else:
        record(SKIP, "govulncheck", "govulncheck не установлен (task setup)")

    # --- Сводка ---
    print("\n== сводка ==")
    for status, name, hint in summary:
        print(f"{status:<5} {name:<14} {hint}")

    if any(s == FAIL for s, _, _ in summary):
        print("\nГЕЙТ КРАСНЫЙ — опиниативные проходы не запускаются")
        return 1
    print("\nгейт зелёный (шаги WARN и SKIP см. в сводке — они идут в находки"
          " и в границы покрытия)")
    return 0


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