"""ORCH-011 (FR-7 / AC-1…AC-12): анти-дрейф контур витрины системы `docs/overview/`.

Структурные проверки витрины (ADR-001 D6, образец — `tests/test_lite_setup_doc.py` /
`test_bundled_setup_doc.py`): 10 файлов D1 существуют и непусты (TC-01); индекс несёт
маршруты трёх аудиторий и норматив сопровождения, из него достижимы все части (TC-02);
бизнес-часть несёт 5 обязательных смысловых разделов и ≥5 сценариев (AC-2);
карта стадий и гейтов derive-сверяется импортом `src.stages.STAGE_TRANSITIONS` и
`src.qg.checks.QG_CHECKS` — не статичным списком (TC-03/TC-04, паттерн ORCH-091);
полнота агентов derive из glob `.openclaw/agents/*.md`, таблица эффортов — из
class-default'ов `src.config.Settings` (TC-05, ORCH-41/81); все относительные ссылки
резолвятся, обязательные golden-source ссылки AC-6 присутствуют (TC-06); гигиена —
полнотекстовый FORBIDDEN-скан (импорт из `tests/test_no_host_hardcodes.py`, не копия) +
секрет-эвристика + запрет вне-репозиторных путей (TC-07, AC-12); слайдо-источник
валидируется каноническим парсером `parse_slides` из `scripts/build_presentation.py`
(TC-08, один парсер — один источник истины о формате, прецедент
`test_bootstrap_script.py`); NFR-2 машинно: `pptx` отсутствует в `requirements*` /
`Dockerfile` (TC-09); указатели README/CLAUDE/CHANGELOG обновлены (TC-10).
Детерминировано: без сети/LLM/subprocess. Новый QG НЕ регистрируется (ТЗ §6) —
модуль исполняется существующими гейтами (`check_ci_green` / `check_tests_passed`).
"""

import ast
import importlib.util
import re
import sys
from pathlib import Path

from src.config import Settings
from src.qg.checks import QG_CHECKS
from src.stages import STAGE_TRANSITIONS

# Один источник истины запрещённых боевых литералов (ORCH-101 AC-7).
from tests.test_no_host_hardcodes import FORBIDDEN

REPO_ROOT = Path(__file__).resolve().parents[1]
OVERVIEW = REPO_ROOT / "docs" / "overview"
AGENTS_DIR = REPO_ROOT / ".openclaw" / "agents"
BUILD_SCRIPT = REPO_ROOT / "scripts" / "build_presentation.py"

# Нормативный состав витрины (ADR-001 D1: плоский каталог, 10 файлов).
SHOWCASE_FILES: tuple[str, ...] = (
    "README.md",
    "business.md",
    "tech-architecture.md",
    "tech-pipeline.md",
    "tech-agents.md",
    "tech-data-model.md",
    "tech-integrations.md",
    "tech-quality-security.md",
    "tech-observability.md",
    "presentation.md",
)

# Под-гейты ребра deploy-staging→deploy: фактические имена реестра QG_CHECKS в
# нормативном порядке security → merge → coverage → image-freshness (adr-0029).
SUBGATES_IN_ORDER: tuple[str, ...] = (
    "check_security_gate",
    "check_branch_mergeable",
    "check_coverage_gate",
    "check_staging_image_fresh",
)

# Обязательные golden-source ссылки витрины (AC-6), repo-relative POSIX.
MANDATORY_LINK_TARGETS: tuple[str, ...] = (
    "docs/architecture/README.md",
    "docs/architecture/internals.md",
    "docs/_standards/PIPELINE_DOCS.md",
    "docs/_standards/HANDOFF_PROTOCOL.md",
    "docs/architecture/adr",
    "docs/deployment/LITE_SETUP.md",
    "docs/deployment/BUNDLED_SETUP.md",
    "docs/PRODUCT_VISION.md",
    "CLAUDE.md",
)

# Маркдаун-ссылка: [текст](цель) — цель без пробелов.
_LINK_RE = re.compile(r"\[[^\]]*\]\(([^)\s]+)\)")

# Секрет-эвристика (паттерн ORCH-102 D8): hex-run >= 32 / чистый alnum-run >= 40.
_SECRET_HEX_RE = re.compile(r"\b[0-9a-fA-F]{32,}\b")
_SECRET_ALNUM_RE = re.compile(r"\b[A-Za-z0-9]{40,}\b")


# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
def _read(name: str) -> str:
    """Текст файла витрины; падает с внятным сообщением, если файла нет."""
    path = OVERVIEW / name
    assert path.is_file(), f"docs/overview/{name} отсутствует (D1 / AC-1)"
    return path.read_text(encoding="utf-8")


def _stage_token_re(stage: str) -> re.Pattern:
    """Регэксп стадии с границами: `deploy` не матчится внутри `deploy-staging`,
    `review` — внутри `reviewer`/`12-review.md` (дефис и \\w исключены с обеих сторон)."""
    return re.compile(rf"(?<![\w-]){re.escape(stage)}(?![\w-])")


def _first_stage_pos(text: str, stage: str) -> int:
    """Позиция первого вхождения стадии (с границами) или -1."""
    m = _stage_token_re(stage).search(text)
    return m.start() if m else -1


def _main_chain() -> list[str]:
    """Основная цепочка стадий, derive проходом по `next` от created (не статика)."""
    chain: list[str] = []
    stage = "created"
    while stage is not None:
        chain.append(stage)
        stage = STAGE_TRANSITIONS[stage]["next"]
    return chain


def _rel_link_targets(name: str) -> list[str]:
    """Относительные цели md-ссылок файла (внешние URL и якоря — мимо)."""
    targets: list[str] = []
    for target in _LINK_RE.findall(_read(name)):
        if target.startswith(("http://", "https://", "mailto:", "#")):
            continue
        targets.append(target.split("#", 1)[0])
    return [t for t in targets if t]


def _load_build_module():
    """Импорт scripts/build_presentation.py по файлу (прецедент test_bootstrap_script):
    top-level скрипта обязан быть stdlib-only, иначе сам импорт здесь упадёт."""
    spec = importlib.util.spec_from_file_location("build_presentation", BUILD_SCRIPT)
    mod = importlib.util.module_from_spec(spec)
    # Регистрация в sys.modules ДО exec: dataclass резолвит строковые аннотации
    # (`from __future__ import annotations`) через sys.modules[cls.__module__].
    sys.modules[spec.name] = mod
    spec.loader.exec_module(mod)
    return mod


# ---------------------------------------------------------------------------
# TC-01: все 10 файлов витрины существуют и непусты (AC-1 / D1).
# ---------------------------------------------------------------------------
def test_all_showcase_files_exist_and_nonempty():
    for name in SHOWCASE_FILES:
        text = _read(name).strip()
        assert len(text) > 300, f"docs/overview/{name} подозрительно пуст (D1)"


# ---------------------------------------------------------------------------
# TC-02: индекс — маршруты 3 аудиторий, норматив, достижимость всех частей (AC-1/8/9).
# ---------------------------------------------------------------------------
def test_index_carries_three_audience_routes():
    text = _read("README.md")
    for route in ("Я заказчик", "Я менеджер", "Я разработчик"):
        assert route in text, f"маршрут {route!r} отсутствует в индексе (FR-5 / AC-8)"


def test_index_carries_maintenance_normative():
    text = _read("README.md")
    assert "в том же PR" in text, (
        "норматив сопровождения «изменил функциональность → обнови витрину "
        "в том же PR» отсутствует в индексе (FR-6 / AC-9)"
    )


def test_index_links_reach_every_showcase_part():
    targets = {t.removeprefix("./") for t in _rel_link_targets("README.md")}
    for name in SHOWCASE_FILES[1:]:
        assert name in targets, (
            f"{name} недостижим из индекса по относительной ссылке (AC-1)"
        )


# ---------------------------------------------------------------------------
# TC-03: бизнес-часть — 5 обязательных разделов + >=5 сценариев (AC-2).
# ---------------------------------------------------------------------------
def test_business_part_has_five_mandatory_sections():
    text = _read("business.md")
    for marker in ("## Проблема", "## Решение", "Что умеет", "## Ценность", "## Сценарии"):
        assert marker in text, f"бизнес-раздел {marker!r} отсутствует (FR-2 / AC-2)"


def test_business_part_has_at_least_five_scenarios():
    text = _read("business.md")
    count = text.count("### Сценарий")
    assert count >= 5, f"в business.md {count} сценариев, требуется >= 5 (FR-2 / AC-2)"


# ---------------------------------------------------------------------------
# TC-04: 7 тех-блоков присутствуют (через TC-01) + схема потока в блоке 1 (AC-3).
# ---------------------------------------------------------------------------
def test_architecture_block_carries_flow_diagram():
    text = _read("tech-architecture.md")
    fenced = re.findall(r"```[^\n]*\n(.*?)```", text, flags=re.DOTALL)
    assert fenced, "tech-architecture.md не несёт ни одного fenced-блока со схемой (AC-3)"
    flow = [b for b in fenced if ("вебхук" in b.lower() or "webhook" in b.lower()) and "→" in b]
    assert flow, (
        "схема потока «вебхук → очередь → агент → гейт → переход» отсутствует "
        "в tech-architecture.md (FR-3.1 / AC-3)"
    )


# ---------------------------------------------------------------------------
# TC-05 (план TC-05): карта стадий = код, derive из STAGE_TRANSITIONS (AC-4).
# ---------------------------------------------------------------------------
def test_every_stage_from_code_is_mentioned_in_pipeline_doc():
    text = _read("tech-pipeline.md")
    missing = [s for s in STAGE_TRANSITIONS if _first_stage_pos(text, s) == -1]
    assert not missing, (
        f"стадии из src.stages.STAGE_TRANSITIONS отсутствуют в tech-pipeline.md "
        f"(AC-4): {missing}"
    )


def test_main_chain_order_in_pipeline_doc_matches_code():
    text = _read("tech-pipeline.md")
    chain = _main_chain()
    positions = [_first_stage_pos(text, s) for s in chain]
    assert -1 not in positions, "стадия основной цепочки не найдена в tech-pipeline.md"
    assert positions == sorted(positions), (
        f"порядок первых вхождений стадий {chain} в tech-pipeline.md противоречит "
        f"коду (AC-4): позиции {positions}"
    )


# ---------------------------------------------------------------------------
# TC-06 (план TC-06): гейты = код; под-гейты в нормативном порядке (AC-4).
# ---------------------------------------------------------------------------
def test_every_exit_gate_from_code_is_named_in_pipeline_doc():
    text = _read("tech-pipeline.md")
    exit_gates = {t["qg"] for t in STAGE_TRANSITIONS.values() if t["qg"]}
    missing = sorted(g for g in exit_gates if g not in text)
    assert not missing, f"exit-гейты рёбер не названы в tech-pipeline.md (AC-4): {missing}"


def test_no_invented_gate_names_anywhere_in_showcase():
    """Каждое упомянутое имя check_* существует в реестре QG_CHECKS (анти-выдумка)."""
    for name in SHOWCASE_FILES:
        mentioned = set(re.findall(r"check_[a-z_]+[a-z]", _read(name)))
        unknown = sorted(mentioned - set(QG_CHECKS))
        assert not unknown, (
            f"docs/overview/{name} упоминает несуществующие гейты (AC-4): {unknown}"
        )


def test_subgates_in_normative_order_and_marked_as_insets():
    text = _read("tech-pipeline.md")
    for gate in SUBGATES_IN_ORDER:
        assert gate in QG_CHECKS, f"под-гейт {gate} исчез из реестра QG_CHECKS"
    positions = [text.find(g) for g in SUBGATES_IN_ORDER]
    assert -1 not in positions, (
        f"под-гейт ребра deploy-staging→deploy не назван в tech-pipeline.md: "
        f"{[g for g, p in zip(SUBGATES_IN_ORDER, positions) if p == -1]}"
    )
    assert positions == sorted(positions), (
        "под-гейты идут не в нормативном порядке security → merge → coverage → "
        "image-freshness (adr-0029 / AC-4)"
    )
    assert "не стадии" in text, (
        "tech-pipeline.md обязан явно помечать под-гейты как врезки в переход, "
        "«не стадии» (AC-4)"
    )


# ---------------------------------------------------------------------------
# TC-07 (план TC-07): полнота агентов derive из glob; эффорты = config (AC-5).
# ---------------------------------------------------------------------------
def test_every_agent_prompt_stem_is_covered():
    stems = sorted(p.stem for p in AGENTS_DIR.glob("*.md"))
    assert stems, ".openclaw/agents/*.md не найдены — glob сломан"
    text = _read("tech-agents.md")
    missing = [s for s in stems if s not in text]
    assert not missing, f"роли из .openclaw/agents/ не описаны в tech-agents.md: {missing}"


def test_effort_table_matches_config_class_defaults():
    """Таблица модель/эффорт сходится с class-default'ами Settings (ORCH-41/81)."""
    text = _read("tech-agents.md")
    table_rows = [ln for ln in text.splitlines() if ln.strip().startswith("|")]
    assert table_rows, "tech-agents.md не несёт таблицы модель/эффорт (AC-5)"
    model_default = Settings.model_fields["agent_model_default"].default
    assert model_default in text, (
        f"дефолтная модель {model_default!r} (config) не упомянута в tech-agents.md"
    )
    effort_default = Settings.model_fields["agent_effort_default"].default
    for stem in sorted(p.stem for p in AGENTS_DIR.glob("*.md")):
        fld = Settings.model_fields.get(f"agent_effort_{stem}")
        effort = (fld.default if fld else "") or effort_default
        role_rows = [ln for ln in table_rows if stem in ln]
        assert role_rows, f"строка таблицы для роли {stem!r} отсутствует (AC-5)"
        assert any(f"`{effort}`" in ln for ln in role_rows), (
            f"эффорт роли {stem!r} в таблице разъехался с config "
            f"(ожидается `{effort}`, ORCH-81)"
        )


# ---------------------------------------------------------------------------
# TC-08 (план TC-08/TC-09): валидность ссылок + обязательные golden sources (AC-6).
# ---------------------------------------------------------------------------
def test_all_relative_links_resolve_to_existing_files():
    broken: list[str] = []
    for name in SHOWCASE_FILES:
        for target in _rel_link_targets(name):
            if not (OVERVIEW / target).resolve().exists():
                broken.append(f"{name}: {target}")
    assert not broken, "битые относительные ссылки витрины (AC-6):\n" + "\n".join(broken)


def test_mandatory_golden_source_links_present():
    resolved: set[str] = set()
    for name in SHOWCASE_FILES:
        for target in _rel_link_targets(name):
            path = (OVERVIEW / target).resolve()
            if path.exists():
                resolved.add(path.relative_to(REPO_ROOT).as_posix())
    missing = [t for t in MANDATORY_LINK_TARGETS if t not in resolved]
    assert not missing, f"обязательные ссылки на golden sources отсутствуют (AC-6): {missing}"


def test_no_out_of_repo_references():
    """AC-12: витрина самодостаточна — никаких ссылок на вне-репозиторные пути."""
    for name in SHOWCASE_FILES:
        text = _read(name)
        for needle in ("tasks/", "memory/"):
            assert needle not in text, (
                f"docs/overview/{name} ссылается на вне-репозиторный путь "
                f"{needle!r} (AC-12)"
            )


# ---------------------------------------------------------------------------
# TC-09 (план TC-13): гигиена — FORBIDDEN-скан полнотекстом + секрет-эвристика.
# ---------------------------------------------------------------------------
def test_showcase_carries_no_forbidden_host_literals():
    """Полнотекстовый скан (шире fenced-скана ORCH-102 — обоснование ADR D6)."""
    offenders = [
        f"{name}: {literal!r}"
        for name in SHOWCASE_FILES
        for literal in FORBIDDEN
        if literal in _read(name)
    ]
    assert not offenders, (
        "боевые хост-литералы в витрине (NFR-3 / AC-11):\n" + "\n".join(offenders)
    )


def test_showcase_carries_no_secret_like_values():
    offenders = []
    for name in SHOWCASE_FILES:
        for rx in (_SECRET_HEX_RE, _SECRET_ALNUM_RE):
            m = rx.search(_read(name))
            if m is not None:
                offenders.append(f"{name}: {m.group(0)[:16]}…")
    assert not offenders, (
        "секретоподобные значения в витрине (NFR-3):\n" + "\n".join(offenders)
    )


def test_secret_heuristic_is_not_evergreen():
    """Негативный самочек (паттерн ORCH-101/102): эвристика реально ловит."""
    assert _SECRET_HEX_RE.search("token=" + "0f" * 20) is not None
    assert _SECRET_ALNUM_RE.search("bot" + "Q7" * 25) is not None
    assert _SECRET_HEX_RE.search("обычный текст витрины 8500/8501") is None
    assert _SECRET_ALNUM_RE.search("`check_staging_image_fresh` и `STAGE_TRANSITIONS`") is None


# ---------------------------------------------------------------------------
# TC-10 (план TC-10): слайдо-источник через канонический парсер (AC-7 / D4).
# ---------------------------------------------------------------------------
def test_build_script_toplevel_imports_are_stdlib_only():
    """Ленивый импорт pptx (D4): top-level скрипта не тянет python-pptx."""
    tree = ast.parse(BUILD_SCRIPT.read_text(encoding="utf-8"))
    top_imports: set[str] = set()
    for node in tree.body:
        if isinstance(node, ast.Import):
            top_imports.update(alias.name.split(".")[0] for alias in node.names)
        elif isinstance(node, ast.ImportFrom):
            top_imports.add((node.module or "").split(".")[0])
    assert "pptx" not in top_imports, (
        "scripts/build_presentation.py импортирует pptx на top-level — "
        "parse_slides обязан работать без python-pptx (D4)"
    )


def test_presentation_source_parses_with_canonical_parser():
    mod = _load_build_module()
    slides = mod.parse_slides(_read("presentation.md"))
    assert len(slides) >= 12, (
        f"слайдов {len(slides)}, требуется >= 12 (FR-4: ориентир 14–18)"
    )
    assert [s.number for s in slides] == list(range(1, len(slides) + 1)), (
        "нумерация слайдов не сквозная с 1 (D4)"
    )
    for s in slides:
        assert s.title.strip(), f"слайд {s.number}: пустой заголовок"
        assert s.bullets, f"слайд {s.number}: ни одного тезиса (D4: 3–6 тезисов)"


def test_presentation_covers_mandatory_narrative_bits():
    low = _read("presentation.md").lower()
    for bit in ("проблем", "решени", "конвейер", "сценари", "тираж", "статус"):
        assert bit in low, f"нормативный бит нарратива {bit!r} отсутствует (FR-4 / AC-7)"


def test_presentation_covers_lite_and_plane_usage_bits():
    """ORCH-105 (FR-5 / AC-4): новый обязательный контент презентации
    зафиксирован анти-дрейфом — бесследное удаление слайда Lite-установки или
    слайдов «как пользоваться орком через Plane» рвёт CI. Матч по lower-case
    подстрокам (как `test_presentation_covers_mandatory_narrative_bits`);
    маркеры — семантические корни и дословные имена статусов/лейблов (анти-
    переобучение к формулировкам), все вне FORBIDDEN/секрет-эвристики."""
    low = _read("presentation.md").lower()

    # Слайд Lite-установки: корень `lite` + маркер развёртывания (FR-1 / AC-1).
    assert "lite" in low, "слайд про Lite-установку отсутствует (FR-1 / AC-1)"
    assert any(m in low for m in ("установк", "разверн")), (
        "слайд Lite не несёт маркера установки/развёртывания (FR-1 / AC-1)"
    )

    # Слайды использования через Plane: корень `plane` + операторские маркеры.
    # Точка входа, оба человеческих гейта и отмена — дословными именами статусов
    # (FR-2 / AC-2; имена сверены с tech-pipeline.md / tech-integrations.md).
    assert "plane" in low, "слайды использования через Plane отсутствуют (FR-2 / AC-2)"
    assert "to analyse" in low, (
        "слайды Plane-usage не называют точку входа «To Analyse» (FR-2 / AC-2)"
    )
    assert "approved" in low, (
        "слайды Plane-usage не называют человеческий гейт «Approved» (FR-2 / AC-2)"
    )
    assert "confirm deploy" in low, (
        "слайды Plane-usage не называют гейт прод-выкладки «Confirm Deploy» (FR-2 / AC-2)"
    )
    assert "stop" in low, (
        "слайды Plane-usage не называют отмену задачи «STOP» (FR-2 / AC-2)"
    )


def test_presentation_carries_reproducible_build_procedure():
    text = _read("presentation.md")
    assert "build_presentation.py" in text, (
        "процедура сборки .pptx не ссылается на скрипт (AC-7)"
    )
    assert "Проверка" in text, (
        "процедура сборки не несёт явных маркеров «Проверка:» (канон LITE_SETUP)"
    )


# ---------------------------------------------------------------------------
# TC-11 (план TC-11): NFR-2 машинно — pptx не в прод-образе.
# ---------------------------------------------------------------------------
def test_no_pptx_dependency_in_prod_image():
    files = sorted(REPO_ROOT.glob("requirements*")) + [REPO_ROOT / "Dockerfile"]
    assert files, "requirements*/Dockerfile не найдены — скан пуст"
    offenders = [
        p.name for p in files if "pptx" in p.read_text(encoding="utf-8").lower()
    ]
    assert not offenders, (
        f"зависимость генерации презентации попала в прод-образ (NFR-2): {offenders}"
    )


# ---------------------------------------------------------------------------
# TC-12 (план TC-12): указатели репо — README / CLAUDE.md / CHANGELOG (AC-9/AC-11).
# ---------------------------------------------------------------------------
def test_repo_readme_links_overview():
    assert "docs/overview/" in (REPO_ROOT / "README.md").read_text(encoding="utf-8"), (
        "README.md не ссылается на витрину docs/overview/ (AC-11)"
    )


def test_claude_md_carries_overview_pointer_and_normative():
    text = (REPO_ROOT / "CLAUDE.md").read_text(encoding="utf-8")
    assert "docs/overview/" in text, "CLAUDE.md не несёт указатель на витрину (AC-9)"


def test_changelog_has_orch_011_entry():
    assert "ORCH-011" in (REPO_ROOT / "CHANGELOG.md").read_text(encoding="utf-8"), (
        "CHANGELOG.md не несёт docs:-записи по ORCH-011 (AC-11)"
    )
