#!/usr/bin/env python3
"""onboard_project.py — операторский turnkey-CLI онбординга нового проекта (ORCH-009).

Разворачивает все слои подключения нового проекта к оркестратору одним проходом:
Plane-проект (22 статуса с точными каноническими именами + лейблы) → Gitea-репо
(+per-repo webhook) → материализация onboarding-kit (рендер плейсхолдеров +
live-copy канона docs/_templates|_standards) + initial push ТОЛЬКО в свежесозданный
пустой репо → merged-вывод ``ORCH_PROJECTS_JSON`` (round-trip через фактический
парсер реестра). Полный процесс — runbook ``docs/operations/ONBOARDING.md``.

Режимы (ADR-001 D11 ORCH-009):
  plan    — дефолт; только GET-пробы + полный план; НИ ОДНОЙ мутации (ни сети, ни диска).
  apply   — идемпотентный ensure: существующее → ``skipped(exists)``; delete-операций НЕТ.
  verify  — GET-пробы + локальные проверки (реестр, статусы, лейблы, webhook, полнота kit).

Гарантии безопасности (NFR-2/NFR-3):
  * прод-контейнер оркестратора НИКОГДА не трогается (ни рестартов, ни остановок);
  * ``.env`` НИКОГДА не правится (читается read-only); регистрация = операторский шаг;
  * push возможен ТОЛЬКО в свежесозданный/пустой репо — в существующие никогда;
  * ничего не удаляется; секреты в отчёте маскируются.

Запуск — из корня чекаута репо orchestrator (см. runbook):
  python3 scripts/onboard_project.py plan --name "My Project" --repo my-project \\
      --prefix MP --stack "Python 3.12 + FastAPI" --test-cmd "pytest -q" \\
      --prod-port 8600 --staging-port 8601 --webhook-url https://<orch-host>/webhook/gitea

Exit-коды: 0 — чисто; 2 — есть manual-step / gap в verify; 1 — ошибка.
"""

import argparse
import dataclasses
import json
import logging
import os
import re
import secrets as _secrets
import subprocess
import sys
import tempfile
import urllib.parse

# Запуск из корня чекаута (паттерн scripts/staging_check.py): добавляем корень в
# sys.path, чтобы работали read-only импорты из src.
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _REPO_ROOT not in sys.path:
    sys.path.insert(0, _REPO_ROOT)

# ── Закрытый список read-only импортов из src (ORCH-009 ADR-001 D4) ──────────
# Любой новый импорт из src — ТОЛЬКО через обновление ADR (контроль —
# tests/test_onboarding_invariants.py::test_tc21_cli_src_imports_stay_in_closed_list).
from src.config import settings  # noqa: E402  # имена лейблов, URL/токены, реестр
from src.plane_sync import _PLANE_NAME_TO_KEY  # noqa: E402  # точные имена 22 статусов (нулевой дрейф)
from src.projects import _parse_projects_json  # noqa: E402  # round-trip валидация реестра

try:  # httpx — существующая зависимость проекта; нужен только реальным клиентам
    import httpx
except Exception:  # pragma: no cover - тесты инжектируют фейки, httpx не обязателен
    httpx = None

logger = logging.getLogger("onboard_project")

ONBOARDING_DIR = os.path.join(_REPO_ROOT, "onboarding")
SKELETON_DIR = os.path.join(ONBOARDING_DIR, "repo-skeleton")
PLACEHOLDERS_PATH = os.path.join(ONBOARDING_DIR, "placeholders.json")
RUNBOOK = "docs/operations/ONBOARDING.md"

# Live-copy канона (BR-2/D3): копируются из чекаута орка, в kit НЕ хранятся.
LIVE_COPY_DIRS = ("docs/_templates", "docs/_standards")

# Per-repo Gitea-webhook (формат docs/operations/SETUP_WEBHOOKS.md).
WEBHOOK_EVENTS = ["push", "pull_request", "status"]

# Синтаксис плейсхолдеров (D2): {{NAME}}, верхний регистр.
PLACEHOLDER_RE = re.compile(r"\{\{[A-Z][A-Z0-9_]*\}\}")

# Канонические группы статусов Plane (ADR-001 D5 ORCH-009). Код-критичные
# констрейнты: STOP — группа `cancelled` (fail-closed ветка отмены, ORCH-090);
# в терминальных группах (`completed`/`cancelled`) ТОЛЬКО Done/Cancelled/STOP —
# иначе terminal-detection (ORCH-068, {uuid→group}) ложно сочтёт живую задачу
# терминальной. Имена ключей — байт-в-байт из plane_sync._PLANE_NAME_TO_KEY.
STATE_GROUPS: dict[str, str] = {
    "Backlog": "backlog",
    "Todo": "unstarted",
    "To Analyse": "unstarted",
    "In Progress": "started",
    "Analysis": "started",
    "Architecture": "started",
    "Development": "started",
    "Code-Review": "started",
    "Review": "started",
    "Testing": "started",
    "Awaiting Deploy": "started",
    "Deploying": "started",
    "Monitoring after Deploy": "started",
    "Needs Input": "started",
    "In Review": "started",
    "Blocked": "started",
    "Approved": "started",
    "Confirm Deploy": "started",
    "Rejected": "started",  # reject = rework-петля, задача жива → НЕ cancelled
    "Done": "completed",
    "Cancelled": "cancelled",
    "STOP": "cancelled",
}

# Статусы шагов отчёта (D11).
PLANNED = "planned"
CREATED = "created"
SKIPPED = "skipped(exists)"
MANUAL = "manual-step"
ERROR = "error"
OK = "ok"
GAP = "gap"


class ManualStep(Exception):
    """API-шаг недоступен (403/404/405/501/не реализовано в CE) → ручной пункт runbook."""


def label_names() -> list[str]:
    """Имена лейблов авто-режимов/багфикс-трека — из конфига (D4), не литералы."""
    return [
        settings.auto_approve_label,
        settings.auto_deploy_label,
        settings.bug_fast_track_label,
    ]


# --------------------------------------------------------------------------- #
# Отчёт
# --------------------------------------------------------------------------- #

@dataclasses.dataclass
class Step:
    """Один шаг плана/исполнения/верификации."""

    id: str
    description: str
    status: str
    detail: str = ""


@dataclasses.dataclass
class Report:
    """Итоговый отчёт прогона: шаги + операторские инструкции + exit-код."""

    mode: str
    steps: list = dataclasses.field(default_factory=list)
    instructions: list = dataclasses.field(default_factory=list)

    def add(self, step_id: str, description: str, status: str, detail: str = "") -> Step:
        step = Step(step_id, description, status, detail)
        self.steps.append(step)
        logger.info("[%s] %s — %s%s", self.mode, step_id, status, f" ({detail})" if detail else "")
        return step

    @property
    def exit_code(self) -> int:
        statuses = {s.status for s in self.steps}
        if ERROR in statuses:
            return 1
        if MANUAL in statuses or GAP in statuses:
            return 2
        return 0

    def to_dict(self) -> dict:
        totals = {"created": 0, "skipped": 0, "manual": 0, "planned": 0, "error": 0, "ok": 0, "gap": 0}
        key_by_status = {
            CREATED: "created", SKIPPED: "skipped", MANUAL: "manual",
            PLANNED: "planned", ERROR: "error", OK: "ok", GAP: "gap",
        }
        for s in self.steps:
            totals[key_by_status.get(s.status, "error")] += 1
        return {
            "mode": self.mode,
            "steps": [dataclasses.asdict(s) for s in self.steps],
            "totals": totals,
            "instructions": list(self.instructions),
            "exit_code": self.exit_code,
        }

    def render_text(self) -> str:
        lines = [f"== onboarding report ({self.mode}) =="]
        for s in self.steps:
            lines.append(f"  [{s.status:>15}] {s.id:<28} {s.description}")
            if s.detail:
                lines.append(f"  {'':>17} ↳ {s.detail}")
        t = self.to_dict()["totals"]
        lines.append(
            f"-- totals: created={t['created']} skipped={t['skipped']} "
            f"manual={t['manual']} planned={t['planned']} ok={t['ok']} "
            f"gap={t['gap']} error={t['error']}"
        )
        if self.instructions:
            lines.append("-- операторские шаги:")
            for i, instr in enumerate(self.instructions, 1):
                lines.append(f"  {i}. {instr}")
        lines.append(f"-- exit code: {self.exit_code}")
        return "\n".join(lines)


# --------------------------------------------------------------------------- #
# Чистое ядро: словарь, рендер, kit
# --------------------------------------------------------------------------- #

def load_placeholders() -> dict:
    """Словарь плейсхолдеров onboarding/placeholders.json (single source of truth)."""
    with open(PLACEHOLDERS_PATH, encoding="utf-8") as f:
        return json.load(f)


def render(text: str, params: dict) -> str:
    """Тупой проход str.replace по словарю (D2) — без шаблонизаторов и логики."""
    for name, value in params.items():
        text = text.replace("{{" + name + "}}", str(value))
    return text


def find_unresolved(text: str) -> list[str]:
    """Все неразрешённые {{PLACEHOLDER}} в тексте (обязательный пост-скан, D2)."""
    return sorted(set(PLACEHOLDER_RE.findall(text)))


def iter_skeleton_files() -> list[str]:
    """Относительные пути всех файлов repo-skeleton (отсортированы, детерминизм)."""
    out: list[str] = []
    for root, _dirs, files in os.walk(SKELETON_DIR):
        for name in files:
            full = os.path.join(root, name)
            out.append(os.path.relpath(full, SKELETON_DIR))
    return sorted(out)


def render_kit_in_memory(params: dict) -> dict:
    """Рендер всех файлов kit В ПАМЯТИ (plan-режим: ноль записей на диск, AC-8)."""
    rendered: dict[str, str] = {}
    for rel in iter_skeleton_files():
        with open(os.path.join(SKELETON_DIR, rel), encoding="utf-8") as f:
            rendered[rel.replace(os.sep, "/")] = render(f.read(), params)
    return rendered


def materialize_kit(params: dict, dest_dir: str) -> list[str]:
    """Материализовать kit на диск: рендер скелета + live-copy канона (apply-only).

    Возвращает список записанных относительных путей. Неразрешённые плейсхолдеры
    после рендера → ValueError (PASS-условие AC-5). Существующие файлы в dest_dir
    не перезаписываются (идемпотентность BR-9 при повторной материализации).
    """
    written: list[str] = []
    rendered = render_kit_in_memory(params)
    problems = {rel: bad for rel, content in rendered.items() if (bad := find_unresolved(content))}
    if problems:
        raise ValueError(f"unresolved placeholders after render: {problems}")

    for rel, content in rendered.items():
        target = os.path.join(dest_dir, *rel.split("/"))
        if os.path.exists(target):
            continue  # не перезаписываем существующее (BR-9)
        os.makedirs(os.path.dirname(target), exist_ok=True)
        with open(target, "w", encoding="utf-8") as f:
            f.write(content)
        written.append(rel)

    # Live-copy канона байт-в-байт из чекаута орка (BR-2/D3).
    for rel_dir in LIVE_COPY_DIRS:
        src_dir = os.path.join(_REPO_ROOT, *rel_dir.split("/"))
        if not os.path.isdir(src_dir):
            raise FileNotFoundError(f"live canon dir missing in checkout: {rel_dir}")
        for root, _dirs, files in os.walk(src_dir):
            for name in files:
                src_file = os.path.join(root, name)
                rel = os.path.join(rel_dir, os.path.relpath(src_file, src_dir)).replace(os.sep, "/")
                target = os.path.join(dest_dir, *rel.split("/"))
                if os.path.exists(target):
                    continue
                os.makedirs(os.path.dirname(target), exist_ok=True)
                with open(src_file, encoding="utf-8") as fsrc, open(target, "w", encoding="utf-8") as fdst:
                    fdst.write(fsrc.read())
                written.append(rel)
    return written


# --------------------------------------------------------------------------- #
# Реестр (D7)
# --------------------------------------------------------------------------- #

def build_registry_entry(params: dict) -> dict:
    """Запись реестра нового проекта (контракт src/projects.py::ProjectConfig)."""
    return {
        "plane_project_id": str(params["PLANE_PROJECT_ID"]),
        "repo": str(params["REPO"]),
        "work_item_prefix": str(params["WORK_ITEM_PREFIX"]),
        "name": str(params["PROJECT_NAME"]),
    }


def merged_projects_json(entry: dict, existing_raw: str) -> tuple[str, str]:
    """(standalone-запись, полный merged-массив) с round-trip через фактический парсер.

    D7: существующие записи verbatim + новая в конец; уже зарегистрированный проект
    (тот же plane_project_id или repo) не дублируется. Несоответствие round-trip →
    RuntimeError (никогда не отдаём оператору строку, которую парсер отвергнет).
    """
    try:
        existing = json.loads(existing_raw) if existing_raw and existing_raw.strip() else []
    except (ValueError, TypeError):
        existing = []
    if not isinstance(existing, list):
        existing = []

    already = any(
        isinstance(item, dict)
        and (
            item.get("plane_project_id") == entry["plane_project_id"]
            or item.get("repo") == entry["repo"]
        )
        for item in existing
    )
    merged_list = list(existing) if already else list(existing) + [entry]
    merged = json.dumps(merged_list, ensure_ascii=False)

    parsed = _parse_projects_json(merged)
    if parsed is None:
        raise RuntimeError("merged ORCH_PROJECTS_JSON failed the registry parser round-trip")
    wanted = [p for p in parsed if p.plane_project_id == entry["plane_project_id"] or p.repo == entry["repo"]]
    if not already:
        if not wanted:
            raise RuntimeError("new registry entry lost in the round-trip")
        got = wanted[0]
        if (
            got.repo != entry["repo"]
            or got.work_item_prefix != entry["work_item_prefix"]
            or got.name != entry["name"]
        ):
            raise RuntimeError("registry round-trip distorted the new entry fields")
    if len(parsed) < len([i for i in existing if isinstance(i, dict)]):
        raise RuntimeError("registry round-trip lost existing entries")

    return json.dumps(entry, ensure_ascii=False), merged


def read_existing_registry(env_file: str | None = None) -> str:
    """Существующие записи реестра: env (settings) > --env-file/.env (read-only)."""
    raw = getattr(settings, "projects_json", "") or ""
    if raw.strip():
        return raw
    path = env_file or os.path.join(_REPO_ROOT, ".env")
    if os.path.isfile(path):
        try:
            with open(path, encoding="utf-8") as f:  # read-only: .env НИКОГДА не пишем (NFR-2)
                for line in f:
                    line = line.strip()
                    if line.startswith("ORCH_PROJECTS_JSON="):
                        return line.split("=", 1)[1]
        except OSError as e:
            logger.warning("cannot read %s: %s", path, e)
    return ""


# --------------------------------------------------------------------------- #
# Тонкие клиенты — единственные точки сети (инжектируются, в тестах фейки)
# --------------------------------------------------------------------------- #

class PlaneClient:
    """Тонкий клиент Plane API (паттерн URL — src/plane_sync.py)."""

    def __init__(self, base_url: str, token: str, workspace: str):
        self.base = f"{base_url.rstrip('/')}/api/v1"
        self.headers = {"X-API-Key": token}
        self.ws = workspace

    def _get(self, url: str):
        resp = httpx.get(url, headers=self.headers, timeout=15)
        if resp.status_code == 200:
            return resp.json()
        if resp.status_code in (403, 404, 405, 501):
            return None
        resp.raise_for_status()
        return None

    def _post(self, url: str, payload: dict) -> dict:
        resp = httpx.post(url, headers=self.headers, json=payload, timeout=20)
        if resp.status_code in (200, 201):
            return resp.json()
        if resp.status_code in (401, 403, 404, 405, 501):
            raise ManualStep(f"Plane API refused POST ({resp.status_code}): {url}")
        resp.raise_for_status()
        raise ManualStep(f"Plane API unexpected status {resp.status_code}")

    def get_project(self, project_id: str):
        if not project_id:
            return None
        return self._get(f"{self.base}/workspaces/{self.ws}/projects/{project_id}/")

    def find_project_by_identifier(self, identifier: str):
        data = self._get(f"{self.base}/workspaces/{self.ws}/projects/")
        results = data.get("results", data) if isinstance(data, dict) else data
        if not isinstance(results, list):
            return None
        for item in results:
            if isinstance(item, dict) and item.get("identifier") == identifier:
                return item
        return None

    def list_states(self, project_id: str):
        data = self._get(f"{self.base}/workspaces/{self.ws}/projects/{project_id}/states/")
        if data is None:
            return None
        return data.get("results", data) if isinstance(data, dict) else data

    def list_labels(self, project_id: str):
        data = self._get(f"{self.base}/workspaces/{self.ws}/projects/{project_id}/labels/")
        if data is None:
            return None
        return data.get("results", data) if isinstance(data, dict) else data

    def create_project(self, name: str, identifier: str) -> dict:
        return self._post(
            f"{self.base}/workspaces/{self.ws}/projects/",
            {"name": name, "identifier": identifier},
        )

    def create_state(self, project_id: str, name: str, group: str) -> dict:
        return self._post(
            f"{self.base}/workspaces/{self.ws}/projects/{project_id}/states/",
            {"name": name, "group": group},
        )

    def create_label(self, project_id: str, name: str) -> dict:
        return self._post(
            f"{self.base}/workspaces/{self.ws}/projects/{project_id}/labels/",
            {"name": name},
        )


class GiteaClient:
    """Тонкий клиент Gitea API (паттерн URL — src/gitea.py, src/merge_gate.py)."""

    def __init__(self, base_url: str, token: str):
        self.base = f"{base_url.rstrip('/')}/api/v1"
        self.headers = {"Authorization": f"token {token}"}

    def _get(self, url: str):
        resp = httpx.get(url, headers=self.headers, timeout=15)
        if resp.status_code == 200:
            return resp.json()
        if resp.status_code == 404:
            return None
        if resp.status_code in (401, 403, 405, 501):
            return None
        resp.raise_for_status()
        return None

    def _post(self, url: str, payload: dict) -> dict:
        resp = httpx.post(url, headers=self.headers, json=payload, timeout=20)
        if resp.status_code in (200, 201):
            return resp.json()
        if resp.status_code in (401, 403, 404, 405, 409, 501):
            raise ManualStep(f"Gitea API refused POST ({resp.status_code}): {url}")
        resp.raise_for_status()
        raise ManualStep(f"Gitea API unexpected status {resp.status_code}")

    def get_repo(self, owner: str, repo: str):
        return self._get(f"{self.base}/repos/{owner}/{repo}")

    def list_hooks(self, owner: str, repo: str):
        return self._get(f"{self.base}/repos/{owner}/{repo}/hooks") or []

    def create_repo(self, owner: str, name: str, description: str = "") -> dict:
        # auto_init=False: репо рождается ПУСТЫМ; `main` создаёт initial push (D6).
        payload = {"name": name, "description": description, "auto_init": False, "private": False}
        try:
            return self._post(f"{self.base}/orgs/{owner}/repos", payload)
        except ManualStep:
            # owner — не организация → личный namespace токена / admin-маршрут.
            return self._post(f"{self.base}/user/repos", payload)

    def create_hook(self, owner: str, repo: str, url: str, secret: str, events: list) -> dict:
        return self._post(
            f"{self.base}/repos/{owner}/{repo}/hooks",
            {
                "type": "gitea",
                "active": True,
                "branch_filter": "*",
                "events": list(events),
                "config": {"url": url, "content_type": "json", "secret": secret},
            },
        )

    def get_file_text(self, owner: str, repo: str, path: str):
        data = self._get(
            f"{self.base}/repos/{owner}/{repo}/raw/{urllib.parse.quote(path)}?ref=main"
        )
        return data if isinstance(data, str) else None

    def list_dir(self, owner: str, repo: str, path: str):
        data = self._get(
            f"{self.base}/repos/{owner}/{repo}/contents/{urllib.parse.quote(path)}?ref=main"
        )
        if not isinstance(data, list):
            return None
        return sorted(item.get("name", "") for item in data if isinstance(item, dict))


# --------------------------------------------------------------------------- #
# git: initial push ТОЛЬКО в свежесозданный пустой репо (D6)
# --------------------------------------------------------------------------- #

def default_git_runner(cmd: list, cwd: str) -> int:
    """Единственная точка subprocess: git в каталоге материализации kit."""
    masked = [re.sub(r"://[^@/]+@", "://***@", part) for part in cmd]
    logger.info("git: %s (cwd=%s)", " ".join(masked), cwd)
    return subprocess.run(cmd, cwd=cwd, check=False).returncode  # noqa: S603


def _push_url(gitea_url: str, token: str, owner: str, repo: str) -> str:
    parts = urllib.parse.urlsplit(gitea_url)
    netloc = f"oauth2:{token}@{parts.netloc}" if token else parts.netloc
    return urllib.parse.urlunsplit(
        (parts.scheme, netloc, f"{parts.path.rstrip('/')}/{owner}/{repo}.git", "", "")
    )


def initial_push(workdir: str, owner: str, repo: str, git_runner) -> bool:
    """git init/commit/push материализованного kit в пустой репо. True = успех."""
    url = _push_url(settings.gitea_url, settings.gitea_token, owner, repo)
    commands = [
        ["git", "init", "-q", "-b", "main"],
        ["git", "add", "-A"],
        [
            "git",
            "-c", "user.email=onboarding@orchestrator.local",
            "-c", "user.name=orchestrator-onboarding",
            "commit", "-q", "-m", "feat: onboarding skeleton (ORCH-009 kit)",
        ],
        ["git", "remote", "add", "origin", url],
        ["git", "push", "-q", "-u", "origin", "main"],
    ]
    for cmd in commands:
        rc = git_runner(cmd, workdir)
        if rc not in (0, None):
            logger.error("git step failed (rc=%s): %s", rc, cmd[0:2])
            return False
    return True


# --------------------------------------------------------------------------- #
# Наблюдение текущего состояния (GET-only) и чистый план
# --------------------------------------------------------------------------- #

@dataclasses.dataclass
class Observed:
    """Снимок текущего состояния внешних систем (только GET-пробы)."""

    project: dict | None = None
    states: list | None = None
    labels: list | None = None
    repo: dict | None = None
    hooks: list = dataclasses.field(default_factory=list)


def observe(params: dict, plane, gitea) -> Observed:
    """GET-пробы Plane/Gitea; ошибки чтения → None (план пометит шаг)."""
    obs = Observed()
    pid = str(params.get("PLANE_PROJECT_ID") or "")
    try:
        if pid and not pid.startswith("<"):
            obs.project = plane.get_project(pid)
        if obs.project is None:
            obs.project = plane.find_project_by_identifier(params["WORK_ITEM_PREFIX"])
    except Exception as e:  # GET-проба не должна валить прогон
        logger.warning("plane project probe failed: %s", e)
    project_id = (obs.project or {}).get("id") or pid
    if obs.project is not None and project_id:
        try:
            obs.states = plane.list_states(project_id)
        except Exception as e:
            logger.warning("plane states probe failed: %s", e)
        try:
            obs.labels = plane.list_labels(project_id)
        except Exception as e:
            logger.warning("plane labels probe failed: %s", e)
    try:
        obs.repo = gitea.get_repo(params["GITEA_OWNER"], params["REPO"])
        if obs.repo is not None:
            obs.hooks = gitea.list_hooks(params["GITEA_OWNER"], params["REPO"]) or []
    except Exception as e:
        logger.warning("gitea probe failed: %s", e)
    return obs


def _existing_names(items, key: str = "name") -> set:
    return {
        str(item.get(key, "")).strip()
        for item in (items or [])
        if isinstance(item, dict)
    }


def _hook_exists(hooks: list, webhook_url: str) -> bool:
    for hook in hooks or []:
        config = hook.get("config", {}) if isinstance(hook, dict) else {}
        if config.get("url") == webhook_url:
            return True
    return False


def build_plan(params: dict, observed: Observed, webhook_url: str) -> list:
    """Чистая функция: упорядоченный план шагов закрытого списка BR-1 (без I/O)."""
    steps: list[Step] = []

    def add(step_id, description, status, detail=""):
        steps.append(Step(step_id, description, status, detail))

    # 1. Plane: проект
    if observed.project is not None:
        add("plane.project", f"Plane-проект «{params['PROJECT_NAME']}»", SKIPPED,
            f"id={observed.project.get('id', '?')}")
    else:
        add("plane.project", f"Plane-проект «{params['PROJECT_NAME']}»", PLANNED,
            f"identifier={params['WORK_ITEM_PREFIX']}")

    # 2. Plane: 22 статуса с точными именами (источник — _PLANE_NAME_TO_KEY, D5)
    existing_states = _existing_names(observed.states)
    for name in _PLANE_NAME_TO_KEY:  # порядок словаря = канонический порядок кода
        group = STATE_GROUPS[name]
        if name in existing_states:
            add(f"plane.state:{name}", f"статус «{name}»", SKIPPED, f"group={group}")
        else:
            add(f"plane.state:{name}", f"статус «{name}»", PLANNED, f"group={group}")

    # 3. Plane: лейблы (имена из конфига, D4)
    existing_labels = _existing_names(observed.labels)
    for label in label_names():
        status = SKIPPED if label in existing_labels else PLANNED
        add(f"plane.label:{label}", f"лейбл «{label}»", status)

    # Заведомо ручные шаги Plane (D5): UI-only / уже существующее.
    add("plane.board-order", "порядок статусов на доске (drag-and-drop)", MANUAL,
        f"UI-only шаг — см. {RUNBOOK}")
    add("plane.workspace-webhook", "workspace-webhook Plane", MANUAL,
        f"уже существует (общий на workspace) — только проверка, см. {RUNBOOK}")

    # 4. Gitea: репо
    if observed.repo is not None:
        add("gitea.repo", f"Gitea-репо {params['GITEA_OWNER']}/{params['REPO']}", SKIPPED,
            f"empty={observed.repo.get('empty')}")
    else:
        add("gitea.repo", f"Gitea-репо {params['GITEA_OWNER']}/{params['REPO']}", PLANNED,
            "auto_init=false (пустой; main создаст initial push)")

    # 5. Gitea: per-repo webhook
    if _hook_exists(observed.hooks, webhook_url):
        add("gitea.webhook", "per-repo webhook", SKIPPED, f"url={webhook_url}")
    else:
        add("gitea.webhook", "per-repo webhook", PLANNED,
            f"url={webhook_url}; events={'/'.join(WEBHOOK_EVENTS)}; "
            "HMAC secret — глобальный из env, в гит не попадает")

    # 6. Kit: материализация + initial push (только пустой/свежий репо, D6)
    repo_exists = observed.repo is not None
    repo_empty = bool((observed.repo or {}).get("empty"))
    if not repo_exists or repo_empty:
        add("kit.materialize", "материализация kit (рендер + live-copy канона)", PLANNED,
            f"{len(iter_skeleton_files())} файлов скелета + {'/'.join(LIVE_COPY_DIRS)}")
        add("kit.push", "initial push kit в свежесозданный пустой репо", PLANNED,
            "git init/commit/push -> main (единственный разрешённый push)")
    else:
        add("kit.materialize", "материализация kit", MANUAL,
            f"репо непустой — kit-файлы НИКОГДА не пушатся поверх существующего контента; см. {RUNBOOK}")
        add("kit.push", "initial push kit", MANUAL,
            f"репо непустой — push запрещён (BR-9); см. {RUNBOOK}")

    # 7. Реестр: merged-вывод (применение env + рестарт — операторский шаг, D7)
    add("registry.emit", "запись реестра ORCH_PROJECTS_JSON (merged-вывод)", PLANNED,
        "применение строки в .env + управляемый рестарт — ОПЕРАТОРСКИЙ шаг")
    return steps


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

def _registry_instructions(report: Report, params: dict, env_file: str | None) -> None:
    """Standalone + merged вывод реестра в инструкции отчёта (D7)."""
    try:
        entry = build_registry_entry(params)
        standalone, merged = merged_projects_json(entry, read_existing_registry(env_file))
        report.instructions.append(
            "Добавь/обнови строку в .env оркестратора (полный merged-массив, атомарно): "
            f"ORCH_PROJECTS_JSON={merged}"
        )
        report.instructions.append(f"Standalone-запись нового проекта (справочно): {standalone}")
        report.instructions.append(
            "После правки .env выполни УПРАВЛЯЕМЫЙ рестарт оркестратора (операторский шаг, "
            f"групповое окно для всех проектов) — см. {RUNBOOK}."
        )
    except (RuntimeError, KeyError, ValueError) as e:
        report.add("registry.emit", "запись реестра", ERROR, str(e))


def run_plan(params: dict, plane, gitea, webhook_url: str, webhook_secret: str | None = None) -> Report:
    """Режим plan: GET-пробы + полный план; НИ ОДНОЙ мутации (сеть/диск) — AC-8."""
    report = Report(mode="plan")
    observed = observe(params, plane, gitea)
    for step in build_plan(params, observed, webhook_url):
        report.steps.append(step)
    # Рендер-проверка строго в памяти: ловим неразрешённые плейсхолдеры ДО apply.
    rendered = render_kit_in_memory(params)
    problems = {rel: bad for rel, content in rendered.items() if (bad := find_unresolved(content))}
    if problems:
        report.add("kit.render-check", "скан неразрешённых плейсхолдеров", ERROR, str(problems))
    else:
        report.add("kit.render-check", "скан неразрешённых плейсхолдеров", OK,
                   f"{len(rendered)} файлов отрендерено в памяти, чисто")
    _registry_instructions(report, params, None)
    if webhook_secret is None and not settings.gitea_webhook_secret:
        report.instructions.append(
            "ORCH_GITEA_WEBHOOK_SECRET отсутствует в env — apply сгенерирует и выведет его "
            "для .env (секрет в гит не попадает)."
        )
    return report


def run_apply(
    params: dict,
    plane,
    gitea,
    webhook_url: str,
    git_runner=default_git_runner,
    workdir: str | None = None,
    webhook_secret: str | None = None,
    env_file: str | None = None,
) -> Report:
    """Режим apply: идемпотентный ensure (BR-9). Существующее → skipped; delete нет."""
    report = Report(mode="apply")
    observed = observe(params, plane, gitea)

    # 1. Plane: проект
    project = observed.project
    if project is not None:
        report.add("plane.project", f"Plane-проект «{params['PROJECT_NAME']}»", SKIPPED,
                   f"id={project.get('id', '?')}")
    else:
        try:
            project = plane.create_project(params["PROJECT_NAME"], params["WORK_ITEM_PREFIX"])
            report.add("plane.project", f"Plane-проект «{params['PROJECT_NAME']}»", CREATED,
                       f"id={project.get('id', '?')}")
        except ManualStep as e:
            report.add("plane.project", f"Plane-проект «{params['PROJECT_NAME']}»", MANUAL,
                       f"{e}; создай проект в UI и перезапусти apply с --plane-project-id; см. {RUNBOOK}")
        except Exception as e:
            report.add("plane.project", "Plane-проект", ERROR, str(e))

    project_id = (project or {}).get("id") or str(params.get("PLANE_PROJECT_ID") or "")
    if project_id and not str(project_id).startswith("<"):
        params = dict(params, PLANE_PROJECT_ID=str(project_id))

    # 2. Plane: статусы (точные имена + группы, D5)
    existing_states = _existing_names(observed.states)
    if project is None:
        for name in _PLANE_NAME_TO_KEY:
            report.add(f"plane.state:{name}", f"статус «{name}»", MANUAL,
                       f"нет проекта — создай статусы вручную по таблице runbook; см. {RUNBOOK}")
    else:
        for name in _PLANE_NAME_TO_KEY:
            group = STATE_GROUPS[name]
            if name in existing_states:
                report.add(f"plane.state:{name}", f"статус «{name}»", SKIPPED, f"group={group}")
                continue
            try:
                plane.create_state(project_id, name, group)
                report.add(f"plane.state:{name}", f"статус «{name}»", CREATED, f"group={group}")
            except ManualStep as e:
                report.add(f"plane.state:{name}", f"статус «{name}»", MANUAL,
                           f"{e}; создай вручную (group={group}); см. {RUNBOOK}")
            except Exception as e:
                report.add(f"plane.state:{name}", f"статус «{name}»", ERROR, str(e))

    # 3. Plane: лейблы
    existing_labels = _existing_names(observed.labels)
    if project is None:
        for label in label_names():
            report.add(f"plane.label:{label}", f"лейбл «{label}»", MANUAL,
                       f"нет проекта — создай лейбл вручную; см. {RUNBOOK}")
    else:
        for label in label_names():
            if label in existing_labels:
                report.add(f"plane.label:{label}", f"лейбл «{label}»", SKIPPED)
                continue
            try:
                plane.create_label(project_id, label)
                report.add(f"plane.label:{label}", f"лейбл «{label}»", CREATED)
            except ManualStep as e:
                report.add(f"plane.label:{label}", f"лейбл «{label}»", MANUAL,
                           f"{e}; создай вручную; см. {RUNBOOK}")
            except Exception as e:
                report.add(f"plane.label:{label}", f"лейбл «{label}»", ERROR, str(e))

    # Заведомо ручные шаги Plane (D5).
    report.add("plane.board-order", "порядок статусов на доске (drag-and-drop)", MANUAL,
               f"UI-only шаг — см. {RUNBOOK}")
    report.add("plane.workspace-webhook", "workspace-webhook Plane", MANUAL,
               f"уже существует (общий на workspace) — только проверка, см. {RUNBOOK}")

    # 4. Gitea: репо
    repo = observed.repo
    freshly_created = False
    if repo is not None:
        report.add("gitea.repo", f"Gitea-репо {params['GITEA_OWNER']}/{params['REPO']}", SKIPPED,
                   f"empty={repo.get('empty')}")
    else:
        try:
            repo = gitea.create_repo(params["GITEA_OWNER"], params["REPO"],
                                     str(params.get("PROJECT_DESCRIPTION", "")))
            freshly_created = True
            report.add("gitea.repo", f"Gitea-репо {params['GITEA_OWNER']}/{params['REPO']}",
                       CREATED, "auto_init=false (пустой)")
        except ManualStep as e:
            report.add("gitea.repo", "Gitea-репо", MANUAL, f"{e}; см. {RUNBOOK}")
        except Exception as e:
            report.add("gitea.repo", "Gitea-репо", ERROR, str(e))

    # 5. Gitea: webhook (секрет переиспользуется из env — D6; в отчёте маскируется)
    secret = webhook_secret if webhook_secret is not None else settings.gitea_webhook_secret
    generated = False
    if not secret:
        secret = _secrets.token_hex(20)
        generated = True
    if repo is None:
        report.add("gitea.webhook", "per-repo webhook", MANUAL,
                   f"нет репо — создай webhook вручную; см. {RUNBOOK}")
    elif _hook_exists(observed.hooks, webhook_url):
        report.add("gitea.webhook", "per-repo webhook", SKIPPED, f"url={webhook_url}")
    else:
        try:
            gitea.create_hook(params["GITEA_OWNER"], params["REPO"], webhook_url, secret,
                              WEBHOOK_EVENTS)
            report.add("gitea.webhook", "per-repo webhook", CREATED,
                       f"url={webhook_url}; events={'/'.join(WEBHOOK_EVENTS)}; secret=***")
        except ManualStep as e:
            report.add("gitea.webhook", "per-repo webhook", MANUAL, f"{e}; см. {RUNBOOK}")
        except Exception as e:
            report.add("gitea.webhook", "per-repo webhook", ERROR, str(e))
    if generated:
        report.instructions.append(
            "Сгенерирован новый HMAC-секрет webhook — добавь в .env оркестратора строку: "
            f"ORCH_GITEA_WEBHOOK_SECRET={secret} (в гит НЕ коммитить)."
        )

    # 6. Kit: материализация + initial push ТОЛЬКО в пустой/свежесозданный репо (D6)
    repo_empty = freshly_created or bool((repo or {}).get("empty"))
    if repo is not None and repo_empty:
        try:
            dest = workdir or tempfile.mkdtemp(prefix="onboard-kit-")
            written = materialize_kit(params, dest)
            report.add("kit.materialize", "материализация kit", CREATED,
                       f"{len(written)} файлов -> {dest}")
            if initial_push(dest, params["GITEA_OWNER"], params["REPO"], git_runner):
                report.add("kit.push", "initial push kit в пустой репо", CREATED,
                           "git push origin main (единственный разрешённый push)")
            else:
                report.add("kit.push", "initial push kit", ERROR,
                           f"git-команда вернула ненулевой код; материализованный kit остался в {dest}")
        except (ValueError, FileNotFoundError) as e:
            report.add("kit.materialize", "материализация kit", ERROR, str(e))
    elif repo is not None:
        report.add("kit.materialize", "материализация kit", MANUAL,
                   f"репо непустой — kit поверх существующего контента не пушится (BR-9); см. {RUNBOOK}")
        report.add("kit.push", "initial push kit", MANUAL,
                   f"репо непустой — push запрещён (BR-9); см. {RUNBOOK}")
    else:
        report.add("kit.materialize", "материализация kit", MANUAL,
                   f"нет репо — повтори apply после создания репо; см. {RUNBOOK}")
        report.add("kit.push", "initial push kit", MANUAL, f"нет репо; см. {RUNBOOK}")

    # 7. Реестр (вывод инструкций; .env скрипт НЕ правит — NFR-2)
    report.add("registry.emit", "запись реестра ORCH_PROJECTS_JSON", CREATED,
               "merged-массив выведен в инструкции; применение env + рестарт — операторский шаг")
    _registry_instructions(report, params, env_file)
    return report


# Полнота kit в verify: ключевые файлы, обязанные лежать в репо (FR-5).
VERIFY_KIT_FILES = (
    ".openclaw/agents/analyst.md",
    ".openclaw/agents/architect.md",
    ".openclaw/agents/developer.md",
    ".openclaw/agents/reviewer.md",
    ".openclaw/agents/tester.md",
    ".openclaw/agents/deployer.md",
    "CLAUDE.md",
    "AGENTS.md",
    "CONTRIBUTING.md",
    "README.md",
    "CHANGELOG.md",
    ".env.example",
    "docs/operations/INFRA.md",
)


def run_verify(
    params: dict,
    plane,
    gitea,
    webhook_url: str,
    projects_raw: str | None = None,
    env_file: str | None = None,
) -> Report:
    """Режим verify: GET-пробы + локальные проверки (FR-5). Ничего не мутирует."""
    report = Report(mode="verify")
    observed = observe(params, plane, gitea)

    # 1. Реестр: round-trip фактическим парсером + наличие записи проекта.
    raw = projects_raw if projects_raw is not None else read_existing_registry(env_file)
    parsed = _parse_projects_json(raw or "")
    entry = None
    for p in parsed or []:
        if p.repo == params["REPO"] or p.plane_project_id == str(params.get("PLANE_PROJECT_ID")):
            entry = p
            break
    if entry is None:
        report.add("verify.registry", "запись реестра ORCH_PROJECTS_JSON", GAP,
                   "проект НЕ зарегистрирован (создано, но не зарегистрировано — см. runbook: "
                   "env + управляемый рестарт)")
    elif (
        entry.work_item_prefix != params["WORK_ITEM_PREFIX"]
        or entry.repo != params["REPO"]
    ):
        report.add("verify.registry", "запись реестра", GAP,
                   f"поля записи расходятся с параметрами: {entry}")
    else:
        report.add("verify.registry", "запись реестра ORCH_PROJECTS_JSON", OK,
                   f"prefix={entry.work_item_prefix}, repo={entry.repo}")

    # 2. Статусы: все 22 канонических имени (включая fail-closed Confirm Deploy/STOP).
    if observed.project is None or observed.states is None:
        report.add("verify.plane.states", "статусы Plane-проекта", GAP,
                   "проект/статусы не читаются через API")
    else:
        existing = _existing_names(observed.states)
        missing = [name for name in _PLANE_NAME_TO_KEY if name not in existing]
        if missing:
            report.add("verify.plane.states", "статусы Plane-проекта", GAP,
                       f"отсутствуют: {', '.join(missing)}")
        else:
            report.add("verify.plane.states", "статусы Plane-проекта", OK,
                       f"все {len(_PLANE_NAME_TO_KEY)} канонических имени на месте")

    # 3. Лейблы.
    if observed.labels is None:
        report.add("verify.plane.labels", "лейблы Plane-проекта", GAP, "лейблы не читаются")
    else:
        existing = _existing_names(observed.labels)
        missing = [label for label in label_names() if label not in existing]
        if missing:
            report.add("verify.plane.labels", "лейблы Plane-проекта", GAP,
                       f"отсутствуют: {', '.join(missing)}")
        else:
            report.add("verify.plane.labels", "лейблы Plane-проекта", OK,
                       ", ".join(label_names()))

    # 4. Gitea-webhook.
    if observed.repo is None:
        report.add("verify.gitea.repo", "Gitea-репо", GAP, "репо не найден")
        report.add("verify.gitea.webhook", "per-repo webhook", GAP, "репо не найден")
    else:
        report.add("verify.gitea.repo", "Gitea-репо", OK,
                   f"{params['GITEA_OWNER']}/{params['REPO']}")
        active_hook = any(
            isinstance(h, dict)
            and h.get("config", {}).get("url") == webhook_url
            and h.get("active", False)
            for h in observed.hooks
        )
        if active_hook:
            report.add("verify.gitea.webhook", "per-repo webhook", OK,
                       f"url={webhook_url}, active")
        else:
            report.add("verify.gitea.webhook", "per-repo webhook", GAP,
                       f"активный webhook с url={webhook_url} не найден")

    # 5. Полнота kit в репо + скан неразрешённых плейсхолдеров.
    if observed.repo is not None:
        missing_files = []
        unresolved: dict[str, list] = {}
        for rel in VERIFY_KIT_FILES:
            content = gitea.get_file_text(params["GITEA_OWNER"], params["REPO"], rel)
            if content is None:
                missing_files.append(rel)
                continue
            bad = find_unresolved(content)
            if bad:
                unresolved[rel] = bad
        if missing_files:
            report.add("verify.kit.files", "kit-файлы в репо", GAP,
                       f"отсутствуют: {', '.join(missing_files)}")
        elif unresolved:
            report.add("verify.kit.files", "kit-файлы в репо", GAP,
                       f"неразрешённые плейсхолдеры: {unresolved}")
        else:
            report.add("verify.kit.files", "kit-файлы в репо", OK,
                       f"{len(VERIFY_KIT_FILES)} ключевых файлов на месте, плейсхолдеров нет")

        templates = gitea.list_dir(params["GITEA_OWNER"], params["REPO"], "docs/_templates") or []
        standards = gitea.list_dir(params["GITEA_OWNER"], params["REPO"], "docs/_standards") or []
        if len(templates) >= 16 and len(standards) >= 3:
            report.add("verify.kit.canon", "live-copy канона в репо", OK,
                       f"_templates={len(templates)}, _standards={len(standards)}")
        else:
            report.add("verify.kit.canon", "live-copy канона в репо", GAP,
                       f"_templates={len(templates)} (>=16?), _standards={len(standards)} (>=3?)")

    # 6. Workspace-webhook Plane: только команда проверки (Ф-6 — существует, не создаём).
    report.add("verify.plane.workspace-webhook", "workspace-webhook Plane", MANUAL,
               f"проверь вручную командой из {RUNBOOK} (создан на уровне workspace)")
    return report


# --------------------------------------------------------------------------- #
# CLI
# --------------------------------------------------------------------------- #

def build_params(args: argparse.Namespace) -> dict:
    """Параметры рендера из аргументов CLI (ключи — словарь placeholders.json)."""
    return {
        "PROJECT_NAME": args.name,
        "PROJECT_DESCRIPTION": args.description,
        "REPO": args.repo,
        "GITEA_OWNER": args.gitea_owner,
        "WORK_ITEM_PREFIX": args.prefix,
        "PLANE_PROJECT_ID": args.plane_project_id or "<assigned-on-apply>",
        "STACK": args.stack,
        "TEST_CMD": args.test_cmd,
        "PROD_PORT": str(args.prod_port),
        "STAGING_PORT": str(args.staging_port),
    }


def main(argv: list | None = None) -> int:
    parser = argparse.ArgumentParser(
        description="Turnkey-онбординг нового проекта в оркестратор (ORCH-009). "
                    f"Полный процесс — {RUNBOOK}.",
    )
    parser.add_argument("mode", nargs="?", default="plan", choices=("plan", "apply", "verify"),
                        help="режим: plan (дефолт, dry-run) / apply / verify")
    parser.add_argument("--name", required=True, help="человекочитаемое имя проекта")
    parser.add_argument("--description", default="", help="1–2 фразы «зачем проект»")
    parser.add_argument("--repo", required=True, help="имя Gitea-репо")
    parser.add_argument("--gitea-owner", default=getattr(settings, "gitea_owner", "admin"),
                        help="owner/org репо в Gitea")
    parser.add_argument("--prefix", required=True, help="префикс work-item (identifier Plane)")
    parser.add_argument("--plane-project-id", default="",
                        help="UUID существующего Plane-проекта (если уже создан)")
    parser.add_argument("--stack", required=True, help="стек проекта (описательно)")
    parser.add_argument("--test-cmd", required=True, help="команда тестов проекта")
    parser.add_argument("--prod-port", required=True, help="порт прод-контура проекта")
    parser.add_argument("--staging-port", required=True, help="порт staging-контура проекта")
    parser.add_argument("--webhook-url", required=True,
                        help="внешний URL приёмника Gitea-webhook оркестратора "
                             "(https://<host>/webhook/gitea)")
    parser.add_argument("--env-file", default=None,
                        help="файл .env с текущим ORCH_PROJECTS_JSON (read-only; дефолт .env корня)")
    parser.add_argument("--json", action="store_true", help="печатать отчёт в JSON")
    args = parser.parse_args(argv)

    logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
    params = build_params(args)

    plane = PlaneClient(settings.plane_api_url, settings.plane_api_token,
                        settings.plane_workspace_slug)
    gitea = GiteaClient(settings.gitea_url, settings.gitea_token)

    if args.mode == "plan":
        report = run_plan(params, plane, gitea, webhook_url=args.webhook_url)
    elif args.mode == "apply":
        report = run_apply(params, plane, gitea, webhook_url=args.webhook_url,
                           env_file=args.env_file)
    else:
        report = run_verify(params, plane, gitea, webhook_url=args.webhook_url,
                            env_file=args.env_file)

    print(json.dumps(report.to_dict(), ensure_ascii=False, indent=2) if args.json
          else report.render_text())
    return report.exit_code


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