#!/usr/bin/env python3
"""
Auditor — Stufe 1: Scan (deterministisch)

Scannt den Vault im definierten Scope und schreibt pro Eval eine Kandidaten-Liste
nach scan.json. Der Judge (Stufe 2) bewertet danach.

Importiert Parser-Helfer aus vault-gap-analysis über den Bundle-internen
Cross-Skill-Resolver (_lib/cross_skill.import_helper).

Aufruf:
    python scan.py [vault_path] [output_path]
    python scan.py --vault <pfad> --output <pfad> --config <pfad>
"""

from __future__ import annotations

import argparse
import json
import os
import re
import sys
import time
from collections import defaultdict
from datetime import datetime, timezone
from pathlib import Path

# --- Cross-Skill-Import ---
# sys.path-Trick damit _lib aus Skript-Kontext erreichbar ist.
_SKILL_DIR = Path(__file__).resolve().parent
_SKILLS_ROOT = _SKILL_DIR.parent
if str(_SKILLS_ROOT) not in sys.path:
    sys.path.insert(0, str(_SKILLS_ROOT))

# Modul-Level-Globals für Cross-Skill-Helfer (via _load_cross_skill gesetzt)
parse_frontmatter = None
extract_wikilinks = None
build_link_index = None
tokenize = None
_CROSS_SKILL_MISSING_HELPERS: list[str] = []
_CROSS_SKILL_AVAILABLE: bool = False


def _load_cross_skill(bundle_root: "Path | None" = None) -> None:
    """Lädt vault-gap-analysis-Helfer und setzt Modul-Level-Globals.

    Args:
        bundle_root: Optionaler Override für den Bundle-Root-Pfad.
            None → Standard-Ableitung aus Datei-Position in cross_skill.py.
            Für Tests via --bundle-root CLI-Argument nützlich.
    """
    global parse_frontmatter, extract_wikilinks, build_link_index, tokenize
    global _CROSS_SKILL_MISSING_HELPERS, _CROSS_SKILL_AVAILABLE

    from _lib.cross_skill import import_helper as _import_helper

    # Cache-Key enthält keinen bundle_root-Pfad → bei Override alten Eintrag entfernen
    cache_key = "_bundle_skill_vault_gap_analysis_graph_analyzer"
    if bundle_root is not None:
        sys.modules.pop(cache_key, None)

    try:
        _gap = _import_helper("vault-gap-analysis", "graph_analyzer", bundle_root=bundle_root)
        # Helfer mit getattr(fallback=None) laden — fehlende Helfer → Degradierung statt Abbruch
        parse_frontmatter = getattr(_gap, "parse_frontmatter", None)
        extract_wikilinks = getattr(_gap, "extract_wikilinks", None)
        build_link_index = getattr(_gap, "build_link_index", None)
        tokenize = getattr(_gap, "tokenize", None)
        _CROSS_SKILL_MISSING_HELPERS = [
            fn for fn, val in [
                ("parse_frontmatter", parse_frontmatter),
                ("extract_wikilinks", extract_wikilinks),
                ("build_link_index", build_link_index),
                ("tokenize", tokenize),
            ] if not callable(val)
        ]
        _CROSS_SKILL_AVAILABLE = len(_CROSS_SKILL_MISSING_HELPERS) == 0
    except FileNotFoundError as _e:
        print(
            f"FEHLER: Cross-Skill-Modul vault-gap-analysis nicht gefunden.\n"
            f"  {_e}\n"
            f"  Bitte sicherstellen, dass der vault-gap-analysis-Skill installiert ist.",
            file=sys.stderr,
        )
        sys.exit(1)
    except Exception as _e:
        print(
            f"FEHLER: vault-gap-analysis konnte nicht geladen werden: {_e}",
            file=sys.stderr,
        )
        sys.exit(1)

    if _CROSS_SKILL_MISSING_HELPERS:
        print(
            f"HINWEIS: vault-gap-analysis fehlen Helfer-Funktionen: {_CROSS_SKILL_MISSING_HELPERS}. "
            f"Betroffene Scan-Schritte werden übersprungen.",
            file=sys.stderr,
        )

# Loader-API
from _lib.config_loader import load_skill_config  # noqa: E402

# --- Scope-Defaults (überschreibbar via skill.config.yaml) ---

DEFAULT_SCOPE_ROOTS = (
    "00 Kontext/",
    ".claude/skills/",
    "04 Ressourcen/KI & Automation/",
    "02 Projekte/",
    "03 Bereiche/",
)

DEFAULT_SCOPE_EXCLUDES = (
    "05 Logbuch/",
    "06 Archiv/",
    "07 Anhänge/",
    "01 Inbox/",
    "04 Ressourcen/00_Images/",
    "04 Ressourcen/00_Sources/",
)

EXCLUDE_DIRS = {".obsidian", ".git", ".trash", "__pycache__", "node_modules"}

# Funktionale Types nach Konvention (Agent-Ready-Pflicht)
FUNCTIONAL_TYPES = {
    "bereich", "ressource", "projekt", "unterprojekt", "skill",
    "handover", "routine", "prinzip", "kontext", "index", "notiz",
}

STALENESS_DAYS = 180
JACCARD_TOP_N = 10

E01_EXCLUDED_STEMS = {"Dateiablage"}
E01_EXCLUDED_TYPES = {"handover"}

E06_PLACEHOLDER_PATTERNS = (
    re.compile(r"^Notiz\s*\d+$", re.IGNORECASE),
    re.compile(r"^Link zu", re.IGNORECASE),
    re.compile(r"^Quell-", re.IGNORECASE),
    re.compile(r"^<.*>$"),
    re.compile(r"^\.\.\.$"),
    re.compile(r"^None$"),
    re.compile(r"^Datei(\s+[A-Z])?$"),
)

ARTIFACT_PATH_PATTERNS = (
    re.compile(r"^\.claude/skills/[^/]+-workspace/skill-snapshot/"),
    re.compile(r"^\.claude/skills/meta-skill/fixtures/"),
)


def is_artifact_path(rel_path: str) -> bool:
    return any(p.search(rel_path) for p in ARTIFACT_PATH_PATTERNS)


def strip_code_blocks(content: str) -> str:
    """Entfernt Fenced-Code-Blöcke und Inline-Code."""
    content = re.sub(r"```.*?```", "", content, flags=re.DOTALL)
    content = re.sub(r"`[^`\n]+`", "", content)
    return content


def build_scope_check(scope_roots: tuple, scope_excludes: tuple):
    """Erstellt eine is_in_scope-Funktion aus den Scope-Konfigurationen."""
    def is_in_scope(rel_path: str) -> bool:
        if any(rel_path.startswith(p) for p in scope_excludes):
            return False
        return any(rel_path.startswith(p) for p in scope_roots)
    return is_in_scope


def find_all_md_files(vault_path: Path) -> list[Path]:
    """Alle MD-Dateien im Vault (ohne Scope-Filter)."""
    results = []
    for root, dirs, files in os.walk(vault_path):
        dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS]
        for f in files:
            if f.endswith(".md"):
                results.append(Path(root) / f)
    return results


def build_nonmd_index(vault_path: Path) -> tuple[set[str], set[str], set[str]]:
    """Baut Indizes für Nicht-MD-Dateien und Ordner."""
    paths: set[str] = set()
    names: set[str] = set()
    dirs_index: set[str] = set()
    for root, dirs, files in os.walk(vault_path):
        dirs[:] = [d for d in dirs if d not in EXCLUDE_DIRS]
        root_p = Path(root)
        if root_p != vault_path:
            dirs_index.add(root_p.relative_to(vault_path).as_posix())
        for f in files:
            if f.endswith(".md"):
                continue
            full = Path(root) / f
            rel = full.relative_to(vault_path).as_posix()
            paths.add(rel)
            names.add(f)
    return paths, names, dirs_index


def parse_vault_full(vault_path: Path, md_files: list[Path], is_in_scope) -> dict:
    """Parst alle Dateien. Feld 'in_scope' unterscheidet Kandidaten vs. Linkziele."""
    if not _CROSS_SKILL_AVAILABLE:
        print("HINWEIS: parse_frontmatter/extract_wikilinks nicht verfügbar. Scan liefert leere Ergebnisse.", file=sys.stderr)
        return {}
    data = {}
    for fp in md_files:
        try:
            content = fp.read_text(encoding="utf-8")
        except (UnicodeDecodeError, OSError):
            continue
        rel = fp.relative_to(vault_path).as_posix()
        key = rel[:-3] if rel.endswith(".md") else rel
        fm = parse_frontmatter(content) or {}
        content_no_code = strip_code_blocks(content)
        tags_raw = fm.get("tags", []) or []
        if isinstance(tags_raw, str):
            tags_raw = [tags_raw]
        data[key] = {
            "name": fp.stem,
            "path": rel,
            "in_scope": is_in_scope(rel),
            "links": extract_wikilinks(content_no_code),
            "type": fm.get("type", ""),
            "description": fm.get("description", ""),
            "tags": tags_raw,
            "updated": str(fm.get("updated", "")),
            "created": str(fm.get("created", "")),
            "agent_ready": fm.get("agent_ready"),
            "mtime": fp.stat().st_mtime,
        }
    return data


# --- Eval-Scanner ---

def scan_e01_tote_zahnraeder(vault_data: dict) -> list[dict]:
    """Funktionale Dateien im Scope ohne eingehende Wikilinks."""
    if not _CROSS_SKILL_AVAILABLE or "build_link_index" in _CROSS_SKILL_MISSING_HELPERS:
        return []
    link_index = build_link_index(vault_data)
    incoming: dict[str, int] = defaultdict(int)
    for key, d in vault_data.items():
        for target in d["links"]:
            target = target.replace("\\", "/").strip()
            resolved = link_index.get(target) or link_index.get(target.split("/")[-1])
            if resolved and resolved != key:
                incoming[resolved] += 1
    candidates = []
    for key, d in vault_data.items():
        if not d["in_scope"]:
            continue
        if d["type"] not in FUNCTIONAL_TYPES:
            continue
        if d["name"] in E01_EXCLUDED_STEMS or d["type"] in E01_EXCLUDED_TYPES:
            continue
        if incoming.get(key, 0) == 0:
            candidates.append({
                "path": d["path"],
                "type": d["type"],
                "description": d["description"][:160],
                "incoming_links": 0,
            })
    return candidates


def scan_e02_pflicht_ohne_ausfuehrung(vault_path: Path, vault_data: dict) -> dict:
    """Trigger-Pflichten in CLAUDE.md ohne zugehörigen Skill."""
    claude_md = vault_path / "CLAUDE.md"
    if not claude_md.exists():
        return {"note": "CLAUDE.md nicht gefunden", "trigger_pflichten": [], "fehlend": []}
    text = claude_md.read_text(encoding="utf-8", errors="ignore")

    available_skills = set()
    for d in vault_data.values():
        if d["path"].startswith(".claude/skills/") and d["path"].endswith("/SKILL.md"):
            available_skills.add(d["path"].split("/")[-2])

    bullet_re = re.compile(
        r"-\s+\*\*([^*]+?Trigger[^*]*?Pflicht[^*]*?)\*\*:?\s*(.*?)(?=\n-\s+\*\*|\n\n|\n###)",
        re.DOTALL,
    )
    trigger_pflichten = []
    fehlend = []
    for m in bullet_re.finditer(text):
        name = m.group(1).strip()
        body = m.group(2)
        referenced = set()
        for sk in re.findall(r"\.claude/skills/([A-Za-z0-9_\-]+)", body):
            referenced.add(sk)
        for sk in re.findall(r"Skill\s+`([A-Za-z0-9_\-]+)`", body):
            referenced.add(sk)
        missing = sorted(referenced - available_skills)
        entry = {
            "pflicht": name,
            "referenzierte_skills": sorted(referenced),
            "fehlende_skills": missing,
        }
        trigger_pflichten.append(entry)
        if missing:
            fehlend.append(entry)
    return {
        "trigger_pflichten": trigger_pflichten,
        "fehlend": fehlend,
        "verfuegbare_skills": sorted(available_skills),
        "note": "Gate-Pflichten (Glossar, Updated, Graph, ...) sind bewusst NICHT geprüft — per Design Selbstprüfung ohne Skill.",
    }


def scan_e03_doppelzahnrad(vault_data: dict) -> list[dict]:
    """Top-N Skill-Paare nach Description-Jaccard."""
    if not _CROSS_SKILL_AVAILABLE or "tokenize" in _CROSS_SKILL_MISSING_HELPERS:
        return []
    skills = [
        (key, d) for key, d in vault_data.items()
        if d["path"].startswith(".claude/skills/") and d["path"].endswith("/SKILL.md")
        and d["description"]
        and not is_artifact_path(d["path"])
    ]
    pairs = []
    for i in range(len(skills)):
        _, d_a = skills[i]
        tok_a = tokenize(d_a["description"])
        if not tok_a:
            continue
        for j in range(i + 1, len(skills)):
            _, d_b = skills[j]
            tok_b = tokenize(d_b["description"])
            if not tok_b:
                continue
            jacc = len(tok_a & tok_b) / len(tok_a | tok_b)
            pairs.append({
                "skill_a": d_a["path"],
                "skill_b": d_b["path"],
                "jaccard": round(jacc, 3),
                "desc_a": d_a["description"][:200],
                "desc_b": d_b["description"][:200],
            })
    pairs.sort(key=lambda p: p["jaccard"], reverse=True)
    return pairs[:JACCARD_TOP_N]


def scan_e04_rostige_zahnraeder(vault_data: dict) -> list[dict]:
    """Funktionale Dateien mit updated älter als STALENESS_DAYS."""
    now = datetime.now(tz=timezone.utc)
    candidates = []
    for key, d in vault_data.items():
        if not d["in_scope"]:
            continue
        if d["type"] not in FUNCTIONAL_TYPES:
            continue
        upd = d["updated"]
        try:
            dt = datetime.strptime(upd, "%Y-%m-%d").replace(tzinfo=timezone.utc)
        except (ValueError, TypeError):
            continue
        age_days = (now - dt).days
        if age_days > STALENESS_DAYS:
            candidates.append({
                "path": d["path"],
                "type": d["type"],
                "updated": upd,
                "age_days": age_days,
                "mtime_age_days": int((now.timestamp() - d["mtime"]) / 86400),
            })
    candidates.sort(key=lambda c: c["age_days"], reverse=True)
    return candidates


def scan_e05_geisterskill(vault_path: Path, vault_data: dict) -> dict:
    """Skills im 7-Tage-Fenster ohne nachweisbaren Aufruf-Pfad."""
    now = datetime.now(tz=timezone.utc)
    LOCAL_DAYS = 7

    def parse_iso(s: str):
        if not s:
            return None
        try:
            return datetime.strptime(str(s)[:10], "%Y-%m-%d").replace(tzinfo=timezone.utc)
        except (ValueError, TypeError):
            return None

    skills: list[tuple[str, dict]] = []
    for key, d in vault_data.items():
        if not (d["path"].startswith(".claude/skills/") and d["path"].endswith("/SKILL.md")):
            continue
        if is_artifact_path(d["path"]):
            continue
        skill_name = d["path"].split("/")[-2]
        skills.append((skill_name, d))

    claude_md_text = ""
    claude_md = vault_path / "CLAUDE.md"
    if claude_md.exists():
        claude_md_text = claude_md.read_text(encoding="utf-8", errors="ignore")

    command_names: set[str] = set()
    commands_dir = vault_path / ".claude" / "commands"
    if commands_dir.exists():
        for f in commands_dir.glob("*.md"):
            command_names.add(f.stem)

    settings_text = ""
    settings_json = vault_path / ".claude" / "settings.json"
    if settings_json.exists():
        settings_text = settings_json.read_text(encoding="utf-8", errors="ignore")

    skill_content: dict[str, str] = {}
    for name, d in skills:
        try:
            skill_content[name] = (vault_path / d["path"]).read_text(encoding="utf-8", errors="ignore")
        except (OSError, UnicodeDecodeError):
            skill_content[name] = ""

    geister: list[dict] = []
    checked_in_window: list[str] = []
    for skill_name, d in skills:
        updated_dt = parse_iso(d["updated"])
        created_dt = parse_iso(d["created"])
        reference_dt = updated_dt or created_dt
        if reference_dt is None:
            reference_dt = datetime.fromtimestamp(d["mtime"], tz=timezone.utc)
        age_days = (now - reference_dt).days
        if age_days > LOCAL_DAYS:
            continue
        checked_in_window.append(skill_name)

        aufruf_quellen: list[str] = []
        if skill_name in claude_md_text:
            aufruf_quellen.append("CLAUDE.md")
        if skill_name in command_names:
            aufruf_quellen.append(f"/{skill_name}")
        if skill_name in settings_text:
            aufruf_quellen.append("settings.json")
        for other_name, other_content in skill_content.items():
            if other_name == skill_name:
                continue
            if skill_name in other_content:
                aufruf_quellen.append(f"skill:{other_name}")
                break

        if not aufruf_quellen:
            geister.append({
                "name": skill_name,
                "path": d["path"],
                "description": d["description"][:200],
                "created": d["created"],
                "updated": d["updated"],
                "age_days": age_days,
            })

    return {
        "geisterskills": geister,
        "skills_in_window": checked_in_window,
        "total_skill_count": len(skills),
        "window_local_days": LOCAL_DAYS,
        "note": "Geisterskill = Skill mit updated/created im 7-Tage-Fenster ohne Aufruf-Pfad.",
    }


def scan_e06_tote_links(vault_data: dict, nonmd_paths: set[str], nonmd_names: set[str], dir_paths: set[str]) -> list[dict]:
    """Wikilinks aus Scope-Dateien, die auf nicht-existente Vault-Dateien zeigen."""
    if not _CROSS_SKILL_AVAILABLE or "build_link_index" in _CROSS_SKILL_MISSING_HELPERS:
        return []
    link_index = build_link_index(vault_data)
    dead = []
    for key, d in vault_data.items():
        if not d["in_scope"]:
            continue
        source_dir = str(Path(d["path"]).parent).replace("\\", "/")
        if source_dir == ".":
            source_dir = ""
        seen = set()
        for target in d["links"]:
            target_norm = target.replace("\\", "/").strip().rstrip("/")
            if not target_norm or target_norm in seen:
                continue
            seen.add(target_norm)
            if any(p.match(target_norm) for p in E06_PLACEHOLDER_PATTERNS):
                continue
            if ":" in target_norm and "/" not in target_norm:
                continue
            if target_norm.startswith("memory/"):
                continue
            if target_norm in dir_paths:
                continue
            resolved = link_index.get(target_norm) or link_index.get(target_norm.split("/")[-1])
            if resolved:
                continue
            basename = target_norm.split("/")[-1]
            rel_candidate = f"{source_dir}/{target_norm}" if source_dir else target_norm
            if (target_norm in nonmd_paths
                    or rel_candidate in nonmd_paths
                    or basename in nonmd_names):
                continue
            dead.append({
                "source": d["path"],
                "target": target_norm,
            })
    return dead


def _build_verdacht_set() -> set:
    """Baut die Erkennungs-Wörter für Umlaut-Ersatz-Prüfung.

    Wörter werden aus ihrer Umlaut-Form abgeleitet (Umlaute → ae/oe/ue),
    damit der Scanner-Code selbst keine Umlaut-Ersatz-Strings enthält.
    """
    def _sub(w: str) -> str:
        return (w
                .replace("ä", "ae").replace("ö", "oe").replace("ü", "ue")
                .replace("Ä", "Ae").replace("Ö", "Oe").replace("Ü", "Ue"))

    korrekte_woerter = [
        "für", "über", "können", "müssen", "wählen", "nächst",
        "möchte", "prüft", "prüfung", "lösung", "erklärung",
        "änderung", "überschrift", "übersicht", "grüße", "schön",
    ]
    return {_sub(w) for w in korrekte_woerter}


def scan_s01_umlaute(vault_data: dict) -> list[dict]:
    """Sekundär: ae/oe/ue als Umlaut-Ersatz außerhalb Frontmatter (heuristisch)."""
    verdacht = _build_verdacht_set()
    hits = []
    for key, d in vault_data.items():
        if not d["in_scope"]:
            continue
        for field, val in (("description", d["description"]),):
            if not val:
                continue
            words = re.findall(r"\b\w+\b", val.lower())
            found = [w for w in words if w in verdacht]
            if found:
                hits.append({
                    "path": d["path"],
                    "field": field,
                    "verdaechtig": found,
                })
    return hits


# --- Config-Loader ---

def load_config(vault_root: Path | None = None):
    """Lädt die Skill-Konfiguration."""
    cfg = load_skill_config(
        skill_dir=_SKILL_DIR,
        vault_root=vault_root,
        bundle_root=None,
        default_paths={"reports_dir": "{system}/Auditor/reports"},
    )
    return cfg


def get_scope_tuples(cfg) -> tuple[tuple, tuple]:
    """Liest Scope-Konfiguration aus cfg.raw oder fällt auf Defaults zurück."""
    raw_scope = cfg.raw.get("scope", {}) if hasattr(cfg, "raw") else {}
    # roots und excludes sind relative Ordner-Präfixe, keine Layout-Platzhalter
    roots = tuple(raw_scope.get("roots", list(DEFAULT_SCOPE_ROOTS)))
    excludes = tuple(raw_scope.get("excludes", list(DEFAULT_SCOPE_EXCLUDES)))
    # {placeholder}-Syntax in roots/excludes durch Layout-Werte ersetzen
    layout = cfg.layout if hasattr(cfg, "layout") else {}
    resolved_roots = tuple(_resolve_scope_entry(r, layout) for r in roots)
    resolved_excludes = tuple(_resolve_scope_entry(e, layout) for e in excludes)
    return resolved_roots, resolved_excludes


def _resolve_scope_entry(entry: str, layout: dict) -> str:
    """Löst {placeholder}-Syntax in Scope-Einträgen auf."""
    for key, val in layout.items():
        placeholder = "{" + key + "}"
        if placeholder in entry:
            val_str = str(val).replace("\\", "/")
            entry = entry.replace(placeholder, val_str)
    # Trailing-Slash sicherstellen
    if entry and not entry.endswith("/"):
        entry = entry + "/"
    return entry


# --- Main ---

def main(argv: list[str] | None = None) -> int:
    ap = argparse.ArgumentParser(description="Auditor Scan — Stufe 1.")
    ap.add_argument("vault", nargs="?", help="Vault-Pfad (positional, alt)")
    ap.add_argument("output", nargs="?", help="Output-Pfad (positional, alt)")
    ap.add_argument("--vault", dest="vault_flag", help="Vault-Pfad")
    ap.add_argument("--output", dest="output_flag", help="Output-Pfad für scan.json")
    ap.add_argument("--config", help="Alternative Config-Datei")
    ap.add_argument("--bundle-root", dest="bundle_root", default=None,
                    help="Alternativer Bundle-Root-Pfad (für Tests). Überschreibt die "
                         "Standard-Ableitung aus der Datei-Position von cross_skill.py.")
    args = ap.parse_args(argv)

    bundle_root_override = Path(args.bundle_root) if args.bundle_root else None
    _load_cross_skill(bundle_root=bundle_root_override)

    vault_arg = args.vault_flag or args.vault
    output_arg = args.output_flag or args.output

    vault_path = Path(vault_arg) if vault_arg else None
    cfg = load_config(vault_path)

    if vault_path is None:
        # Vault-Root aus Bundle-Kontext ableiten
        vault_path = cfg.layout.get("context", _SKILL_DIR.parents[3]).parent if cfg.layout else _SKILL_DIR.parents[3]
        # Sicherer Fallback: bundle_root.parent (vault liegt neben bundle)
        bundle_root = _SKILL_DIR.parents[2]
        vault_path = bundle_root.parent

    output_path = Path(output_arg) if output_arg else _SKILL_DIR / "scan.json"

    print(f"Vault: {vault_path}")
    print(f"Output: {output_path}")

    scope_roots, scope_excludes = get_scope_tuples(cfg)
    is_in_scope = build_scope_check(scope_roots, scope_excludes)

    start = time.time()
    md_files = find_all_md_files(vault_path)
    print(f"Alle MD-Dateien: {len(md_files)}")

    vault_data = parse_vault_full(vault_path, md_files, is_in_scope)

    artifact_count = 0
    for d in vault_data.values():
        if d["in_scope"] and is_artifact_path(d["path"]):
            d["in_scope"] = False
            artifact_count += 1
    print(f"Artefakt-Pfade ausgefiltert: {artifact_count}")

    in_scope = sum(1 for d in vault_data.values() if d["in_scope"])
    print(f"Geparst: {len(vault_data)} (davon im Scope: {in_scope})")

    nonmd_paths, nonmd_names, dir_paths = build_nonmd_index(vault_path)

    result = {
        "scan_version": "0.3.0-bundle",
        "generated_at": datetime.now(timezone.utc).isoformat(),
        "vault_path": str(vault_path),
        "files_in_scope": in_scope,
        "scope": {
            "roots": list(scope_roots),
            "excludes": list(scope_excludes),
        },
        "cross_skill_available": _CROSS_SKILL_AVAILABLE,
        "cross_skill_missing_helpers": _CROSS_SKILL_MISSING_HELPERS,
        "eval_results": {
            "E01-tote-zahnräder": scan_e01_tote_zahnraeder(vault_data),
            "E02-pflicht-ohne-ausführung": scan_e02_pflicht_ohne_ausfuehrung(vault_path, vault_data),
            "E03-doppelzahnrad": scan_e03_doppelzahnrad(vault_data),
            "E04-rostige-zahnräder": scan_e04_rostige_zahnraeder(vault_data),
            "E05-geisterskill": scan_e05_geisterskill(vault_path, vault_data),
            "E06-tote-links": scan_e06_tote_links(vault_data, nonmd_paths, nonmd_names, dir_paths),
            "S01-umlaute": scan_s01_umlaute(vault_data),
        },
        "duration_seconds": round(time.time() - start, 2),
    }

    for eval_id, res in result["eval_results"].items():
        if isinstance(res, list):
            print(f"  {eval_id}: {len(res)} Kandidaten")
        else:
            print(f"  {eval_id}: Judge-Input bereitgestellt")

    output_path.parent.mkdir(parents=True, exist_ok=True)
    tmp = output_path.with_suffix(".tmp")
    tmp.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
    tmp.replace(output_path)
    print(f"\nScan geschrieben: {output_path}")
    print(f"Laufzeit: {result['duration_seconds']}s")
    return 0


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