# /// script
# requires-python = ">=3.11"
# dependencies = ["pyyaml"]
# ///
"""
build_index.py — Erzeugt INDEX.md im Vault-Root aus dem aktuellen Vault-Stand.

Karpathy-Compiler-Analogie: raw (Markdown-Dateien) -> wiki (Ordner 00-07) ->
index (diese Datei) -> runtime (Claude Code liest INDEX.md beim Session-Start).

Flache Markdown-Datei, Ein-Zeilen-Einträge pro Bereich. Alle Pfade kommen
aus skill.config.yaml plus bundle.json.vault_layout — keine Literal-Pfade.

Aufruf:
    PYTHONUTF8=1 python .skills/index-rebuild/build_index.py
    PYTHONUTF8=1 python .skills/index-rebuild/build_index.py --dry-run
    PYTHONUTF8=1 python .skills/index-rebuild/build_index.py --diff
    PYTHONUTF8=1 python .skills/index-rebuild/build_index.py --vault PATH

Optionen:
    --dry-run     Zeigt den neuen Index auf stdout, schreibt nichts.
    --diff        Zeigt ein unified diff gegen die bestehende INDEX.md.
    --vault PATH  Überschreibt den Vault-Pfad (Standard: cwd).
"""

from __future__ import annotations

import argparse
import difflib
import re
import sys
from datetime import date
from pathlib import Path
from typing import Iterable

import yaml

_SKILL_DIR = Path(__file__).resolve().parent
_SKILLS_ROOT = _SKILL_DIR.parent

# Sys-Path für _lib
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

# --- Regex-Muster ---
# FRONTMATTER_RE fasst den Block zwischen den ersten beiden ---Zeilen.
# read_frontmatter parst diesen Block primär per yaml.safe_load (block-scalar-fest).
# Schlägt yaml fehl (z.B. unquotierte Description mit ': ' wie bei meta-skill,
# "mapping values are not allowed here"), greift der Zeilen-Regex-Fallback
# (_DESCRIPTION_LINE_RE / _STATUS_LINE_RE) — erste Zeile pro Feld, so tolerant
# wie vor der yaml-Umstellung. CREATED_RE liest aus dem eigenen, immer-
# stringförmigen Generator-Output zurück (_read_existing_created).
FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---", re.DOTALL)
CREATED_RE = re.compile(r"^created:\s*(.+?)\s*$", re.MULTILINE)
_DESCRIPTION_LINE_RE = re.compile(r"^description:\s*(.+?)\s*$", re.MULTILINE)
_STATUS_LINE_RE = re.compile(r"^status:\s*(.+?)\s*$", re.MULTILINE)

# --- Default-Pfade und -Features ---
_DEFAULT_PATHS: dict[str, str] = {
    "output_file": "INDEX.md",
    "routinen_subdir": "{system}/Routinen",
    "skills_dir": ".claude/skills",
    "commands_dir": ".claude/commands",
    "evals_file": ".skills/index-rebuild/eval/index-rebuild.eval.json",
}

_DEFAULT_SECTION_HEADINGS: list[str] = [
    "## Kontext",
    "## Projekte (aktiv)",
    "## Bereiche",
    "## Ressourcen",
    "## Routinen",
    "## Skills",
    "## Commands",
    "## Letzte Änderungen",
]

_DEFAULT_RESOURCE_SKIP_SUBDIRS: list[str] = ["00_Images", "00_Sources", "Routinen"]
_DEFAULT_RECENT_CHANGES_LIMIT: int = 10
_DEFAULT_MAX_DESCRIPTION_LEN: int = 140
_DEFAULT_TOTAL_SIZE_MAX: int = 20000
_DEFAULT_BODY_INTRO: str = (
    "Runtime-Startpunkt für das Vault. Jede Claude-Session liest diese Datei zuerst. "
    "Wird vom Skill index-rebuild aus dem Vault-Stand regeneriert."
)


# ---------------------------------------------------------------------------
# Hilfsfunktionen
# ---------------------------------------------------------------------------

def read_frontmatter(path: Path) -> dict[str, str]:
    """Liest description und status aus dem YAML-Frontmatter einer Markdown-Datei.

    Primär per yaml.safe_load (block-scalar-fest gegen `description: >` /
    `description: |`). Jeder geholte Wert wird über str() gecastet und zu
    Single-Line normalisiert (" ".join(str(val).split())) — nötig, weil
    unquotiertes `created:` als datetime.date und `status: true` als bool
    parst. status zusätzlich .lower().

    Schlägt yaml fehl (unquotierte Description mit ': ', z.B. meta-skill →
    "mapping values are not allowed here"), greift ein Zeilen-Regex-Fallback
    pro Feld (erste Zeile), so tolerant wie vor der yaml-Umstellung. Bei
    fehlender Frontmatter: leeres Dict.
    """
    try:
        text = path.read_text(encoding="utf-8", errors="replace")
    except OSError:
        return {}
    match = FRONTMATTER_RE.match(text)
    if not match:
        return {}
    block = match.group(1)
    try:
        data = yaml.safe_load(block)
    except yaml.YAMLError:
        data = None
    if isinstance(data, dict):
        result: dict[str, str] = {}
        if data.get("description") is not None:
            result["description"] = " ".join(str(data["description"]).split())
        if data.get("status") is not None:
            result["status"] = " ".join(str(data["status"]).split()).lower()
        return result
    # Fallback: yaml hat den Block nicht als Mapping erkannt (Parse-Fehler oder
    # Skalar). Erste Zeile pro Feld greifen.
    result = {}
    desc = _DESCRIPTION_LINE_RE.search(block)
    if desc:
        val = desc.group(1).strip().strip('"').strip("'")
        result["description"] = " ".join(val.split())
    status = _STATUS_LINE_RE.search(block)
    if status:
        result["status"] = " ".join(status.group(1).split()).lower()
    return result


def trim(text: str, limit: int = _DEFAULT_MAX_DESCRIPTION_LEN) -> str:
    """Kürzt eine Beschreibung auf limit Zeichen."""
    text = text.strip().strip('"').strip("'")
    if len(text) <= limit:
        return text
    return text[: limit - 1].rstrip() + "..."


def section_line(path: Path, vault: Path, description: str, max_len: int) -> str:
    """Erzeugt eine Index-Zeile als Wikilink mit optionaler Description."""
    rel = path.relative_to(vault).as_posix()
    link_target = rel[:-3] if rel.endswith(".md") else rel
    display = path.stem
    desc = trim(description, max_len) if description else ""
    if desc:
        return f"- [[{link_target}|{display}]] — {desc}"
    return f"- [[{link_target}|{display}]]"


def _resolve_path(config: SkillConfig, key: str, vault: Path) -> Path:
    """Gibt einen aufgelösten absoluten Pfad aus config.paths zurück."""
    p = config.paths.get(key)
    if p is None:
        return vault / _DEFAULT_PATHS.get(key, key)
    if p.is_absolute():
        return p
    return vault / p


def _feature(config: SkillConfig, key: str, default):
    """Liest einen Feature-Wert aus der Config mit Fallback."""
    return config.features.get(key, default)


# ---------------------------------------------------------------------------
# Sammel-Funktionen
# ---------------------------------------------------------------------------

def collect_kontext(vault: Path, config: SkillConfig) -> list[str]:
    """Alle .md-Dateien in {context}/."""
    folder = config.layout["context"]
    max_len = _feature(config, "max_description_len", _DEFAULT_MAX_DESCRIPTION_LEN)
    if not folder.is_dir():
        return []
    lines: list[str] = []
    for md in sorted(folder.glob("*.md")):
        fm = read_frontmatter(md)
        lines.append(section_line(md, vault, fm.get("description", ""), max_len))
    return lines


def collect_projects(vault: Path, config: SkillConfig) -> list[str]:
    """Alle aktiven Projekte in {projects}/ (ohne archivierte)."""
    folder = config.layout["projects"]
    max_len = _feature(config, "max_description_len", _DEFAULT_MAX_DESCRIPTION_LEN)
    if not folder.is_dir():
        return []
    lines: list[str] = []
    for sub in sorted(folder.iterdir()):
        if not sub.is_dir():
            continue
        steckbrief = sub / f"{sub.name}.md"
        if not steckbrief.exists():
            continue
        fm = read_frontmatter(steckbrief)
        if fm.get("status") == "archiviert":
            continue
        lines.append(section_line(steckbrief, vault, fm.get("description", ""), max_len))
    return lines


def collect_bereiche(vault: Path, config: SkillConfig) -> list[str]:
    """Alle Bereiche in {areas}/."""
    folder = config.layout["areas"]
    max_len = _feature(config, "max_description_len", _DEFAULT_MAX_DESCRIPTION_LEN)
    if not folder.is_dir():
        return []
    lines: list[str] = []
    for sub in sorted(folder.iterdir()):
        if not sub.is_dir():
            continue
        main = sub / f"{sub.name}.md"
        if not main.exists():
            continue
        fm = read_frontmatter(main)
        lines.append(section_line(main, vault, fm.get("description", ""), max_len))
    return lines


def collect_ressourcen(vault: Path, config: SkillConfig) -> list[str]:
    """Alle Ressourcen in {resources}/ — überspringt konfigurierbare Unterordner."""
    folder = config.layout["resources"]
    max_len = _feature(config, "max_description_len", _DEFAULT_MAX_DESCRIPTION_LEN)
    skip_subdirs: list[str] = _feature(
        config, "resource_skip_subdirs", _DEFAULT_RESOURCE_SKIP_SUBDIRS
    )
    if not isinstance(skip_subdirs, list):
        skip_subdirs = _DEFAULT_RESOURCE_SKIP_SUBDIRS
    if not folder.is_dir():
        return []
    lines: list[str] = []
    for sub in sorted(folder.iterdir()):
        if not sub.is_dir():
            continue
        if sub.name in skip_subdirs:
            continue
        main = sub / f"{sub.name}.md"
        if main.exists():
            fm = read_frontmatter(main)
            lines.append(section_line(main, vault, fm.get("description", ""), max_len))
        else:
            rel = sub.relative_to(vault).as_posix()
            lines.append(f"- [[{rel}|{sub.name}]]")
    return lines


def collect_routinen(vault: Path, config: SkillConfig) -> list[str]:
    """Alle Routinen aus dem konfigurierten routinen_subdir."""
    folder = _resolve_path(config, "routinen_subdir", vault)
    max_len = _feature(config, "max_description_len", _DEFAULT_MAX_DESCRIPTION_LEN)
    if not folder.is_dir():
        return []
    lines: list[str] = []
    for md in sorted(folder.glob("*.md")):
        fm = read_frontmatter(md)
        lines.append(section_line(md, vault, fm.get("description", ""), max_len))
    return lines


def _codex_mirror_fallback(folder: Path, vault: Path, leaf: str) -> Path:
    """Fehlender `.claude/<leaf>`-Default-Spiegel → Codex-Spiegel `.agents/<leaf>`.

    Beide Spiegel sind laut sync.py inhaltsgleich aus der SSoT `.skills/`;
    bei Codex-only-Vaults ohne `.claude/`-Ordner blieben die INDEX-Sektionen
    sonst leer (CODEX-WAVE-1B-Audit, Pfad-Default-Befund). Greift für jeden
    Pfad, der auf `.claude/<leaf>` endet (Suffix-Check) — also auch für
    einen expliziten Override mit dem Default-Wert; nie für davon
    abweichende Overrides.
    """
    if folder.is_dir() or folder.parts[-2:] != (".claude", leaf):
        return folder
    agents_twin = vault / ".agents" / leaf
    return agents_twin if agents_twin.is_dir() else folder


def collect_skills(vault: Path, config: SkillConfig) -> list[str]:
    """Alle Skills mit SKILL.md aus dem konfigurierten skills_dir."""
    # skills_dir ist via _DEFAULT_PATHS-Merge im Loader immer gesetzt —
    # kein None-Fall (v0.24.0 Posten 7c, toter Zweig entfernt).
    skills_path_raw = config.paths["skills_dir"]
    if skills_path_raw.is_absolute():
        folder = skills_path_raw
    else:
        folder = vault / skills_path_raw
    folder = _codex_mirror_fallback(folder, vault, "skills")
    max_len = _feature(config, "max_description_len", _DEFAULT_MAX_DESCRIPTION_LEN)
    if not folder.is_dir():
        return []
    lines: list[str] = []
    for sub in sorted(folder.iterdir()):
        if not sub.is_dir():
            continue
        skill_md = None
        for candidate in ("SKILL.md", "skill.md"):
            if (sub / candidate).exists():
                skill_md = sub / candidate
                break
        if not skill_md:
            continue
        fm = read_frontmatter(skill_md)
        desc = trim(fm.get("description", ""), max_len)
        lines.append(f"- `{sub.name}` — {desc}" if desc else f"- `{sub.name}`")
    return lines


def collect_commands(vault: Path, config: SkillConfig) -> list[str]:
    """Alle Commands aus dem konfigurierten commands_dir."""
    # commands_dir ist via _DEFAULT_PATHS-Merge im Loader immer gesetzt —
    # kein None-Fall (v0.24.0 Posten 7c, toter Zweig entfernt).
    commands_path_raw = config.paths["commands_dir"]
    if commands_path_raw.is_absolute():
        folder = commands_path_raw
    else:
        folder = vault / commands_path_raw
    folder = _codex_mirror_fallback(folder, vault, "commands")
    max_len = _feature(config, "max_description_len", _DEFAULT_MAX_DESCRIPTION_LEN)
    if not folder.is_dir():
        return []
    lines: list[str] = []
    for md in sorted(folder.glob("*.md")):
        fm = read_frontmatter(md)
        desc = trim(fm.get("description", ""), max_len)
        lines.append(f"- `/{md.stem}` — {desc}" if desc else f"- `/{md.stem}`")
    return lines


def collect_recent_changes(vault: Path, config: SkillConfig) -> list[str]:
    """Neueste geänderte Markdown-Dateien — überspringt Archive und Anhänge."""
    limit: int = _feature(config, "recent_changes_limit", _DEFAULT_RECENT_CHANGES_LIMIT)
    if not isinstance(limit, int):
        limit = _DEFAULT_RECENT_CHANGES_LIMIT

    extra_skip: list[str] = _feature(config, "recent_changes_extra_skip", [])
    if not isinstance(extra_skip, list):
        extra_skip = []

    # Layout-basierte Skip-Ordner (archive und attachments)
    archive_folder = config.layout["archive"]
    attachments_folder = config.layout["attachments"]
    skip_roots: list[Path] = [archive_folder, attachments_folder]

    # Ausgabedatei selbst überspringen
    output_file_raw = config.paths.get("output_file")
    if output_file_raw is None:
        output_name = "INDEX.md"
    else:
        output_name = output_file_raw.name if output_file_raw.is_absolute() else str(output_file_raw)

    candidates: list[tuple[float, Path]] = []
    for path in vault.rglob("*.md"):
        # Versteckte Ordner überspringen
        if any(p.startswith(".") for p in path.parts):
            continue
        # Layout-basierte Skip-Ordner
        skip = False
        for skip_root in skip_roots:
            try:
                path.relative_to(skip_root)
                skip = True
                break
            except ValueError:
                pass
        if skip:
            continue
        # Zusätzliche Skip-Pfade
        rel_str = path.relative_to(vault).as_posix()
        if any(s in rel_str for s in extra_skip if s):
            continue
        # Output-Datei selbst
        if path.name == output_name and path.parent == vault:
            continue
        try:
            mtime = path.stat().st_mtime
        except OSError:
            continue
        candidates.append((mtime, path))

    candidates.sort(key=lambda x: x[0], reverse=True)
    lines: list[str] = []
    for _, path in candidates[:limit]:
        rel = path.relative_to(vault).as_posix()
        link_target = rel[:-3] if rel.endswith(".md") else rel
        lines.append(f"- [[{link_target}|{path.stem}]]")
    return lines


# ---------------------------------------------------------------------------
# Index-Rendering
# ---------------------------------------------------------------------------

def _read_existing_created(target: Path) -> str | None:
    """Liest das created-Feld aus einer bestehenden INDEX.md (Sticky-Create-Date)."""
    if not target.exists():
        return None
    try:
        text = target.read_text(encoding="utf-8", errors="replace")
    except OSError:
        return None
    match = FRONTMATTER_RE.match(text)
    if not match:
        return None
    m = CREATED_RE.search(match.group(1))
    if not m:
        return None
    return m.group(1).strip()


def render_index(vault: Path, config: SkillConfig) -> str:
    """Baut den vollständigen INDEX.md-Inhalt als String."""
    today = date.today().isoformat()

    # Sticky-Created-Date
    output_path_raw = config.paths.get("output_file")
    if output_path_raw is None:
        target = vault / "INDEX.md"
    elif output_path_raw.is_absolute():
        target = output_path_raw
    else:
        target = vault / output_path_raw

    created = _read_existing_created(target) or today

    # Frontmatter-Werte aus Config
    fm_type = config.frontmatter.get("type", "index")
    fm_tags = config.frontmatter.get("tags", ["index", "runtime"])
    fm_description = config.frontmatter.get(
        "description",
        "Runtime-Startpunkt des Vaults. Flacher Index aller Sektionen. "
        "Wird von index-rebuild aus dem Vault-Stand regeneriert."
    )

    # Body-Intro
    body_intro: str = _feature(config, "body_intro", _DEFAULT_BODY_INTRO)
    if not isinstance(body_intro, str):
        body_intro = _DEFAULT_BODY_INTRO

    # Sektion-Headings
    section_headings: list[str] = _feature(
        config, "section_headings", _DEFAULT_SECTION_HEADINGS
    )
    if not isinstance(section_headings, list):
        section_headings = _DEFAULT_SECTION_HEADINGS

    # Footer-Links
    footer_links: list[str] = _feature(config, "footer_links", [])
    if not isinstance(footer_links, list):
        footer_links = []

    # Sektionen sammeln (Mapping: heading → collect-Funktion)
    section_map = {
        "## Kontext": lambda: collect_kontext(vault, config),
        "## Projekte (aktiv)": lambda: collect_projects(vault, config),
        "## Bereiche": lambda: collect_bereiche(vault, config),
        "## Ressourcen": lambda: collect_ressourcen(vault, config),
        "## Routinen": lambda: collect_routinen(vault, config),
        "## Skills": lambda: collect_skills(vault, config),
        "## Commands": lambda: collect_commands(vault, config),
        "## Letzte Änderungen": lambda: collect_recent_changes(vault, config),
    }

    lines: list[str] = []

    # Frontmatter
    lines.append("---")
    lines.append(f"type: {fm_type}")
    if isinstance(fm_description, str):
        lines.append(f'description: "{fm_description}"')
    lines.append("tags:")
    if isinstance(fm_tags, list):
        for tag in fm_tags:
            lines.append(f"  - {tag}")
    else:
        lines.append(f"  - {fm_tags}")
    lines.append(f"created: {created}")
    lines.append(f"updated: {today}")
    lines.append("generated-by: index-rebuild")
    lines.append("---")
    lines.append("")
    lines.append("# INDEX")
    lines.append("")
    lines.append(body_intro)
    lines.append("")

    for heading in section_headings:
        collect_fn = section_map.get(heading)
        entries = collect_fn() if collect_fn else []
        lines.append(heading)
        if entries:
            lines.extend(entries)
        else:
            lines.append("- _(leer)_")
        lines.append("")

    # Footer-Block (nur wenn footer_links nicht leer)
    if footer_links:
        lines.append("## Verknüpfungen")
        lines.append("")
        for link in footer_links:
            lines.append(f"- {link}")
        lines.append("")

    return "\n".join(lines)


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

def load_config(vault: Path) -> SkillConfig:
    """Lädt die Skill-Config. Layout-Wurzeln stehen fertig in cfg.layout."""
    return load_skill_config(
        skill_dir=_SKILL_DIR,
        vault_root=vault,
        bundle_root=None,
        default_paths=_DEFAULT_PATHS,
    )


def main(argv: Iterable[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description="Rebuild INDEX.md at vault root.")
    parser.add_argument("--vault", type=Path, default=Path.cwd())
    parser.add_argument("--dry-run", action="store_true")
    parser.add_argument("--diff", action="store_true")
    args = parser.parse_args(argv)

    vault = args.vault.resolve()
    config = load_config(vault)

    output_path_raw = config.paths.get("output_file")
    if output_path_raw is None:
        target = vault / "INDEX.md"
    elif output_path_raw.is_absolute():
        target = output_path_raw
    else:
        target = vault / output_path_raw

    new_content = render_index(vault, config)

    if args.dry_run:
        sys.stdout.write(new_content)
        return 0

    old_content = target.read_text(encoding="utf-8") if target.exists() else ""

    if args.diff:
        diff = difflib.unified_diff(
            old_content.splitlines(keepends=True),
            new_content.splitlines(keepends=True),
            fromfile="INDEX.md (alt)",
            tofile="INDEX.md (neu)",
        )
        sys.stdout.writelines(diff)
        return 0

    if old_content == new_content:
        print("INDEX.md unchanged.")
        return 0

    target.write_text(new_content, encoding="utf-8")
    print(f"INDEX.md written: {target}")
    return 0


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