#!/usr/bin/env python3
"""aria-citation-fixer — Wikilink-Drift in aria-brain finden und (optional) reparieren.

KAR-184 (Phase-1 gbrain-Adoption). Pattern aus gbrain `skills/citation-fixer/`.

Usage:
  python3 aria-citation-fixer.py                    # Dry-Run: Report nur
  python3 aria-citation-fixer.py --apply            # Autofix anwenden (Threshold 0.85)
  python3 aria-citation-fixer.py --threshold 0.9    # strenger
  python3 aria-citation-fixer.py --section 02-Wissen  # nur eine Sektion

Scope:
  - Scannt alle *.md unter /root/aria/brain/ rekursiv
  - Findet Wikilinks [[Name]] und [[Name|Display]]
  - Vergleicht gegen Datei-Index (kanonische Slugs)
  - Output: dirty Links pro File, Fuzzy-Match-Vorschläge

Quelle: gbrain `skills/citation-fixer/SKILL.md`
"""
from __future__ import annotations
import sys as _sys
_sys.path.insert(0, "/root/aria/lib")
from aria_logging import get_logger as _get_logger
_log = _get_logger("aria-citation-fixer")


import argparse
import re
import sys
from collections import defaultdict
from difflib import SequenceMatcher
from pathlib import Path
from typing import Iterable

BRAIN_ROOT = Path("/root/aria/brain")

# Wikilink-Regex: [[Slug]] oder [[Slug|Display]] — Slug ist alles bis ] oder |
WIKILINK_RE = re.compile(r"\[\[([^\]\|\n]+?)(?:\|([^\]\n]+?))?\]\]")

# Notes-Ordner die wir nicht scannen
SKIP_DIRS = {".git", "akp", "youtube", ".obsidian", "HANDOFF.archive", "CORRECTIONS.archive"}


def _slug_from_path(p: Path) -> str:
    """Aria-Slug-Konvention: filename without .md extension."""
    return p.stem


def _build_slug_index(root: Path) -> dict[str, list[Path]]:
    """Map slug -> list of paths (slugs können dupliziert sein über Folders)."""
    idx: dict[str, list[Path]] = defaultdict(list)
    for p in root.rglob("*.md"):
        if any(skip in p.parts for skip in SKIP_DIRS):
            continue
        idx[_slug_from_path(p)].append(p)
    return idx


def _extract_links(text: str) -> list[tuple[int, str, str | None]]:
    """Liste (position, slug, display) für jeden Wikilink."""
    return [(m.start(), m.group(1).strip(), (m.group(2) or "").strip() or None)
            for m in WIKILINK_RE.finditer(text)]


def _fuzzy_suggest(target: str, candidates: Iterable[str], threshold: float = 0.85) -> str | None:
    """Beste Fuzzy-Match-Suggestion oder None wenn unter Threshold."""
    target_l = target.lower()
    best, best_score = None, 0.0
    for c in candidates:
        score = SequenceMatcher(None, target_l, c.lower()).ratio()
        if score > best_score:
            best, best_score = c, score
    return best if best_score >= threshold else None


def scan(root: Path = BRAIN_ROOT, section: str | None = None) -> dict:
    """Scan + return Report-Dict."""
    slug_index = _build_slug_index(root)
    all_slugs = set(slug_index.keys())

    report = {
        "total_files": 0,
        "total_links": 0,
        "dirty_links": 0,
        "files_with_drift": defaultdict(list),  # path -> [(pos, slug, suggestion)]
    }

    scan_root = root / section if section else root
    if not scan_root.is_dir():
        return report

    for p in scan_root.rglob("*.md"):
        if any(skip in p.parts for skip in SKIP_DIRS):
            continue
        report["total_files"] += 1
        try:
            text = p.read_text(encoding="utf-8", errors="replace")
        except OSError:
            continue
        for pos, slug, _display in _extract_links(text):
            report["total_links"] += 1
            # Hash-Anchors (z.B. [[Slug#Heading]]) strippen
            clean_slug = slug.split("#")[0].strip()
            if not clean_slug:
                continue
            # Path-Form (z.B. [[02-Wissen/foo]]) — extract last component
            base = Path(clean_slug).name
            if base in all_slugs or clean_slug in all_slugs:
                continue
            # Drift detected
            suggestion = _fuzzy_suggest(base, all_slugs)
            rel = p.relative_to(root).as_posix()
            report["files_with_drift"][rel].append((pos, slug, suggestion))
            report["dirty_links"] += 1

    return report


def apply_fixes(report: dict, threshold: float = 0.85, root: Path = BRAIN_ROOT) -> int:
    """Apply autofix wo Fuzzy-Match >= threshold. Return Zahl der Fixes."""
    applied = 0
    for rel, drifts in report["files_with_drift"].items():
        path = root / rel
        try:
            text = path.read_text(encoding="utf-8", errors="replace")
        except OSError:
            continue
        # Apply von hinten nach vorne damit Positionen stabil bleiben
        drifts_sorted = sorted(drifts, key=lambda x: x[0], reverse=True)
        new_text = text
        for pos, old_slug, suggestion in drifts_sorted:
            if not suggestion:
                continue
            # Re-check Score (Sicherheit)
            base = old_slug.split("#")[0].split("/")[-1].strip()
            score = SequenceMatcher(None, base.lower(), suggestion.lower()).ratio()
            if score < threshold:
                continue
            # Replace [[old_slug|...]] → [[suggestion|...]]
            # Find the actual link at pos
            m = WIKILINK_RE.match(new_text, pos)
            if not m:
                continue
            display = m.group(2)
            if display:
                replacement = f"[[{suggestion}|{display}]]"
            else:
                replacement = f"[[{suggestion}]]"
            new_text = new_text[:m.start()] + replacement + new_text[m.end():]
            applied += 1
        if new_text != text:
            path.write_text(new_text, encoding="utf-8")
    return applied


def print_report(report: dict, root: Path = BRAIN_ROOT) -> None:
    print(f"aria-citation-fixer report")
    print(f"  root: {root}")
    print(f"  scanned files: {report['total_files']:,}")
    print(f"  total wikilinks: {report['total_links']:,}")
    print(f"  dirty wikilinks: {report['dirty_links']:,}")
    if report["dirty_links"]:
        drift_pct = 100 * report["dirty_links"] / max(1, report["total_links"])
        print(f"  drift: {drift_pct:.1f}%")
        print()
        for rel, drifts in sorted(report["files_with_drift"].items())[:20]:
            print(f"  {rel}")
            for pos, slug, suggestion in drifts[:5]:
                sug = f" → {suggestion}" if suggestion else " (no good match)"
                print(f"    pos {pos:>6}: [[{slug}]]{sug}")
        if len(report["files_with_drift"]) > 20:
            extra = len(report["files_with_drift"]) - 20
            print(f"  ... and {extra} more files with drift")


def main(argv: list[str]) -> int:
    ap = argparse.ArgumentParser(description="Wikilink drift scanner/fixer for aria-brain")
    ap.add_argument("--apply", action="store_true", help="Apply autofixes (default: dry-run)")
    ap.add_argument("--threshold", type=float, default=0.85, help="Fuzzy-match threshold")
    ap.add_argument("--section", default=None, help="Limit to one section (e.g. 02-Wissen)")
    ap.add_argument(
        "--strict-exit",
        action="store_true",
        help="Exit 1 when dirty links are found (for CI gates). Default exits 0 — see KAR-375.",
    )
    args = ap.parse_args(argv[1:])

    report = scan(section=args.section)
    print_report(report)

    if args.apply and report["dirty_links"]:
        print()
        print(f"Applying autofixes (threshold={args.threshold})...")
        n = apply_fixes(report, threshold=args.threshold)
        print(f"  applied {n} fix(es)")
        # Re-scan to confirm
        report2 = scan(section=args.section)
        print(f"  remaining dirty: {report2['dirty_links']:,}")

    # KAR-375: dry-run reports return 0 even with findings. A "report fired"
    # is not a "the script broke" — exit 1 only on actual script failure
    # (caught above by the try/except in main and not reached here). Apply
    # mode still returns 0; only --strict-exit re-enables the old behaviour
    # for CI gates that want findings to block.
    if args.strict_exit and report["dirty_links"] and not args.apply:
        return 1
    return 0


if __name__ == "__main__":
    _log.event("script_start")
    sys.exit(main(sys.argv))
