#!/usr/bin/env python3
"""Vault Gap Analysis — Graph Analyzer (Stufe 1).

Analysiert den Vault als Wissensgraph:
1. Parst alle Markdown-Dateien und extrahiert Wikilinks + Frontmatter
2. Baut einen gerichteten Graph mit networkx
3. Erkennt Themencluster mit dem Louvain-Algorithmus
4. Berechnet Lücken-Scores zwischen Cluster-Paaren
5. Generiert gap-report.md

CLI:
    python graph_analyzer.py                      # liest skill.config.yaml, schreibt report_file
    python graph_analyzer.py --vault <pfad>       # explizit
    python graph_analyzer.py --output <pfad>      # explizit
    python graph_analyzer.py --dry-run            # parse + cluster, kein Report-Write
    python graph_analyzer.py --config <pfad>      # alternative Config-Datei

Öffentliche API für Wave B (auditor):
    parse_frontmatter(content: str) -> dict
    extract_wikilinks(content: str) -> list[str]
    build_link_index(vault_data: dict) -> dict[str, str]
    tokenize(text: str) -> set[str]
"""
from __future__ import annotations

import argparse
import math
import os
import re
import string
import sys
import time
from collections import Counter, defaultdict
from datetime import date
from pathlib import Path

import yaml
import networkx as nx
import community as community_louvain

# ---------------------------------------------------------------------------
# Loader-API: _lib/config_loader via sys.path-Trick
# ---------------------------------------------------------------------------

_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))

from _lib.config_loader import load_skill_config, SkillConfig  # noqa: E402


# ---------------------------------------------------------------------------
# Default-Konfiguration (greift wenn keine skill.config.yaml vorhanden)
# ---------------------------------------------------------------------------

_DEFAULT_FEATURES: dict = {
    "min_cluster_size": 3,
    "max_gaps": 10,
    "max_oneway_links": 15,
    "semantic_boost_max": 0.15,
    "louvain_random_seed": 42,
}

_DEFAULT_SCOPE: dict = {
    "exclude_dirs": [".obsidian", ".claude", ".agents", "node_modules", ".git", ".trash"],
    "exclude_paths": [],
    "exclude_files": ["gap-report.md", "gap-report"],
}

# Cluster-Paare mit diesen type-Kombinationen sind keine echten Lücken
TRIVIAL_TYPE_PAIRS: frozenset[frozenset] = frozenset({
    frozenset({"daily", "projekt"}),
    frozenset({"daily", "projekt-notiz"}),
    frozenset({"daily", "routine"}),
})


# ---------------------------------------------------------------------------
# Locale-Loader
# ---------------------------------------------------------------------------

def _load_stop_words(locale_name: str) -> set[str]:
    """Lädt STOP_WORDS aus dem passenden Locale-Modul."""
    sys.path.insert(0, str(_SKILL_DIR))
    try:
        from locale_data import get_locale
        loc = get_locale(locale_name)
        return getattr(loc, "STOP_WORDS", set())
    except Exception:  # noqa: BLE001
        return set()
    finally:
        # sys.path nicht dauerhaft verunreinigen
        if str(_SKILL_DIR) in sys.path:
            sys.path.remove(str(_SKILL_DIR))


# ---------------------------------------------------------------------------
# Scope-Helper
# ---------------------------------------------------------------------------

def _resolve_scope_paths(raw_scope: dict, layout: dict) -> dict:
    """Löst Platzhalter in scope.exclude_paths gegen das Layout auf.

    scope.exclude_paths-Einträge können {logbuch} etc. enthalten.
    Diese werden hier gegen cfg.layout aufgelöst. Unbekannte Platzhalter
    werden still belassen (kein Crash — robuste Handhabung).
    """
    resolved_excludes = []
    for raw_path in raw_scope.get("exclude_paths", []):
        result = raw_path
        for key, val in layout.items():
            placeholder = f"{{{key}}}"
            if placeholder in result:
                result = result.replace(placeholder, str(val))
        resolved_excludes.append(result)
    return {
        "exclude_dirs": set(raw_scope.get("exclude_dirs", [])),
        "exclude_paths": set(resolved_excludes),
        "exclude_files": set(raw_scope.get("exclude_files", [])),
    }


# ---------------------------------------------------------------------------
# Öffentliche API-Helper (Wave B importiert diese vier Funktionen)
# ---------------------------------------------------------------------------

def parse_frontmatter(content: str) -> dict:
    """Extrahiert YAML-Frontmatter aus Markdown-Inhalt.

    Gibt leeres Dict bei fehlendem oder kaputtem Frontmatter zurück.
    Kein Crash bei fehlendem schließendem '---'.
    """
    if not content.startswith("---"):
        return {}
    end = content.find("---", 3)
    if end == -1:
        return {}
    try:
        fm = yaml.safe_load(content[3:end])
        return fm if isinstance(fm, dict) else {}
    except yaml.YAMLError:
        return {}


def extract_wikilinks(content: str) -> list[str]:
    """Extrahiert alle Wikilink-Ziele aus Markdown-Inhalt.

    Unterstützt [[Ziel]], [[Ziel|Alias]], [[Ordner/Datei]].
    """
    pattern = r"\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|[^\]]+)?\]\]"
    return re.findall(pattern, content)


def build_link_index(vault_data: dict) -> dict[str, str]:
    """Baut einen Index: Dateiname/Pfad → voller Schlüssel.

    Für Link-Auflösung: zuerst voller Pfad, dann reiner Dateiname.
    """
    index: dict[str, str] = {}
    for key in vault_data:
        name = vault_data[key]["name"]
        index[key] = key
        if name not in index:
            index[name] = key
    return index


def tokenize(text: str) -> set[str]:
    """Einfache Tokenisierung: lowercase, Satzzeichen entfernen, Stoppwörter raus.

    Stoppwörter werden lazy beim ersten Aufruf aus dem Default-Locale-Modul (`de`) geladen,
    sodass Cross-Skill-Konsumenten via `cross_skill.import_helper` ohne expliziten `main()`-Aufruf
    sinnvolle Defaults erhalten (Zahnrad-Fix für Wave B / auditor).
    Bei Skript-Start via `main()` wird `_STOP_WORDS` aus der Config überschrieben.
    """
    _ensure_stop_words_loaded()
    text = text.lower().translate(str.maketrans("", "", string.punctuation))
    return {w for w in text.split() if len(w) > 2 and w not in _STOP_WORDS}


# Stoppwörter werden lazy beim ersten tokenize()-Aufruf befüllt (Default-Locale `de`).
# Zur Laufzeit (main()) wird _STOP_WORDS aus der Config aktualisiert (überschreibt Lazy-Default).
_STOP_WORDS: set[str] = set()
_STOP_WORDS_LOADED: bool = False


def _ensure_stop_words_loaded(locale_name: str = "de") -> None:
    """Lazy-Init von _STOP_WORDS aus dem Default-Locale, falls noch nicht gesetzt."""
    global _STOP_WORDS, _STOP_WORDS_LOADED
    if _STOP_WORDS_LOADED:
        return
    _STOP_WORDS = _load_stop_words(locale_name)
    _STOP_WORDS_LOADED = True


# ---------------------------------------------------------------------------
# Vault-Parsing
# ---------------------------------------------------------------------------

def find_md_files(
    vault_path: Path,
    exclude_dirs: set[str],
    exclude_paths: set[str],
    exclude_files: set[str],
) -> list[Path]:
    """Alle .md-Dateien im Vault, exklusive konfigurierter Verzeichnisse."""
    results = []
    for root, dirs, files in os.walk(vault_path):
        rel_root = Path(root).relative_to(vault_path).as_posix()
        # Ausgeschlossene Verzeichnisse überspringen
        dirs[:] = [
            d for d in dirs
            if d not in exclude_dirs
            and not any(
                rel_root.startswith(ep) or f"{rel_root}/{d}".startswith(ep)
                for ep in exclude_paths
            )
        ]
        for f in files:
            if f.endswith(".md") and f not in exclude_files and f[:-3] not in exclude_files:
                results.append(Path(root) / f)
    return results


def parse_vault(vault_path: Path, md_files: list[Path]) -> dict:
    """Parst alle Dateien und gibt ein Dictionary mit Metadaten zurück."""
    vault_data = {}
    for filepath in md_files:
        try:
            content = filepath.read_text(encoding="utf-8")
        except (UnicodeDecodeError, OSError):
            continue

        rel_path = filepath.relative_to(vault_path).as_posix()
        key = rel_path[:-3] if rel_path.endswith(".md") else rel_path
        name = filepath.stem

        fm = parse_frontmatter(content)
        links = extract_wikilinks(content)

        vault_data[key] = {
            "name": name,
            "path": rel_path,
            "links": links,
            "type": fm.get("type", ""),
            "description": fm.get("description", ""),
            "tags": fm.get("tags", []) or [],
        }
    return vault_data


# ---------------------------------------------------------------------------
# Graph-Aufbau und Clustering
# ---------------------------------------------------------------------------

def build_graph(vault_data: dict) -> nx.DiGraph:
    """Baut den gerichteten Wissensgraph."""
    link_index = build_link_index(vault_data)
    G = nx.DiGraph()

    for key, data in vault_data.items():
        G.add_node(key, **{k: v for k, v in data.items() if k != "links"})

    for key, data in vault_data.items():
        for link_target in data["links"]:
            target = link_target.replace("\\", "/").strip()
            resolved = link_index.get(target)
            if resolved is None:
                resolved = link_index.get(target.split("/")[-1])
            if resolved and resolved in vault_data:
                G.add_edge(key, resolved)

    return G


def detect_clusters(G: nx.DiGraph, random_state: int) -> dict[str, int]:
    """Erkennt Communities mit dem Louvain-Algorithmus auf dem ungerichteten Graph."""
    undirected = G.to_undirected()
    connected_nodes = [n for n in undirected.nodes() if undirected.degree(n) > 0]
    subgraph = undirected.subgraph(connected_nodes)

    if len(subgraph) == 0:
        return {}

    partition = community_louvain.best_partition(subgraph, random_state=random_state)
    return partition


# ---------------------------------------------------------------------------
# Cluster-Labels und Dominant-Type
# ---------------------------------------------------------------------------

def label_cluster(nodes: list[str], vault_data: dict) -> str:
    """Generiert ein Label für einen Cluster basierend auf häufigsten Tags und Types."""
    tag_counter: Counter = Counter()
    type_counter: Counter = Counter()
    for node in nodes:
        data = vault_data.get(node, {})
        tags = data.get("tags", [])
        if isinstance(tags, list):
            tag_counter.update(tags)
        t = data.get("type", "")
        if t:
            type_counter.update([t])

    parts = []
    if type_counter:
        parts.append(type_counter.most_common(1)[0][0])
    for tag, _ in tag_counter.most_common(3):
        if tag not in parts:
            parts.append(tag)
        if len(parts) >= 3:
            break
    return ", ".join(parts) if parts else "unlabeled"


def dominant_type(nodes: list[str], vault_data: dict) -> str:
    """Gibt den häufigsten type eines Clusters zurück."""
    type_counter: Counter = Counter()
    for node in nodes:
        t = vault_data.get(node, {}).get("type", "")
        if t:
            type_counter.update([t])
    return type_counter.most_common(1)[0][0] if type_counter else ""


# ---------------------------------------------------------------------------
# Semantische Analyse
# ---------------------------------------------------------------------------

def cluster_description_tokens(nodes: list[str], vault_data: dict) -> set[str]:
    """Sammelt alle Description-Tokens eines Clusters."""
    tokens: set[str] = set()
    for node in nodes:
        desc = vault_data.get(node, {}).get("description", "")
        if desc:
            tokens |= tokenize(desc)
    return tokens


def semantic_similarity(tokens_a: set[str], tokens_b: set[str]) -> float:
    """Jaccard-Ähnlichkeit zwischen zwei Token-Mengen."""
    if not tokens_a or not tokens_b:
        return 0.0
    return len(tokens_a & tokens_b) / len(tokens_a | tokens_b)


# ---------------------------------------------------------------------------
# Gap-Score-Berechnung
# ---------------------------------------------------------------------------

def compute_gap_scores(
    G: nx.DiGraph,
    clusters: dict[int, list[str]],
    vault_data: dict,
    min_cluster_size: int,
    max_gaps: int,
    semantic_boost_max: float,
) -> list[dict]:
    """Berechnet Lücken-Scores zwischen Cluster-Paaren.

    Gap-Score-Formel: 1.0 - min(actual / expected, 1.0) + sim * semantic_boost_max
    """
    total_edges = G.number_of_edges()
    n_nodes = G.number_of_nodes()
    if total_edges == 0 or n_nodes < 2:
        return []

    density = total_edges / (n_nodes * (n_nodes - 1))

    cluster_ids = sorted(clusters.keys())
    gaps = []

    for i, cid_a in enumerate(cluster_ids):
        for cid_b in cluster_ids[i + 1:]:
            nodes_a = clusters[cid_a]
            nodes_b = clusters[cid_b]

            if len(nodes_a) < min_cluster_size or len(nodes_b) < min_cluster_size:
                continue

            type_a = dominant_type(nodes_a, vault_data)
            type_b = dominant_type(nodes_b, vault_data)
            if frozenset({type_a, type_b}) in TRIVIAL_TYPE_PAIRS:
                continue

            actual_edges = 0
            connecting_edges = []
            set_b = set(nodes_b)
            set_a = set(nodes_a)
            for node in nodes_a:
                for successor in G.successors(node):
                    if successor in set_b:
                        actual_edges += 1
                        connecting_edges.append((node, successor))
            for node in nodes_b:
                for successor in G.successors(node):
                    if successor in set_a:
                        actual_edges += 1
                        connecting_edges.append((node, successor))

            expected = density * len(nodes_a) * len(nodes_b) * 2
            if expected < 1:
                expected = 1

            gap_score = 1.0 - min(actual_edges / expected, 1.0)

            tokens_a = cluster_description_tokens(nodes_a, vault_data)
            tokens_b = cluster_description_tokens(nodes_b, vault_data)
            sim = semantic_similarity(tokens_a, tokens_b)
            gap_score = min(gap_score + sim * semantic_boost_max, 1.0)
            gap_score = round(gap_score, 2)

            if gap_score < 0.3:
                continue

            degree_a = sorted(nodes_a, key=lambda n: G.degree(n), reverse=True)
            degree_b = sorted(nodes_b, key=lambda n: G.degree(n), reverse=True)

            tags_a: set[str] = set()
            tags_b: set[str] = set()
            for n in nodes_a:
                t = vault_data.get(n, {}).get("tags", [])
                if isinstance(t, list):
                    tags_a.update(t)
            for n in nodes_b:
                t = vault_data.get(n, {}).get("tags", [])
                if isinstance(t, list):
                    tags_b.update(t)
            tag_overlap = tags_a & tags_b

            gaps.append({
                "score": gap_score,
                "semantic_sim": round(sim, 2),
                "cluster_a_id": cid_a,
                "cluster_b_id": cid_b,
                "cluster_a_label": label_cluster(nodes_a, vault_data),
                "cluster_b_label": label_cluster(nodes_b, vault_data),
                "cluster_a_size": len(nodes_a),
                "cluster_b_size": len(nodes_b),
                "actual_edges": actual_edges,
                "connecting": [(a, b) for a, b in connecting_edges[:5]],
                "hub_a": degree_a[0] if degree_a else "",
                "hub_b": degree_b[0] if degree_b else "",
                "tag_overlap": sorted(tag_overlap),
                "desc_a": vault_data.get(degree_a[0], {}).get("description", "")[:120] if degree_a else "",
                "desc_b": vault_data.get(degree_b[0], {}).get("description", "")[:120] if degree_b else "",
            })

    gaps.sort(key=lambda g: g["score"], reverse=True)
    return gaps[:max_gaps]


# ---------------------------------------------------------------------------
# Einseitige Links
# ---------------------------------------------------------------------------

def find_one_way_links(
    G: nx.DiGraph,
    vault_data: dict,
    partition: dict[str, int],
    max_oneway: int,
) -> list[dict]:
    """Findet einseitige Links: A→B existiert, B→A nicht.

    Filtert Daily Notes und triviale Fälle heraus.
    """
    oneway_skip_types = {"daily", "daily-note"}
    oneway_skip_names = {"INDEX", "CLAUDE"}

    one_way = []
    for u, v in G.edges():
        if G.has_edge(v, u):
            continue
        type_u = vault_data.get(u, {}).get("type", "")
        type_v = vault_data.get(v, {}).get("type", "")
        if type_u in oneway_skip_types or type_v in oneway_skip_types:
            continue
        name_u = vault_data.get(u, {}).get("name", "")
        name_v = vault_data.get(v, {}).get("name", "")
        if name_u in oneway_skip_names or name_v in oneway_skip_names:
            continue
        cluster_u = partition.get(u)
        cluster_v = partition.get(v)
        if cluster_u is not None and cluster_u == cluster_v:
            continue
        one_way.append({
            "source": u,
            "target": v,
            "source_type": type_u,
            "target_type": type_v,
            "combined_degree": G.degree(u) + G.degree(v),
        })
    one_way.sort(key=lambda x: x["combined_degree"], reverse=True)
    return one_way[:max_oneway]


# ---------------------------------------------------------------------------
# Waisen-Dateien
# ---------------------------------------------------------------------------

def find_orphans(G: nx.DiGraph, vault_data: dict) -> list[str]:
    """Findet Waisen-Dateien (keine eingehenden oder ausgehenden Kanten)."""
    orphans = []
    for node in vault_data:
        if node not in G or G.degree(node) == 0:
            orphans.append(node)
    return sorted(orphans)


# ---------------------------------------------------------------------------
# Report-Generator
# ---------------------------------------------------------------------------

def generate_report(
    gaps: list[dict],
    orphans: list[str],
    clusters: dict[int, list[str]],
    vault_data: dict,
    stats: dict,
    one_way: list[dict] | None = None,
) -> str:
    """Generiert den Markdown-Report."""
    today = date.today().isoformat()
    lines = [
        "---",
        "type: projekt-notiz",
        'description: "Automatisch generierter Lückenbericht der Vault Gap Analysis"',
        f"generated: {today}",
        "---",
        "",
        "# Vault Gap Analysis Report",
        "",
        f"Analysiert: {stats['files']} Dateien, {stats['edges']} Wikilinks, "
        f"{stats['clusters']} Cluster",
        f"Waisen (ohne Verbindungen): {len(orphans)}",
        f"Laufzeit: {stats['duration']:.1f}s",
        "",
    ]

    # Cluster-Übersicht
    lines.append("## Cluster-Übersicht")
    lines.append("")
    for cid in sorted(clusters.keys()):
        nodes = clusters[cid]
        lbl = label_cluster(nodes, vault_data)
        lines.append(f"- **Cluster {cid}** ({len(nodes)} Dateien): {lbl}")
    lines.append("")

    # Lücken
    if gaps:
        lines.append("## Identifizierte Lücken")
        lines.append("")
        for i, gap in enumerate(gaps, 1):
            lines.append(f"### Lücke {i} (Score: {gap['score']})")
            lines.append("")
            lines.append(f"**Cluster A:** {gap['cluster_a_label']} ({gap['cluster_a_size']} Dateien)")
            lines.append(f"**Cluster B:** {gap['cluster_b_label']} ({gap['cluster_b_size']} Dateien)")
            lines.append(f"**Bestehende Verbindungen:** {gap['actual_edges']}")
            if gap["connecting"]:
                connections = ", ".join(f"[[{a}]] → [[{b}]]" for a, b in gap["connecting"])
                lines.append(f"**Verbindungskanten:** {connections}")
            lines.append(f"**Stärkster Knoten A:** [[{gap['hub_a']}]]")
            lines.append(f"**Stärkster Knoten B:** [[{gap['hub_b']}]]")
            if gap.get("semantic_sim", 0) > 0:
                lines.append(f"**Semantische Ähnlichkeit:** {gap['semantic_sim']}")
            if gap["tag_overlap"]:
                lines.append(f"**Tag-Überschneidung:** {', '.join(gap['tag_overlap'])}")
            if gap["desc_a"] or gap["desc_b"]:
                lines.append("**Frontmatter-Kontext:**")
                if gap["desc_a"]:
                    lines.append(f'- A: "{gap["desc_a"]}"')
                if gap["desc_b"]:
                    lines.append(f'- B: "{gap["desc_b"]}"')
            lines.append("")
    else:
        lines.append("## Keine Lücken gefunden")
        lines.append("")
        lines.append("Alle Cluster sind hinreichend verbunden.")
        lines.append("")

    # Einseitige Links
    if one_way:
        lines.append("## Einseitige Links (Rücklink fehlt)")
        lines.append("")
        lines.append("Links, die nur in eine Richtung existieren (zwischen verschiedenen Clustern, ohne Daily Notes).")
        lines.append("")
        for ow in one_way:
            lines.append(f"- [[{ow['source']}]] → [[{ow['target']}]]")
        lines.append("")

    # Waisen
    if orphans:
        lines.append("## Waisen-Dateien (ohne Verbindungen)")
        lines.append("")
        for o in orphans[:30]:
            lines.append(f"- [[{o}]]")
        if len(orphans) > 30:
            lines.append(f"- ... und {len(orphans) - 30} weitere")
        lines.append("")

    return "\n".join(lines)


# ---------------------------------------------------------------------------
# Haupt-Einstiegspunkt
# ---------------------------------------------------------------------------

def main() -> int:
    """CLI-Einstiegspunkt."""
    parser = argparse.ArgumentParser(
        description="Vault Gap Analysis — analysiert den Wissensgraph und schreibt gap-report.md.",
    )
    parser.add_argument(
        "--vault",
        type=Path,
        default=None,
        help="Vault-Root-Verzeichnis. Default: aus bundle.json aufgelöst.",
    )
    parser.add_argument(
        "--output",
        type=Path,
        default=None,
        help="Ausgabepfad für gap-report.md. Überschreibt paths.report_file in der Config.",
    )
    parser.add_argument(
        "--dry-run",
        action="store_true",
        help="Parse und cluster, schreibe keinen Report.",
    )
    parser.add_argument(
        "--config",
        type=Path,
        default=None,
        help="Alternative skill.config.yaml-Datei.",
    )
    args = parser.parse_args()

    # Config laden
    if args.config is not None:
        # --config übergibt direkte skill.config.yaml-Datei.
        # skill_dir = Verzeichnis der Config-Datei, damit _find_bundle_root
        # von dort aufwärts sucht (Fixture-Layout wird korrekt geladen).
        config_path = args.config.resolve()
        if not config_path.exists():
            print(f"Config-Datei nicht gefunden: {config_path}", file=sys.stderr)
            return 1
        if config_path.name != "skill.config.yaml":
            # Benenne temporär um — oder lade direkt via skill_dir-Trick:
            # Wir nutzen das Elternverzeichnis als skill_dir, die Datei muss
            # skill.config.yaml heißen oder wir kopieren sie kurz.
            import shutil, tempfile as _tf
            _tmp = Path(_tf.mkdtemp())
            shutil.copy(config_path, _tmp / "skill.config.yaml")
            # bundle.json aus dem Config-Verzeichnis mitgeben, damit Layout stimmt
            bundle_candidate = config_path.parent / "bundle.json"
            if bundle_candidate.exists():
                shutil.copy(bundle_candidate, _tmp / "bundle.json")
            cfg = load_skill_config(skill_dir=_tmp, vault_root=args.vault)
        else:
            # skill_dir = Elternverzeichnis — _find_bundle_root sucht ab hier
            cfg = load_skill_config(skill_dir=config_path.parent, vault_root=args.vault)
    else:
        cfg = load_skill_config(
            skill_dir=_SKILL_DIR,
            vault_root=args.vault,
        )

    # Feature-Flags aus Config (mit Defaults)
    features = {**_DEFAULT_FEATURES, **cfg.features}
    min_cluster_size = int(features.get("min_cluster_size", 3))
    max_gaps = int(features.get("max_gaps", 10))
    max_oneway = int(features.get("max_oneway_links", 15))
    semantic_boost_max = float(features.get("semantic_boost_max", 0.15))
    random_state = int(features.get("louvain_random_seed", 42))

    # Scope aus cfg.raw (H-2: Listen nicht über Loader-API, sondern via raw)
    raw_scope = cfg.raw.get("scope", _DEFAULT_SCOPE)
    # Layout-Strings müssen RELATIV sein für den Vergleich mit rel_root in
    # find_md_files. cfg.layout enthält absolute Pfade (wenn vault_root gesetzt),
    # daher raw layout aus _lib/config_loader.DEFAULT_LAYOUT bzw. bundle.json ziehen.
    from _lib.config_loader import _find_bundle_root, _load_vault_layout  # noqa: E402
    _raw_bundle_root = _find_bundle_root(_SKILL_DIR)
    _raw_layout_str = _load_vault_layout(_raw_bundle_root)
    # Wenn config aus Fixture-bundle_root stammt, raw layout daraus lesen
    if args.config is not None:
        _fixture_bundle_root = _find_bundle_root(args.config.resolve().parent)
        if _fixture_bundle_root is not None:
            _raw_layout_str = _load_vault_layout(_fixture_bundle_root)
    scope = _resolve_scope_paths(raw_scope, _raw_layout_str)

    # Stoppwörter aus Config-Locale laden — überschreibt Lazy-Default aus _ensure_stop_words_loaded
    global _STOP_WORDS, _STOP_WORDS_LOADED
    _STOP_WORDS = _load_stop_words(cfg.locale)
    _STOP_WORDS_LOADED = True

    # Vault-Pfad bestimmen
    if args.vault is not None:
        vault_path = args.vault.resolve()
    elif cfg.layout.get("logbuch") is not None:
        # Vault-Root ist eine Ebene über dem logbuch-Platzhalter
        # — wenn vault_root übergeben wurde, ist layout absolut
        # Fallback: Annahme, dass layout["system"] relativ ist → CWD
        system_val = cfg.layout.get("system")
        if system_val and system_val.is_absolute():
            vault_path = system_val.parent.parent  # ../.. von system
        else:
            vault_path = Path.cwd()
    else:
        vault_path = Path.cwd()

    # Output-Pfad bestimmen
    if args.output is not None:
        output_path = args.output.resolve()
    elif "report_file" in cfg.paths:
        output_path = cfg.paths["report_file"]
        # Falls report_file relativ, gegen vault_path auflösen
        if not output_path.is_absolute():
            output_path = vault_path / output_path
    else:
        # Kein report_file in Config: gegen cfg.layout['system'] auflösen statt Stefan-Vault-Pfad zu hardcoden (H-1 Critic-Pass).
        system_dir = cfg.layout.get("system")
        if system_dir is None:
            raise RuntimeError(
                "Kein report_file in skill.config.yaml und kein 'system'-Layout in bundle.json gefunden. "
                "Bitte report_file in skill.config.yaml setzen."
            )
        output_path = system_dir / "Vault Gap Analysis" / "gap-report.md"

    print(f"Vault: {vault_path}")
    print(f"Output: {output_path}")
    if args.dry_run:
        print("[dry-run] Kein Report wird geschrieben.")

    start = time.time()

    # 1. Dateien finden
    md_files = find_md_files(vault_path, scope["exclude_dirs"], scope["exclude_paths"], scope["exclude_files"])
    print(f"Gefunden: {len(md_files)} Markdown-Dateien")

    # 2. Parsen
    vault_data = parse_vault(vault_path, md_files)
    print(f"Geparst: {len(vault_data)} Dateien mit Frontmatter und Links")

    # 3. Graph bauen
    G = build_graph(vault_data)
    print(f"Graph: {G.number_of_nodes()} Knoten, {G.number_of_edges()} Kanten")

    # 4. Cluster erkennen
    partition = detect_clusters(G, random_state=random_state)
    clusters: dict[int, list[str]] = defaultdict(list)
    for node, cid in partition.items():
        clusters[cid].append(node)
    print(f"Cluster: {len(clusters)} erkannt")

    # 5. Waisen finden
    orphans = find_orphans(G, vault_data)
    print(f"Waisen: {len(orphans)} Dateien ohne Verbindungen")

    # 6. Lücken berechnen
    gaps = compute_gap_scores(G, dict(clusters), vault_data, min_cluster_size, max_gaps, semantic_boost_max)
    print(f"Lücken: {len(gaps)} identifiziert")

    # 7. Einseitige Links finden
    one_way = find_one_way_links(G, vault_data, partition, max_oneway)
    print(f"Einseitige Links: {len(one_way)} gefunden (Inter-Cluster, ohne Daily Notes)")

    duration = time.time() - start

    if args.dry_run:
        print(f"\n[dry-run] Analyse abgeschlossen in {duration:.1f}s — kein Report geschrieben.")
        return 0

    # 8. Report schreiben
    stats = {
        "files": len(vault_data),
        "edges": G.number_of_edges(),
        "clusters": len(clusters),
        "duration": duration,
    }
    report = generate_report(gaps, orphans, dict(clusters), vault_data, stats, one_way)

    output_path.parent.mkdir(parents=True, exist_ok=True)
    output_path.write_text(report, encoding="utf-8")
    print(f"\nReport geschrieben: {output_path}")
    print(f"Laufzeit: {duration:.1f}s")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
