#!/usr/bin/env python3
"""bootstrap_bundle.py — доводка Bundled-инсталляции до рабочего конвейера (ORCH-103).

Один запуск поверх `deploy/bundled/docker-compose.yml` доводит свежеподнятый стек
(орк + watchdog + Gitea + Plane CE) до рабочего состояния: preflight → секреты →
up + готовность → init Gitea (полностью автоматом) → init Plane (честные
manual-step чекпоинты с верификацией) → онбординг sandbox-проекта строго
кирпичом ``scripts/onboard_project.py`` → git-доступ агентов (HTTP token-remote)
→ сборка runtime-конфига орка (корневые ``.env`` / ``.env.watchdog``) → health.

Режимы (ADR-001 D5, паттерн ORCH-009):
  plan    — дефолт; ноль мутаций: печать плана + read-only preflight-диагностика.
  apply   — полный прогон; step-движок check→ensure (повторный запуск = каскад
            skip; «resume» после manual-step = просто повторный запуск).
  verify  — read-only пост-проверка (health/queue/metrics + onboard verify).

Exit-коды (контракт TRZ FR-2): 0 — успех; 2 — остановка на manual-step или
незавершённое предусловие; 1 — ошибка.

Гарантии (NFR-3 / D5 / D9):
  * python stdlib-only; модули платформы не импортируются (канон-знания — только
    субпроцессами кирпичей gen_secrets.py / onboard_project.py, AC-7);
  * значения секретов НИКОГДА не печатаются (только имена ключей/пути файлов);
  * delete-операций НЕТ ВООБЩЕ: teardown — только документированная процедура
    docs/deployment/BUNDLED_SETUP.md §13 (ADR-001 D9);
  * существующие секреты не перетираются без явного ``--force-secrets``
    (использовать только ДО первого подъёма стека: уже инициализированные
    Plane/Gitea новых паролей сами не подхватят);
  * скрипт говорит только с локальным docker целевого хоста.

Запуск — из корня чекаута репо orchestrator на целевом хосте:
    python3 scripts/bootstrap_bundle.py            # план + диагностика
    python3 scripts/bootstrap_bundle.py apply      # полный прогон
    python3 scripts/bootstrap_bundle.py verify     # read-only пост-проверка
"""

import argparse
import getpass
import json
import os
import secrets
import shutil
import socket
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
import uuid

REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BUNDLE_DIR = os.path.join(REPO_ROOT, "deploy", "bundled")
BUNDLE_COMPOSE = os.path.join(BUNDLE_DIR, "docker-compose.yml")
BUNDLE_ENV_EXAMPLE = os.path.join(BUNDLE_DIR, ".env.example")
BUNDLE_ENV = os.path.join(BUNDLE_DIR, ".env")
ROOT_ENV_EXAMPLE = os.path.join(REPO_ROOT, ".env.example")
ROOT_ENV = os.path.join(REPO_ROOT, ".env")
WATCHDOG_ENV_EXAMPLE = os.path.join(REPO_ROOT, ".env.watchdog.example")
WATCHDOG_ENV = os.path.join(REPO_ROOT, ".env.watchdog")
GEN_SECRETS = os.path.join(REPO_ROOT, "scripts", "gen_secrets.py")
ONBOARD = os.path.join(REPO_ROOT, "scripts", "onboard_project.py")
REQUIREMENTS = os.path.join(REPO_ROOT, "requirements.txt")
VENV_DIR = os.path.join(REPO_ROOT, ".venv")
VENV_PY = os.path.join(VENV_DIR, "bin", "python")

DOC = "docs/deployment/BUNDLED_SETUP.md"

# Узнаваемый префикс томов/контейнеров инсталляции (compose project name, D1).
PROJECT = "orchestrator-bundle"
ORCH_CONTAINER = "orchestrator-bundle-orchestrator-1"

# Машинные in-network URL (D4): сервис-DNS bundle-сети, не хост.
WEBHOOK_PLANE_URL = "http://orchestrator:8500/webhook/plane"
WEBHOOK_GITEA_URL = "http://orchestrator:8500/webhook/gitea"
GITEA_INTERNAL_URL = "http://gitea:3000"
PLANE_INTERNAL_URL = "http://proxy"

EXIT_OK = 0
EXIT_MANUAL = 2
EXIT_ERROR = 1

# Минимумы хоста (синхронизированы с BUNDLED_SETUP §2; пороги preflight, TR-1).
MIN_RAM_GB = 8
MIN_DISK_GB = 40
MIN_CPUS = 4

# Тайм-ауты ожидания готовности (D5 шаг 3): миграции Plane — самые долгие.
READY_TIMEOUT_S = 180
PLANE_READY_TIMEOUT_S = 600

# Bundle-внутренние креды (upstream-имена, D2/FR-3) — генерирует bootstrap.
BUNDLE_SECRET_KEYS = (
    "POSTGRES_PASSWORD",
    "SECRET_KEY",
    "RABBITMQ_DEFAULT_PASS",
    "MINIO_ROOT_PASSWORD",
    "GITEA_ADMIN_PASSWORD",
)

# Обязательные НЕсекретные ключи bundle-конфига (preflight, D5 шаг 1).
REQUIRED_BUNDLE_KEYS = (
    "BUNDLE_PUBLIC_HOST",
    "BUNDLE_ORCH_PORT",
    "BUNDLE_PLANE_PORT",
    "BUNDLE_GITEA_HTTP_PORT",
    "ORCH_RUN_UID",
    "ORCH_RUN_GID",
    "ORCH_DOCKER_GID",
    "ORCH_AGENT_HOME_DIR",
    "GITEA_ADMIN_USERNAME",
)

# Webhook-секреты орка — выпускает ТОЛЬКО кирпич gen_secrets.py (AC-7).
WEBHOOK_SECRET_KEYS = ("ORCH_PLANE_WEBHOOK_SECRET", "ORCH_GITEA_WEBHOOK_SECRET")

# Sandbox-проект первого smoke (онбордится строго onboard_project.py, BR-6).
SANDBOX_DEFAULTS = {
    "name": "Sandbox",
    "repo": "sandbox",
    "prefix": "SBX",
    "stack": "python",
    "test_cmd": "pytest -q",
    "prod_port": "8600",
    "staging_port": "8601",
}


class ManualStop(Exception):
    """Остановка на manual-step / незавершённом предусловии → exit 2."""


class BootstrapError(Exception):
    """Невосстановимая ошибка шага → exit 1."""


def log(msg: str) -> None:
    """Печать строки прогресса. Значения секретов сюда НЕ передаются (NFR-3)."""
    print(msg, flush=True)


# --------------------------------------------------------------------------- #
# Чистые функции (unit-тесты — tests/test_bootstrap_script.py, TC-08)
# --------------------------------------------------------------------------- #

def parse_env(text: str) -> dict:
    """``KEY=value``-строки текста → словарь (комментарии/пустые — мимо)."""
    out: dict = {}
    for line in text.splitlines():
        line = line.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        key, value = line.split("=", 1)
        out[key.strip()] = value.strip()
    return out


def render_env(example_text: str, overrides: dict) -> str:
    """Рендер env-файла от канона-example: ``KEY=`` строки получают значения
    overrides (та же строка, комментарии сохранены); ключи overrides, которых
    в каноне нет, дописываются управляемым блоком в конец."""
    used: set = set()
    lines: list = []
    for line in example_text.splitlines():
        stripped = line.strip()
        if stripped and not stripped.startswith("#") and "=" in stripped:
            key = stripped.split("=", 1)[0].strip()
            if key in overrides:
                lines.append(f"{key}={overrides[key]}")
                used.add(key)
                continue
        lines.append(line)
    extra = [k for k in overrides if k not in used]
    if extra:
        lines.append("")
        lines.append("# --- bootstrap_bundle.py (ORCH-103): управляемые ключи ---")
        for key in extra:
            lines.append(f"{key}={overrides[key]}")
    return "\n".join(lines) + "\n"


def merge_missing_secrets(existing: dict, keys: tuple = BUNDLE_SECRET_KEYS,
                          force: bool = False, gen=None) -> dict:
    """Новые значения ТОЛЬКО для пустых/отсутствующих секрет-ключей (AC-8:
    существующие не перетираются; ``force=True`` — явная регенерация всех)."""
    gen = gen or (lambda key: secrets.token_hex(32 if key == "SECRET_KEY" else 16))
    fresh: dict = {}
    for key in keys:
        if force or not existing.get(key, ""):
            fresh[key] = gen(key)
    return fresh


def preflight_verdict(facts: dict) -> tuple:
    """Чистый вердикт preflight (BR-7): ``(blockers, warnings, resume)``.

    resume=True — на хосте уже есть тома/контейнеры с префиксом проекта:
    не «грязь», а инициализированная инсталляция → ensure-режим (AC-8);
    противоречивое состояние (есть тома, но нет конфига) — блокер.
    """
    blockers: list = []
    warnings: list = []
    resume = bool(facts.get("leftovers"))

    if not facts.get("docker"):
        blockers.append("docker не найден — установите Docker Engine (BUNDLED_SETUP §3)")
    if not facts.get("compose"):
        blockers.append("docker compose v2 не найден (BUNDLED_SETUP §3)")
    if not facts.get("env_exists"):
        if resume:
            blockers.append(
                "противоречивое состояние: тома/контейнеры orchestrator-bundle уже "
                "есть, а deploy/bundled/.env отсутствует — восстановите конфиг "
                "или выполните полный сброс (BUNDLED_SETUP §13)"
            )
        else:
            blockers.append(
                "deploy/bundled/.env отсутствует — создайте: "
                "cp deploy/bundled/.env.example deploy/bundled/.env (BUNDLED_SETUP §5)"
            )
    for key in facts.get("missing_keys", []):
        blockers.append(f"deploy/bundled/.env: обязательный ключ {key} пуст")
    if not resume:
        for port in facts.get("busy_ports", []):
            blockers.append(
                f"порт {port} уже занят на хосте — освободите его или смените "
                f"BUNDLE_*-порт в deploy/bundled/.env (BUNDLED_SETUP §2)"
            )
    ram = facts.get("ram_gb")
    if ram is not None and ram < MIN_RAM_GB:
        blockers.append(
            f"RAM {ram:.1f} GB < минимума {MIN_RAM_GB} GB (Plane ≈ 14 контейнеров, "
            f"BUNDLED_SETUP §2)"
        )
    disk = facts.get("disk_gb")
    if disk is not None and disk < MIN_DISK_GB:
        blockers.append(f"свободный диск {disk:.0f} GB < минимума {MIN_DISK_GB} GB")
    cpus = facts.get("cpus")
    if cpus is not None and cpus < MIN_CPUS:
        warnings.append(f"CPU {cpus} < рекомендуемых {MIN_CPUS} vCPU — стек будет медленным")
    if not facts.get("python3", True):
        blockers.append("python3/venv недоступны — нужны для onboard-кирпича (TR-9)")
    if not facts.get("claude_cli"):
        warnings.append(
            "Claude CLI/креды не найдены на хосте — стек поднимется, но конвейер "
            "без LLM не поедет (BUNDLED_SETUP §8)"
        )
    return blockers, warnings, resume


def build_plan() -> list:
    """Нормативный план apply (нумерация — TRZ FR-2; механика — ADR-001 D5)."""
    return [
        ("preflight", "fail-fast проверки хоста ДО любых мутаций (BR-7)"),
        ("secrets", "новые секреты инсталляции: gen_secrets.py + bundle-креды (FR-3)"),
        ("up", "подъём bundle-compose + ожидание готовности (миграции Plane/Gitea)"),
        ("init-gitea", "админ-бот + API-токен через `gitea admin ...` (полностью автоматом)"),
        ("init-plane", "instance-setup/workspace/API-токен — manual-step с верификацией"),
        ("plane-webhook", "workspace-webhook Plane → орк (ensure либо manual-step + проверка)"),
        ("onboard", "sandbox-проект строго через onboard_project.py apply+verify (BR-6)"),
        ("agent-git", "git-доступ агентов: клон sandbox-репо token-remote в /repos (D8)"),
        ("orch-env", "сборка корневых .env/.env.watchdog + пересоздание орка/watchdog"),
        ("health", "GET /health, /queue, /metrics + итоговая сводка PASS/FAIL"),
    ]


def build_arg_parser() -> argparse.ArgumentParser:
    """CLI: режимы plan (дефолт) / apply / verify + параметры sandbox."""
    parser = argparse.ArgumentParser(
        description="Bootstrap Bundled-инсталляции (ORCH-103). Канон — "
                    f"{DOC}."
    )
    parser.add_argument(
        "mode", nargs="?", default="plan", choices=("plan", "apply", "verify"),
        help="plan — дефолт, ноль мутаций; apply — прогон; verify — пост-проверка",
    )
    parser.add_argument(
        "--force-secrets", action="store_true",
        help="регенерировать СУЩЕСТВУЮЩИЕ bundle-креды (только ДО первого up!)",
    )
    parser.add_argument("--sandbox-name", default=SANDBOX_DEFAULTS["name"])
    parser.add_argument("--sandbox-repo", default=SANDBOX_DEFAULTS["repo"])
    parser.add_argument("--sandbox-prefix", default=SANDBOX_DEFAULTS["prefix"])
    parser.add_argument("--sandbox-stack", default=SANDBOX_DEFAULTS["stack"])
    parser.add_argument("--sandbox-test-cmd", default=SANDBOX_DEFAULTS["test_cmd"])
    parser.add_argument("--sandbox-prod-port", default=SANDBOX_DEFAULTS["prod_port"])
    parser.add_argument("--sandbox-staging-port", default=SANDBOX_DEFAULTS["staging_port"])
    return parser


# --------------------------------------------------------------------------- #
# Тонкие обёртки subprocess/HTTP (единственные точки side-effects)
# --------------------------------------------------------------------------- #

def _run(cmd: list, input_text: str | None = None, env: dict | None = None,
         timeout: int = 600) -> subprocess.CompletedProcess:
    """subprocess.run c capture; команды логируются БЕЗ секретов вызывающим."""
    return subprocess.run(
        cmd, input=input_text, env=env, capture_output=True, text=True,
        timeout=timeout, check=False,
    )


def _compose(*args: str, input_text: str | None = None,
             timeout: int = 600) -> subprocess.CompletedProcess:
    return _run(["docker", "compose", "-f", BUNDLE_COMPOSE, *args],
                input_text=input_text, timeout=timeout)


def _http(url: str, headers: dict | None = None, timeout: int = 10) -> tuple:
    """GET url → (status|None, body). Никогда не бросает (poll-friendly)."""
    req = urllib.request.Request(url, headers=headers or {})
    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:  # noqa: S310
            return resp.status, resp.read().decode("utf-8", "replace")
    except urllib.error.HTTPError as e:
        return e.code, ""
    except (urllib.error.URLError, OSError, ValueError):
        return None, ""


def _port_busy(port: int) -> bool:
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.settimeout(0.5)
        return s.connect_ex(("127.0.0.1", port)) == 0


def _write_private(path: str, content: str) -> None:
    """Запись live-конфига: права 600, без печати содержимого (NFR-3)."""
    with open(path, "w", encoding="utf-8") as f:
        f.write(content)
    os.chmod(path, 0o600)
    log(f"  записан {os.path.relpath(path, REPO_ROOT)} (права 600)")


def update_env_file(path: str, example_path: str, overrides: dict) -> None:
    """Идемпотентный ensure env-файла: существующий — обновить ключи overrides,
    отсутствующий — отрендерить от канона-example. Никаких удалений."""
    if os.path.isfile(path):
        base = open(path, encoding="utf-8").read()
    else:
        base = open(example_path, encoding="utf-8").read()
    _write_private(path, render_env(base, overrides))


# --------------------------------------------------------------------------- #
# Сбор фактов хоста (read-only; используется plan/apply/verify)
# --------------------------------------------------------------------------- #

def bundle_ports(bundle_env: dict) -> list:
    out = []
    for key, default in (("BUNDLE_ORCH_PORT", 8500), ("BUNDLE_PLANE_PORT", 8080),
                         ("BUNDLE_GITEA_HTTP_PORT", 3000)):
        try:
            out.append(int(bundle_env.get(key) or default))
        except ValueError:
            out.append(default)
    return out


def collect_facts(bundle_env: dict) -> dict:
    """Read-only снимок хоста для preflight_verdict (ни одной мутации)."""
    docker = shutil.which("docker") is not None
    compose = docker and _compose("version", timeout=30).returncode == 0
    leftovers: list = []
    if docker:
        vols = _run(["docker", "volume", "ls", "--format", "{{.Name}}"], timeout=30)
        names = _run(["docker", "ps", "-a", "--format", "{{.Names}}"], timeout=30)
        for line in (vols.stdout + "\n" + names.stdout).splitlines():
            if line.strip().startswith(PROJECT):
                leftovers.append(line.strip())
    ram_gb = None
    try:
        with open("/proc/meminfo", encoding="utf-8") as f:
            for line in f:
                if line.startswith("MemTotal:"):
                    ram_gb = int(line.split()[1]) / 1024 / 1024
                    break
    except OSError:
        pass
    try:
        disk_gb = shutil.disk_usage(REPO_ROOT).free / 2**30
    except OSError:
        disk_gb = None
    env_exists = os.path.isfile(BUNDLE_ENV)
    missing = [k for k in REQUIRED_BUNDLE_KEYS if not bundle_env.get(k, "")]
    claude_ok = (
        shutil.which("claude") is not None
        or os.path.isdir(os.path.expanduser(
            bundle_env.get("ORCH_HOST_CLAUDE_DIR", "") or "~/.claude"))
    )
    return {
        "docker": docker,
        "compose": compose,
        "env_exists": env_exists,
        "missing_keys": missing if env_exists else [],
        "busy_ports": [p for p in bundle_ports(bundle_env) if _port_busy(p)],
        "leftovers": leftovers,
        "ram_gb": ram_gb,
        "disk_gb": disk_gb,
        "cpus": os.cpu_count(),
        "python3": True,  # мы уже исполняемся под python3
        "claude_cli": claude_ok,
    }


# --------------------------------------------------------------------------- #
# Manual-step контракт (D5/D7): инструкция → подтверждение → верификация
# --------------------------------------------------------------------------- #

def manual_checkpoint(title: str, instructions: list, verify, max_tries: int = 3):
    """Честный чекпоинт: печать точной инструкции; без TTY — немедленный exit 2
    с той же инструкцией; с TTY — ожидание подтверждения и ВЕРИФИКАЦИЯ результата
    (молчаливый пропуск запрещён). verify() → (ok, hint)."""
    log(f"\n🖐 MANUAL-STEP: {title}")
    for line in instructions:
        log(f"   {line}")
    if not sys.stdin.isatty():
        log("   Нет TTY: выполните шаги и перезапустите `apply` (resume = повторный запуск).")
        raise ManualStop(title)
    for _ in range(max_tries):
        input("   Когда выполнено — нажмите Enter: ")
        ok, hint = verify()
        if ok:
            log("   ✓ верификация пройдена")
            return
        log(f"   ✗ верификация не прошла: {hint}")
    raise ManualStop(f"{title}: верификация не прошла после {max_tries} попыток")


# --------------------------------------------------------------------------- #
# Шаги apply (step-движок check→ensure; каждый идемпотентен)
# --------------------------------------------------------------------------- #

def step_preflight(ctx: dict) -> str:
    facts = collect_facts(ctx["bundle_env"])
    blockers, warnings, resume = preflight_verdict(facts)
    for w in warnings:
        log(f"  ⚠ {w}")
    if blockers:
        for b in blockers:
            log(f"  ✗ {b}")
        raise ManualStop("preflight: незавершённые предусловия хоста")
    ctx["resume"] = resume
    if resume:
        log("  инсталляция уже существует — продолжаю в ensure-режиме (AC-8)")
    return "ok"


def step_secrets(ctx: dict) -> str:
    """FR-3: bundle-креды (stdlib secrets) + webhook-секреты (gen_secrets.py)."""
    force = ctx["args"].force_secrets
    bundle_env = ctx["bundle_env"]
    fresh = merge_missing_secrets(bundle_env, force=force)
    # uid/gid/docker-gid хоста — дозаполняются фактическими значениями оператора
    infra: dict = {}
    if not bundle_env.get("ORCH_RUN_UID"):
        infra["ORCH_RUN_UID"] = str(os.getuid())
    if not bundle_env.get("ORCH_RUN_GID"):
        infra["ORCH_RUN_GID"] = str(os.getgid())
    if fresh or infra:
        update_env_file(BUNDLE_ENV, BUNDLE_ENV_EXAMPLE, {**infra, **fresh})
        ctx["bundle_env"] = parse_env(open(BUNDLE_ENV, encoding="utf-8").read())
        log(f"  bundle-креды выпущены: {', '.join(sorted(fresh)) or '—'}")
    else:
        log("  bundle-креды уже на месте (не перетираю без --force-secrets)")
    # webhook-секреты орка — СТРОГО кирпичом gen_secrets.py (AC-7)
    root_env = ctx["root_env"]
    if all(root_env.get(k) for k in WEBHOOK_SECRET_KEYS) and not force:
        log("  webhook-секреты уже в .env — skip")
        return "skipped"
    with tempfile.TemporaryDirectory() as tmp:
        frag_path = os.path.join(tmp, "fragment.env")
        proc = _run([sys.executable, GEN_SECRETS, "--write", frag_path], timeout=60)
        if proc.returncode != 0:
            raise BootstrapError(f"gen_secrets.py отказал (rc={proc.returncode})")
        fragment = parse_env(open(frag_path, encoding="utf-8").read())
    overrides = {k: fragment[k] for k in WEBHOOK_SECRET_KEYS
                 if fragment.get(k) and (force or not root_env.get(k))}
    update_env_file(ROOT_ENV, ROOT_ENV_EXAMPLE, overrides)
    ctx["root_env"] = parse_env(open(ROOT_ENV, encoding="utf-8").read())
    log(f"  webhook-секреты выпущены: {', '.join(sorted(overrides)) or '—'}")
    return "ok"


def _wait_http(url: str, timeout_s: int, label: str, ok_statuses=(200,)) -> None:
    deadline = time.monotonic() + timeout_s
    while time.monotonic() < deadline:
        status, _ = _http(url, timeout=5)
        if status in ok_statuses:
            log(f"  ✓ {label} готов ({url})")
            return
        time.sleep(5)
    tail = _compose("logs", "--tail", "30", label, timeout=60).stdout[-2000:]
    raise BootstrapError(f"{label} не дождались за {timeout_s}с ({url}); хвост логов:\n{tail}")


def _wait_migrator(timeout_s: int) -> None:
    deadline = time.monotonic() + timeout_s
    name = f"{PROJECT}-migrator-1"
    while time.monotonic() < deadline:
        proc = _run(["docker", "inspect", "-f",
                     "{{.State.Status}} {{.State.ExitCode}}", name], timeout=30)
        state = proc.stdout.strip()
        if proc.returncode == 0 and state.startswith("exited"):
            if state.endswith(" 0"):
                log("  ✓ миграции Plane завершились (migrator exit 0)")
                return
            tail = _compose("logs", "--tail", "30", "migrator", timeout=60).stdout[-2000:]
            raise BootstrapError(f"миграции Plane упали ({state}); хвост логов:\n{tail}")
        time.sleep(5)
    raise BootstrapError(f"миграции Plane не завершились за {timeout_s}с (TR-1: проверьте RAM/диск)")


def step_up(ctx: dict) -> str:
    """Подъём стека + ожидание готовности каждого слоя (D5 шаг 3)."""
    for sub in ("data", "repos"):
        os.makedirs(os.path.join(BUNDLE_DIR, sub), exist_ok=True)
    proc = _compose("up", "-d", timeout=1800)
    if proc.returncode != 0:
        raise BootstrapError(f"docker compose up отказал:\n{proc.stderr[-2000:]}")
    ports = dict(zip(("orch", "plane", "gitea"), bundle_ports(ctx["bundle_env"])))
    _wait_http(f"http://127.0.0.1:{ports['gitea']}/api/healthz", READY_TIMEOUT_S, "gitea")
    _wait_migrator(PLANE_READY_TIMEOUT_S)
    _wait_http(f"http://127.0.0.1:{ports['plane']}/", PLANE_READY_TIMEOUT_S,
               "proxy", ok_statuses=(200, 301, 302))
    _wait_http(f"http://127.0.0.1:{ports['orch']}/health", READY_TIMEOUT_S, "orchestrator")
    return "ok"


def step_init_gitea(ctx: dict) -> str:
    """D6: админ-бот + API-токен официальным CLI в контейнере; идемпотентно.
    Branch protection НЕ настраивается (норматив D10 ORCH-009 / INV-4)."""
    bundle_env, root_env = ctx["bundle_env"], ctx["root_env"]
    user = bundle_env.get("GITEA_ADMIN_USERNAME", "orchestrator-bot")
    gitea_port = bundle_ports(bundle_env)[2]
    ctx["gitea_owner"] = user
    proc = _compose(
        "exec", "-T", "-u", "git", "gitea",
        "gitea", "admin", "user", "create", "--admin",
        "--username", user, "--password", bundle_env.get("GITEA_ADMIN_PASSWORD", ""),
        "--email", f"{user}@{PROJECT}.local", "--must-change-password=false",
        timeout=120,
    )
    blob = proc.stdout + proc.stderr
    if proc.returncode == 0:
        log(f"  создан админ-бот Gitea: {user}")
    elif "already exists" in blob:
        log(f"  админ-бот {user} уже существует — skip")
    else:
        raise BootstrapError(f"gitea admin user create отказал: {blob[-500:]}")
    # API-токен (носитель — root .env, ORCH_GITEA_TOKEN)
    token = root_env.get("ORCH_GITEA_TOKEN", "")
    if token:
        status, _ = _http(f"http://127.0.0.1:{gitea_port}/api/v1/user",
                          headers={"Authorization": f"token {token}"})
        if status == 200:
            log("  ORCH_GITEA_TOKEN валиден — skip")
            return "skipped"
    proc = _compose(
        "exec", "-T", "-u", "git", "gitea",
        "gitea", "admin", "user", "generate-access-token",
        "--username", user, "--token-name", f"orchestrator-{int(time.time())}",
        "--scopes", "all", "--raw",
        timeout=120,
    )
    if proc.returncode != 0:
        raise BootstrapError(f"generate-access-token отказал: {proc.stderr[-500:]}")
    token = proc.stdout.strip().splitlines()[-1].strip()
    status, _ = _http(f"http://127.0.0.1:{gitea_port}/api/v1/user",
                      headers={"Authorization": f"token {token}"})
    if status != 200:
        raise BootstrapError(f"свежий токен Gitea не прошёл верификацию (HTTP {status})")
    update_env_file(ROOT_ENV, ROOT_ENV_EXAMPLE,
                    {"ORCH_GITEA_TOKEN": token, "ORCH_GITEA_OWNER": user})
    ctx["root_env"] = parse_env(open(ROOT_ENV, encoding="utf-8").read())
    log("  выпущен ORCH_GITEA_TOKEN (значение в .env, не печатается)")
    return "ok"


def _verify_plane_token(plane_port: int, slug: str, token: str) -> tuple:
    status, _ = _http(
        f"http://127.0.0.1:{plane_port}/api/v1/workspaces/{slug}/projects/",
        headers={"X-API-Key": token}, timeout=15,
    )
    if status == 200:
        return True, ""
    return False, f"GET /api/v1/workspaces/{slug}/projects/ → HTTP {status}"


def step_init_plane(ctx: dict) -> str:
    """D7: instance-setup / workspace / API-токен — честные manual-step
    чекпоинты (Plane CE не даёт API первичной инициализации)."""
    bundle_env, root_env = ctx["bundle_env"], ctx["root_env"]
    host = bundle_env.get("BUNDLE_PUBLIC_HOST", "localhost")
    plane_port = bundle_ports(bundle_env)[1]
    slug = root_env.get("ORCH_PLANE_WORKSPACE_SLUG", "")
    token = root_env.get("ORCH_PLANE_API_TOKEN", "")
    if slug and token and _verify_plane_token(plane_port, slug, token)[0]:
        log("  workspace и ORCH_PLANE_API_TOKEN валидны — skip")
        return "skipped"

    def _instance_done():
        status, body = _http(f"http://127.0.0.1:{plane_port}/api/instances/", timeout=10)
        if status == 200 and '"is_setup_done":true' in body.replace(" ", ""):
            return True, ""
        if status == 200:
            return False, "instance setup ещё не завершён (is_setup_done != true)"
        # эндпоинт недоступен в этой сборке CE → деградация: живость UI
        ui, _ = _http(f"http://127.0.0.1:{plane_port}/", timeout=10)
        return (ui in (200, 301, 302)), f"Plane UI отвечает HTTP {ui}"

    manual_checkpoint(
        "Plane: instance setup (первый администратор)",
        [f"Откройте http://{host}:{plane_port}/ и зарегистрируйте первого",
         "пользователя — он станет администратором инстанса (Plane CE)."],
        _instance_done,
    )
    if not sys.stdin.isatty():
        raise ManualStop("Plane: workspace/API-токен требуют интерактивного ввода")
    slug = input("   Введите slug созданного workspace: ").strip()
    log("   Plane UI → Workspace Settings → API tokens → выпустите токен.")
    token = getpass.getpass("   Вставьте ORCH_PLANE_API_TOKEN (ввод скрыт): ").strip()
    ok, hint = _verify_plane_token(plane_port, slug, token)
    if not ok:
        raise ManualStop(f"Plane: токен/slug не прошли верификацию ({hint})")
    update_env_file(ROOT_ENV, ROOT_ENV_EXAMPLE,
                    {"ORCH_PLANE_WORKSPACE_SLUG": slug, "ORCH_PLANE_API_TOKEN": token})
    ctx["root_env"] = parse_env(open(ROOT_ENV, encoding="utf-8").read())
    log("  ✓ workspace и ORCH_PLANE_API_TOKEN верифицированы (значения в .env)")
    return "ok"


def _psql(sql: str, bundle_env: dict) -> subprocess.CompletedProcess:
    """SQL в plane-db через stdin (секреты не попадают в argv, NFR-3)."""
    return _compose(
        "exec", "-T", "plane-db", "psql",
        "-U", bundle_env.get("POSTGRES_USER", "plane"),
        "-d", bundle_env.get("POSTGRES_DB", "plane"),
        "-t", "-A", "-v", "ON_ERROR_STOP=1",
        input_text=sql, timeout=60,
    )


def step_plane_webhook(ctx: dict) -> str:
    """Workspace-webhook Plane→орк. CE не даёт API → ensure прямой записью в
    Postgres инсталляции (прогрессивная автоматизация D7: контракт чекпоинта —
    та же верификация SELECT'ом); схема — канон LITE_SETUP §5.4 (путь Б)."""
    bundle_env, root_env = ctx["bundle_env"], ctx["root_env"]
    secret = root_env.get("ORCH_PLANE_WEBHOOK_SECRET", "")
    slug = root_env.get("ORCH_PLANE_WORKSPACE_SLUG", "")
    if not (secret and slug):
        raise BootstrapError("нет ORCH_PLANE_WEBHOOK_SECRET/ORCH_PLANE_WORKSPACE_SLUG в .env")

    def _exists() -> tuple:
        probe = _psql(
            f"SELECT count(*) FROM webhooks WHERE url='{WEBHOOK_PLANE_URL}' "
            f"AND deleted_at IS NULL;", bundle_env)
        ok = probe.returncode == 0 and probe.stdout.strip().isdigit() \
            and int(probe.stdout.strip()) > 0
        return ok, f"SELECT по webhooks: rc={probe.returncode}"

    if _exists()[0]:
        log("  workspace-webhook уже зарегистрирован — skip")
        return "skipped"
    wid = _psql(f"SELECT id FROM workspaces WHERE slug='{slug}';", bundle_env)
    workspace_id = wid.stdout.strip().splitlines()[0].strip() if wid.stdout.strip() else ""
    if wid.returncode == 0 and workspace_id:
        ins = _psql(
            "INSERT INTO webhooks (id, created_at, updated_at, deleted_at, "
            "workspace_id, url, is_active, secret_key, project, issue, module, "
            "cycle, issue_comment, is_internal, version) VALUES "
            f"('{uuid.uuid4()}', NOW(), NOW(), NULL, '{workspace_id}', "
            f"'{WEBHOOK_PLANE_URL}', true, '{secret}', true, true, false, false, "
            "true, false, 'v1');", bundle_env)
        if ins.returncode == 0 and _exists()[0]:
            log(f"  ✓ workspace-webhook зарегистрирован: {WEBHOOK_PLANE_URL}")
            return "ok"
        log("  прямая регистрация не удалась — честный manual-step (fail-safe)")
    manual_checkpoint(
        "Plane: workspace-webhook (CE без API)",
        [f"Workspace Settings → Webhooks → Add Webhook: URL {WEBHOOK_PLANE_URL},",
         "секрет — значение ORCH_PLANE_WEBHOOK_SECRET из корневого .env,",
         "события Issue + Issue Comment (канон — LITE_SETUP §5.4)."],
        _exists,
    )
    return "ok"


def _ensure_venv() -> str:
    """Host-venv для onboard-кирпича (канон ONBOARDING; ensure, TR-9)."""
    if not os.path.exists(VENV_PY):
        proc = _run([sys.executable, "-m", "venv", VENV_DIR], timeout=300)
        if proc.returncode != 0:
            raise BootstrapError(f"python3 -m venv отказал: {proc.stderr[-500:]}")
    probe = _run([VENV_PY, "-c", "import httpx, pydantic"], timeout=60)
    if probe.returncode != 0:
        log("  ставлю зависимости onboard-кирпича в .venv (requirements.txt)…")
        proc = _run([VENV_PY, "-m", "pip", "install", "-q", "-r", REQUIREMENTS],
                    timeout=1200)
        if proc.returncode != 0:
            raise BootstrapError(f"pip install отказал: {proc.stderr[-500:]}")
    return VENV_PY


def _onboard_env(ctx: dict) -> dict:
    """Окружение onboard-субпроцесса: host-видимые URL bundle-инсталляции
    (pydantic env-переменные перекрывают env_file, D7)."""
    bundle_env, root_env = ctx["bundle_env"], ctx["root_env"]
    host = bundle_env.get("BUNDLE_PUBLIC_HOST", "localhost")
    plane_p, gitea_p = bundle_ports(bundle_env)[1:]
    return {
        **os.environ,
        "ORCH_PLANE_API_URL": f"http://127.0.0.1:{plane_p}",
        "ORCH_PLANE_WEB_URL": f"http://{host}:{plane_p}",
        "ORCH_PLANE_WORKSPACE_SLUG": root_env.get("ORCH_PLANE_WORKSPACE_SLUG", ""),
        "ORCH_PLANE_API_TOKEN": root_env.get("ORCH_PLANE_API_TOKEN", ""),
        "ORCH_GITEA_URL": f"http://127.0.0.1:{gitea_p}",
        "ORCH_GITEA_PUBLIC_URL": f"http://{host}:{gitea_p}",
        "ORCH_GITEA_OWNER": root_env.get("ORCH_GITEA_OWNER", ""),
        "ORCH_GITEA_TOKEN": root_env.get("ORCH_GITEA_TOKEN", ""),
        "ORCH_GITEA_WEBHOOK_SECRET": root_env.get("ORCH_GITEA_WEBHOOK_SECRET", ""),
    }


def _onboard_args(ctx: dict, mode: str) -> list:
    a = ctx["args"]
    return [
        ONBOARD, mode,
        "--name", a.sandbox_name, "--repo", a.sandbox_repo,
        "--gitea-owner", ctx["root_env"].get("ORCH_GITEA_OWNER", ""),
        "--prefix", a.sandbox_prefix, "--stack", a.sandbox_stack,
        "--test-cmd", a.sandbox_test_cmd,
        "--prod-port", a.sandbox_prod_port, "--staging-port", a.sandbox_staging_port,
        "--webhook-url", WEBHOOK_GITEA_URL,
        "--env-file", ROOT_ENV, "--json",
    ]


def step_onboard(ctx: dict) -> str:
    """BR-6/AC-7: статусы/лейблы/репо/вебхуки — СТРОГО onboard_project.py."""
    venv_py = _ensure_venv()
    env = _onboard_env(ctx)
    proc = _run([venv_py, *_onboard_args(ctx, "apply")], env=env, timeout=900)
    if proc.returncode not in (0, 2):
        raise BootstrapError(f"onboard apply отказал (rc={proc.returncode}): "
                             f"{proc.stderr[-800:]}")
    try:
        report = json.loads(proc.stdout)
    except ValueError:
        raise BootstrapError("onboard apply вернул непарсимый отчёт")
    merged = ""
    for instr in report.get("instructions", []):
        if isinstance(instr, str) and instr.startswith("ORCH_PROJECTS_JSON="):
            merged = instr.split("=", 1)[1]
    if merged:
        update_env_file(ROOT_ENV, ROOT_ENV_EXAMPLE, {"ORCH_PROJECTS_JSON": merged})
        ctx["root_env"] = parse_env(open(ROOT_ENV, encoding="utf-8").read())
        log("  реестр ORCH_PROJECTS_JSON записан в .env (merged-вывод onboard)")
    manual = [s for s in report.get("steps", [])
              if s.get("status") == "manual-step"
              and s.get("id") not in ("plane.workspace-webhook",)]
    if manual:
        log("  onboard оставил ручные шаги (см. отчёт): "
            + ", ".join(s.get("id", "?") for s in manual))
    verify = _run([venv_py, *_onboard_args(ctx, "verify")], env=env, timeout=300)
    if verify.returncode == 1:
        raise BootstrapError(f"onboard verify отказал: {verify.stderr[-800:]}")
    log(f"  onboard verify: exit {verify.returncode} "
        f"(0 — чисто; 2 — остались ручные пункты отчёта)")
    ctx["onboard_manual"] = bool(manual) or verify.returncode == 2
    return "ok"


def step_agent_git(ctx: dict) -> str:
    """D8: клон sandbox-репо token-remote ВНУТРИ контейнера орка (origin —
    in-network gitea:3000, агенты наследуют его для push/fetch)."""
    repo = ctx["args"].sandbox_repo
    owner = ctx["root_env"].get("ORCH_GITEA_OWNER", "")
    token = ctx["root_env"].get("ORCH_GITEA_TOKEN", "")
    probe = _compose("exec", "-T", "orchestrator", "test", "-d",
                     f"/repos/{repo}/.git", timeout=30)
    if probe.returncode == 0:
        log(f"  /repos/{repo} уже клонирован — skip")
        return "skipped"
    url = f"{GITEA_INTERNAL_URL.split('://')[0]}://oauth2:{token}@" \
          f"{GITEA_INTERNAL_URL.split('://')[1]}/{owner}/{repo}.git"
    proc = _compose("exec", "-T", "orchestrator",
                    "git", "clone", url, f"/repos/{repo}", timeout=300)
    if proc.returncode != 0:
        raise BootstrapError(
            f"клон {repo} в /repos не удался (лог замаскирован): rc={proc.returncode}")
    log(f"  ✓ /repos/{repo} клонирован (token-remote, TR-7: права локального каталога)")
    return "ok"


def step_orch_env(ctx: dict) -> str:
    """D5 шаг 8: корневой .env (канон Lite 1:1) + .env.watchdog; пересоздание
    орка/watchdog для подхвата конфига."""
    bundle_env = ctx["bundle_env"]
    host = bundle_env.get("BUNDLE_PUBLIC_HOST", "localhost")
    plane_p, gitea_p = bundle_ports(bundle_env)[1:]
    overrides = {
        # in-network машинные URL (D4) + публичные от BUNDLE_PUBLIC_HOST
        "ORCH_PLANE_API_URL": PLANE_INTERNAL_URL,
        "ORCH_PLANE_WEB_URL": f"http://{host}:{plane_p}",
        "ORCH_GITEA_URL": GITEA_INTERNAL_URL,
        "ORCH_GITEA_PUBLIC_URL": f"http://{host}:{gitea_p}",
        # когерентность дублируемых ключей — механически (TR-8)
        "ORCH_AGENT_HOME_DIR": bundle_env.get("ORCH_AGENT_HOME_DIR", "/home/orchestrator"),
        "ORCH_RUN_UID": bundle_env.get("ORCH_RUN_UID", "1000"),
        "ORCH_RUN_GID": bundle_env.get("ORCH_RUN_GID", "1000"),
        "ORCH_DOCKER_GID": bundle_env.get("ORCH_DOCKER_GID", "999"),
        "ORCH_HOST_REPOS_DIR": os.path.join(BUNDLE_DIR, "repos"),
        "ORCH_HOST_CLAUDE_CODE_DIR": bundle_env.get("ORCH_HOST_CLAUDE_CODE_DIR", ""),
        "ORCH_HOST_NODE_BIN": bundle_env.get("ORCH_HOST_NODE_BIN", ""),
        "ORCH_HOST_CLAUDE_DIR": bundle_env.get("ORCH_HOST_CLAUDE_DIR", ""),
        "ORCH_HOST_CLAUDE_JSON": bundle_env.get("ORCH_HOST_CLAUDE_JSON", ""),
        # деплой-машинерия нашего хоста в bundle структурно спит (D4)
        "ORCH_DEPLOY_SSH_HOST": "",
    }
    update_env_file(ROOT_ENV, ROOT_ENV_EXAMPLE, overrides)
    ctx["root_env"] = parse_env(open(ROOT_ENV, encoding="utf-8").read())
    if not os.path.isfile(WATCHDOG_ENV):
        # Telegram-ключи опциональны: пусто = деградация только нотификаций
        update_env_file(WATCHDOG_ENV, WATCHDOG_ENV_EXAMPLE, {})
    proc = _compose("up", "-d", "--force-recreate",
                    "orchestrator", "orchestrator-watchdog", timeout=600)
    if proc.returncode != 0:
        raise BootstrapError(f"пересоздание орка/watchdog отказало:\n{proc.stderr[-1000:]}")
    log("  ✓ орк и watchdog пересозданы с собранным конфигом")
    return "ok"


def step_health(ctx: dict) -> str:
    orch_p = bundle_ports(ctx["bundle_env"])[0]
    failures = []
    for path in ("/health", "/queue", "/metrics"):
        url = f"http://127.0.0.1:{orch_p}{path}"
        status, body = None, ""
        deadline = time.monotonic() + 60
        while time.monotonic() < deadline:
            status, body = _http(url, timeout=5)
            if status == 200:
                break
            time.sleep(3)
        ok = status == 200
        if path != "/health" and ok:
            try:
                json.loads(body)
            except ValueError:
                ok = False
        log(f"  GET {path} → {'PASS' if ok else f'FAIL (HTTP {status})'}")
        if not ok:
            failures.append(path)
    if failures:
        raise BootstrapError(f"health-контракты не зелёные: {', '.join(failures)}")
    return "ok"


APPLY_STEPS = (
    ("preflight", step_preflight),
    ("secrets", step_secrets),
    ("up", step_up),
    ("init-gitea", step_init_gitea),
    ("init-plane", step_init_plane),
    ("plane-webhook", step_plane_webhook),
    ("onboard", step_onboard),
    ("agent-git", step_agent_git),
    ("orch-env", step_orch_env),
    ("health", step_health),
)


# --------------------------------------------------------------------------- #
# Режимы
# --------------------------------------------------------------------------- #

def _load_ctx(args: argparse.Namespace) -> dict:
    bundle_env = parse_env(open(BUNDLE_ENV, encoding="utf-8").read()) \
        if os.path.isfile(BUNDLE_ENV) else {}
    root_env = parse_env(open(ROOT_ENV, encoding="utf-8").read()) \
        if os.path.isfile(ROOT_ENV) else {}
    return {"args": args, "bundle_env": bundle_env, "root_env": root_env,
            "results": {}}


def run_plan(ctx: dict) -> int:
    log("== bootstrap_bundle: план apply (ноль мутаций) ==")
    for i, (name, summary) in enumerate(build_plan(), 1):
        log(f"  {i}. {name:<14} {summary}")
    facts = collect_facts(ctx["bundle_env"])
    blockers, warnings, resume = preflight_verdict(facts)
    log("\n-- preflight-диагностика (read-only):")
    for w in warnings:
        log(f"  ⚠ {w}")
    for b in blockers:
        log(f"  ✗ {b}")
    if resume:
        log("  ℹ найдены тома/контейнеры orchestrator-bundle: apply пойдёт в ensure-режиме")
    if not blockers:
        log("  ✓ предусловия хоста выполнены — запускайте: "
            "python3 scripts/bootstrap_bundle.py apply")
        return EXIT_OK
    log(f"  итог: {len(blockers)} блокеров — устраните и повторите (канон — {DOC})")
    return EXIT_MANUAL


def run_apply(ctx: dict) -> int:
    log("== bootstrap_bundle: apply ==")
    for name, fn in APPLY_STEPS:
        log(f"\n→ шаг {name}")
        status = fn(ctx)
        ctx["results"][name] = status
    log("\n== итоговая сводка ==")
    for name, _ in APPLY_STEPS:
        log(f"  [{ctx['results'].get(name, '—'):>8}] {name}")
    if ctx.get("onboard_manual"):
        log("\n🖐 Остались ручные пункты onboard-отчёта (порядок статусов на доске и т.п.)")
        log("   Выполните их и перезапустите verify. Exit 2 (незавершённые шаги).")
        return EXIT_MANUAL
    log(f"\n✓ Bundled-инсталляция готова. Следующий шаг — smoke: {DOC} §11 "
        "(чек-лист REPLICATION.md §4).")
    return EXIT_OK


def run_verify(ctx: dict) -> int:
    """Read-only пост-проверка: health-контракты + onboard verify."""
    log("== bootstrap_bundle: verify (read-only) ==")
    orch_p = bundle_ports(ctx["bundle_env"])[0]
    failed = False
    for path in ("/health", "/queue", "/metrics"):
        status, _ = _http(f"http://127.0.0.1:{orch_p}{path}", timeout=10)
        ok = status == 200
        failed = failed or not ok
        log(f"  GET {path} → {'PASS' if ok else f'FAIL (HTTP {status})'}")
    if os.path.exists(VENV_PY) and ctx["root_env"].get("ORCH_PLANE_API_TOKEN"):
        proc = _run([VENV_PY, *_onboard_args(ctx, "verify")],
                    env=_onboard_env(ctx), timeout=300)
        log(f"  onboard verify → exit {proc.returncode}")
        failed = failed or proc.returncode == 1
        if proc.returncode == 2:
            return EXIT_MANUAL
    else:
        log("  onboard verify пропущен (нет .venv или ORCH_PLANE_API_TOKEN) → exit 2")
        return EXIT_MANUAL
    return EXIT_ERROR if failed else EXIT_OK


def main(argv: list | None = None) -> int:
    args = build_arg_parser().parse_args(argv)
    ctx = _load_ctx(args)
    try:
        if args.mode == "plan":
            return run_plan(ctx)
        if args.mode == "verify":
            return run_verify(ctx)
        return run_apply(ctx)
    except ManualStop as e:
        log(f"\n🖐 ОСТАНОВКА (exit {EXIT_MANUAL}): {e}")
        log("   Выполните шаг и перезапустите apply — завершённые шаги будут пропущены.")
        return EXIT_MANUAL
    except BootstrapError as e:
        log(f"\n✗ ОШИБКА (exit {EXIT_ERROR}): {e}")
        return EXIT_ERROR
    except KeyboardInterrupt:
        log(f"\n✗ прервано оператором (exit {EXIT_ERROR})")
        return EXIT_ERROR


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