#!/usr/bin/env python3
"""Clean empty Obsidian artifacts ("Untitled…" notes/canvases, stray daily notes) out of the vault root.

Obsidian drops stray files in the vault root — an accidental Cmd-N, a misclick
on "New canvas", a daily note created before the daily-notes folder was set —
named Untitled.md / Untitled 1.canvas / New note.md / 2026-07-12.md and so on.
Real daily notes live in Daily/ as YYYY-MM-DD.md (see Agent/schemas/daily-note.md),
so a bare date-named file in the *root* is an artifact. This tool finds them,
deletes the ones with no real content, and reports (but never deletes) the ones
that have content — so a note the owner actually typed into is only ever flagged
for review, never silently removed.

Safety posture (kept from the reference implementation):
  * default is a DRY RUN that lists what it would do; --apply performs deletions;
  * only root-level files are considered — nothing inside any folder is touched;
  * only artifact-named files qualify (so Home.md etc. can never match);
  * only trivially-empty files are deleted (blank/whitespace, or an empty canvas
    skeleton like {} / {"nodes":[],"edges":[]}); anything with content is flagged;
  * git-tracked files are removed with `git rm` (left staged) so a commit records
    the removal; untracked ones are simply unlinked;
  * the vault root is resolved strictly (--vault / $VAULT_DIR / walk-up to the
    Config sentinel) — a deleting tool never guesses a root, so a miss is a hard
    error rather than a cwd fallback.

Every run prints a one-line scanned/deleted/flagged summary to stderr, so --json
stdout stays parseable.

Usage:
    python -m alexandria_vault.engine.clean_root --vault .            # dry run
    python -m alexandria_vault.engine.clean_root --vault . --apply    # delete the trivial artifacts
    python -m alexandria_vault.engine.clean_root --vault . --json     # machine-readable (composable with --apply)

Read-only unless --apply. Python 3.9+, standard library only.
"""
import argparse
import json
import re
import subprocess
import sys
from pathlib import Path

from alexandria_vault.vault_core import VaultNotFoundError, locate_vault

# Names Obsidian gives auto-created files (optionally numbered: "Untitled 3.md"),
# plus bare YYYY-MM-DD stems from accidental daily-note creation (real daily
# notes live in Daily/ as "YYYY-MM-DD.md", never in the root).
ARTIFACT_NAME = re.compile(
    r"^((Untitled|New note|New canvas|New base)( \d+)?|\d{4}-\d{2}-\d{2})$", re.IGNORECASE
)
ARTIFACT_EXTS = {".md", ".canvas", ".base"}

# Content that counts as "nothing in it": empty/whitespace, bare {}, or the
# empty-canvas skeleton Obsidian writes to a fresh canvas ({"nodes":[],"edges":[]}
# in either order, whitespace-tolerant). Anything else is real content and is
# never deleted.
EMPTY_CANVAS = re.compile(r'^\{\s*("nodes"\s*:\s*\[\s*\]\s*,?\s*|"edges"\s*:\s*\[\s*\]\s*,?\s*)*\}$')


def is_trivial(path: Path) -> bool:
    """True only for files it is safe to delete without asking: unreadable or
    content-bearing files fall through to the flagged bucket."""
    try:
        text = path.read_text(encoding="utf-8", errors="replace").strip()
    except OSError:
        return False
    if not text:
        return True
    if path.suffix.lower() in (".canvas", ".base"):
        return bool(EMPTY_CANVAS.match(text))
    return False


def scan(root: Path) -> dict:
    """Bucket the vault root's artifact-named files: trivially-empty -> delete,
    content-bearing -> flagged. Non-root files are never considered."""
    delete, flagged = [], []
    scanned = 0
    for f in sorted(root.iterdir()):
        if not f.is_file():
            continue
        scanned += 1
        if f.suffix.lower() not in ARTIFACT_EXTS or not ARTIFACT_NAME.match(f.stem):
            continue
        (delete if is_trivial(f) else flagged).append(f.name)
    return {"scanned": scanned, "delete": delete, "flagged": flagged}


def git_tracked(root: Path, names) -> set:
    """The subset of `names` git tracks — empty when git is absent or this isn't
    a repo, in which case deletions fall back to plain unlink."""
    if not names:
        return set()
    try:
        out = subprocess.run(
            ["git", "-C", str(root), "ls-files", "--", *names],
            capture_output=True, text=True,
        )
    except OSError:
        return set()
    return set(out.stdout.splitlines())


def apply_deletions(root: Path, names) -> int:
    """Delete `names` from the root — `git rm` (left staged) for tracked files so
    the removal lands in history, plain unlink otherwise. Returns how many were
    staged via git."""
    staged = 0
    tracked = git_tracked(root, names)
    for name in names:
        if name in tracked:
            try:
                subprocess.run(["git", "-C", str(root), "rm", "-q", "--", name], check=True)
                staged += 1
                continue
            except (OSError, subprocess.CalledProcessError):
                pass  # index locked / git vanished — fall back to a plain unlink
        try:
            (root / name).unlink()
        except FileNotFoundError:
            pass  # a concurrent process beat us to it — the goal state holds
    return staged


def warn(msg: str) -> None:
    print("clean_root: %s" % msg, file=sys.stderr)


def main() -> None:
    ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    ap.add_argument("--vault", type=Path, default=None,
                    help="vault root (default: $VAULT_DIR, else walk up from cwd)")
    ap.add_argument("--apply", action="store_true", help="actually delete (default: dry run)")
    ap.add_argument("--json", action="store_true", help="emit JSON instead of prose")
    args = ap.parse_args()

    # Strict resolution — no cwd fallback for a tool that deletes files.
    try:
        root = locate_vault(explicit=args.vault)
    except VaultNotFoundError as e:
        sys.exit("clean_root: %s" % e)

    result = scan(root)
    result["root"] = str(root)
    result["applied"] = False

    staged = 0
    if args.apply and result["delete"]:
        staged = apply_deletions(root, result["delete"])
        result["applied"] = True

    warn("scanned %d root files · %s %d empty artifact(s) · flagged %d" % (
        result["scanned"],
        "deleted" if result["applied"] else "would delete",
        len(result["delete"]), len(result["flagged"])))

    if args.json:
        print(json.dumps(result, indent=2))
        return

    if not result["delete"] and not result["flagged"]:
        print("Vault root is clean — no Obsidian artifacts found in %s" % root)
        return
    if result["delete"]:
        verb = "Deleted" if result["applied"] else "Would delete (rerun with --apply)"
        print("%s — empty artifacts:" % verb)
        for name in result["delete"]:
            print("  - %s" % name)
        if staged:
            print("Tracked deletions are staged; commit them (paths-only) to finish.")
    if result["flagged"]:
        print("Has content — NOT deleted, needs review/routing:")
        for name in result["flagged"]:
            print("  - %s" % name)


if __name__ == "__main__":
    main()
