#!/usr/bin/env python3
"""kb-objects-audit — еженедельный health-check vault'а.

Проверки:
1. Каждый онлайн-netbird-пир имеет привязку к проекту (через aliases в frontmatter
   или собственную карточку). Orphans = клиентские машины без описания.
2. Каждый projects/<dir>/README.md имеет валидный frontmatter (type, status,
   aliases как минимум).
3. Битые wiki-ссылки `[[...]]` в vault'е (указывают в небытие).
4. Дубли по нечёткому совпадению заголовков (опасные близнецы).

Output: audit/YYYY-MM-DD-objects-audit.md (каждый запуск перезаписывает дневной).

Запуск ручной или cron weekly:
  cd ~/knowledge-base && python3 scripts/kb-objects-audit.py
"""
from datetime import date
from pathlib import Path
import json
import re
import sys

VAULT = Path(__file__).resolve().parent.parent
OBJECTS_MAP = VAULT / "audit/objects-map.json"

REQUIRED_FRONTMATTER_KEYS = ["type", "status"]
PROJECT_FRONTMATTER_KEYS = ["type", "status", "aliases"]


def load_map() -> list:
    if not OBJECTS_MAP.exists():
        sys.exit(f"FATAL: нет {OBJECTS_MAP.relative_to(VAULT)} — запусти scripts/kb-objects-map.py сначала")
    return json.loads(OBJECTS_MAP.read_text())


def parse_fm(text: str) -> dict:
    m = re.match(r"^---\n(.+?)\n---\n", text, re.S)
    if not m:
        return {}
    fm = {}
    for line in m.group(1).splitlines():
        if ":" not in line or line.startswith("#"):
            continue
        k, _, v = line.partition(":")
        fm[k.strip()] = v.strip()
    return fm


def find_md_files() -> list[Path]:
    out = []
    for p in VAULT.rglob("*.md"):
        rel = p.relative_to(VAULT)
        if any(part.startswith(".") for part in rel.parts):
            continue
        if rel.parts[0] in ("audit",):
            # audit-отчёты сами не аудируются
            if "archive" in rel.parts:
                continue
        out.append(p)
    return out


def check_project_frontmatter(objects: list) -> list[str]:
    issues = []
    for o in objects:
        if o["type"] != "project" or not o["file"]:
            continue
        path = VAULT / o["file"]
        if not path.exists():
            issues.append(f"- `{o['id']}`: file missing — `{o['file']}`")
            continue
        fm = parse_fm(path.read_text())
        missing = [k for k in PROJECT_FRONTMATTER_KEYS if k not in fm]
        if missing:
            issues.append(f"- `{o['id']}`: frontmatter missing {missing} — `{o['file']}`")
    return issues


def check_broken_wikilinks(files: list[Path]) -> list[tuple[str, str, str]]:
    """Возвращает [(source, link, reason)] для битых [[...]] ссылок.

    Проверка только полноценных wiki-ссылок [[...]] (двойная скобка с обеих сторон)."""
    issues = []
    all_basenames = {f.stem for f in files}
    all_relpaths = {str(f.relative_to(VAULT)).replace(".md", "") for f in files}
    # match [[target]] / [[target|alias]] / [[target#anchor]]
    pat = re.compile(r"\[\[([^\]\|#\n]+?)(?:[\|#][^\]\n]*)?\]\]")
    for f in files:
        # пропускаем graphify-плагин output и весь audit/ (само-цитирование, autogen)
        rel = f.relative_to(VAULT)
        if "graphify-out" in rel.parts:
            continue
        if rel.parts[0] == "audit":
            continue
        if str(rel) == "CLAUDE.md":
            continue  # обучающие placeholder'ы вроде [[двойные скобки]]
        try:
            text = f.read_text()
        except Exception:
            continue
        for m in pat.finditer(text):
            target = m.group(1).strip().rstrip("/").removesuffix(".md")
            if not target or target in (".", ".."):
                continue
            # Allow absolute by relpath, basename, or relative-to-source
            if target in all_relpaths:
                continue
            base = target.split("/")[-1]
            if base in all_basenames:
                continue
            src_dir = f.parent.relative_to(VAULT)
            resolved = str(src_dir / target).replace("./", "")
            if resolved in all_relpaths:
                continue
            issues.append((str(rel), m.group(0), "→ нет такого файла"))
    return issues


def check_orphans(objects: list) -> list[dict]:
    return [o for o in objects if o["type"] == "netbird-only"
            and o["netbird_peers"] and o["netbird_peers"][0].get("online", True)]


def main() -> None:
    objects = load_map()
    files = find_md_files()

    fm_issues = check_project_frontmatter(objects)
    orphans = check_orphans(objects)
    wiki_issues = check_broken_wikilinks(files)

    # счётчики
    n_proj = sum(1 for o in objects if o["type"] == "project")
    n_proj_with_fm = n_proj - len(fm_issues)
    n_orphan_online = len(orphans)
    n_wiki_broken = len(wiki_issues)
    score = len(fm_issues) * 5 + n_orphan_online * 2 + n_wiki_broken * 3
    today = date.today().isoformat()
    out = VAULT / f"audit/{today}-objects-audit.md"

    md = [
        "---",
        f"date: {today}",
        "type: audit",
        "source: scripts/kb-objects-audit.py",
        "tags: [audit, objects, frontmatter, links]",
        f"score: {score}",
        "---",
        "",
        f"# KB objects audit — {today}",
        "",
        f"**Score (меньше = лучше): `{score}`**",
        "",
        f"- Проектов с frontmatter: **{n_proj_with_fm}/{n_proj}** ({len(fm_issues)} проблем)",
        f"- NetBird online-пиров без проектной карточки: **{n_orphan_online}**",
        f"- Битых wiki-ссылок `[[...]]`: **{n_wiki_broken}**",
        "",
    ]

    md.extend(["## Frontmatter в projects/", ""])
    if fm_issues:
        md.extend(fm_issues)
    else:
        md.append("✅ все проекты имеют валидный frontmatter")
    md.append("")

    md.extend(["## Online netbird-пиры без проектной карточки",
               "",
               "Эти пиры онлайн в NetBird, но не привязаны ни к одной projects/-странице. ",
               "Бот не сможет ответить «найди X» осмысленно — нет файла или alias.",
               "",
               "Лечение: либо создать stub в `projects/<slug>/README.md` (см. `projects/lipki/` как образец), ",
               "либо добавить имя пира как полную строку в `aliases` подходящего проекта.",
               "",
               "| NetBird-имя | IP | OS | Город |",
               "|---|---|---|---|"])
    if orphans:
        for o in orphans:
            p = o["netbird_peers"][0]
            md.append(f"| `{p['name']}` | {p['ip']} | {p.get('os','')} | {p.get('city','')} |")
    else:
        md.append("| — | — | — | — |")
        md.append("")
        md.append("✅ все онлайн-пиры покрыты")
    md.append("")

    md.extend(["## Битые wiki-ссылки", ""])
    if wiki_issues:
        for src, link, reason in wiki_issues[:50]:
            md.append(f"- [{src}]({src}) — `{link}` {reason}")
        if len(wiki_issues) > 50:
            md.append(f"- ... ещё {len(wiki_issues)-50} (truncated до 50)")
    else:
        md.append("✅ битых ссылок не найдено")
    md.append("")

    out.write_text("\n".join(md))
    print(f"Wrote {out.relative_to(VAULT)} (score={score})")
    print(f"  frontmatter issues: {len(fm_issues)}")
    print(f"  orphan online peers: {n_orphan_online}")
    print(f"  broken wiki links: {n_wiki_broken}")


if __name__ == "__main__":
    main()
