#!/usr/bin/env python3
"""Rename every file and folder under a target to kebab-case, then repair the
markdown links that pointed at the old names.

convert.sh runs this first so markitdown always writes into an already
normalised tree; it is also safe to run standalone over the whole vault.

Naming rules: lowercase, Latin accents folded to ASCII, every non-alphanumeric
run collapsed to a single '-', no leading/trailing '-'. A leading dot (hidden
files, the sidecar asset folders) and the final extension are preserved.
Scripts without an ASCII equivalent (CJK) are kept as-is.
"""

from __future__ import annotations

import argparse
import os
import re
import sys
import unicodedata
from urllib.parse import quote, unquote

# Renaming any of these breaks something outside the vault's content: git's
# object store, the Obsidian config, or the markitdown plugin package (whose
# modules are imported by file name).
SKIP_DIRS = {
    ".git",
    ".obsidian",
    ".claude",
    ".setup",
    ".trash",
    "__pycache__",
    "node_modules",
}

# One level of nested parens so targets like ".cra-regulation-(eu)-2024-2847"
# survive; a trailing ' "title"' is split off separately.
MD_LINK_RE = re.compile(r"(!?\[[^\]]*\]\()([^()]*(?:\([^()]*\)[^()]*)*)(\))")
WIKI_LINK_RE = re.compile(r"(!?\[\[)([^\[\]|#^]+)([#^][^\[\]|]*)?(\|[^\[\]]*)?(\]\])")
HTML_ATTR_RE = re.compile(r"""(\b(?:src|href)\s*=\s*)(["'])(.*?)\2""", re.IGNORECASE)
TITLE_RE = re.compile(r'^(.*?)(\s+"[^"]*")$', re.DOTALL)

NON_PATH_PREFIXES = ("http://", "https://", "mailto:", "data:", "tel:", "ftp://", "//")


def slugify(text: str) -> str:
    decomposed = unicodedata.normalize("NFKD", text)
    stripped = "".join(c for c in decomposed if not unicodedata.combining(c))
    lowered = unicodedata.normalize("NFC", stripped).lower()
    dashed = "".join(c if c.isalnum() else "-" for c in lowered)
    return re.sub(r"-{2,}", "-", dashed).strip("-")


def kebab_name(name: str, is_dir: bool) -> str:
    hidden = name.startswith(".")
    core = name[1:] if hidden else name
    stem, ext = core, ""
    if not is_dir:
        head, dot, tail = core.rpartition(".")
        tail_slug = slugify(tail)
        if dot and head and tail_slug:
            stem, ext = head, "." + tail_slug
    return ("." if hidden else "") + (slugify(stem) or "untitled") + ext


def uniquify(name: str, taken: set[str]) -> str:
    if name.lower() not in taken:
        return name
    stem, dot, ext = name.rpartition(".")
    if not (dot and stem):
        stem, ext = name, ""
    else:
        ext = "." + ext
    for n in range(2, 1000):
        candidate = f"{stem}-{n}{ext}"
        if candidate.lower() not in taken:
            return candidate
    raise RuntimeError(f"cannot find a free name for {name!r}")


def under(path: str, target: str) -> bool:
    return path == target or path.startswith(target + os.sep)


def build_map(root: str, target: str) -> dict[str, str]:
    """Map every path under root to its post-rename path.

    Entries outside the target keep their name but may still move, because an
    ancestor inside the target was renamed.
    """
    path_map = {root: root}
    for dirpath, dirnames, filenames in os.walk(root):
        dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS)
        filenames.sort()
        new_parent = path_map[dirpath]

        entries = [(d, True) for d in dirnames] + [(f, False) for f in filenames]
        renaming, keeping = [], []
        for entry in entries:
            bucket = renaming if under(os.path.join(dirpath, entry[0]), target) else keeping
            bucket.append(entry)

        # Names that stay put claim their slot first, so a kebabbed sibling
        # never steals it and forces the untouched file to be suffixed.
        taken = {name.lower() for name, _ in keeping}
        for name, _ in keeping:
            path_map[os.path.join(dirpath, name)] = os.path.join(new_parent, name)
        for name, is_dir in renaming:
            new_name = uniquify(kebab_name(name, is_dir), taken)
            taken.add(new_name.lower())
            path_map[os.path.join(dirpath, name)] = os.path.join(new_parent, new_name)
    return path_map


def apply_renames(path_map: dict[str, str], dry_run: bool) -> int:
    # Deepest first: ancestors are still at their old location when a child is
    # renamed, so every key in the map stays a valid source path.
    pending = [(o, n) for o, n in path_map.items() if o != n]
    pending.sort(key=lambda pair: pair[0].count(os.sep), reverse=True)
    for old, new in pending:
        if os.path.basename(old) == os.path.basename(new):
            continue  # only an ancestor changed; the entry moves with it
        if dry_run:
            print(f"  rename {old} -> {os.path.basename(new)}", file=sys.stderr)
            continue
        target = os.path.join(os.path.dirname(old), os.path.basename(new))
        os.rename(old, target)
    return sum(1 for o, n in pending if os.path.basename(o) != os.path.basename(n))


class LinkFixer:
    def __init__(self, root: str, path_map: dict[str, str]):
        self.root = root
        self.path_map = path_map
        self.by_name: dict[str, list[str]] = {}
        for old in path_map:
            base = os.path.basename(old).lower()
            self.by_name.setdefault(base, []).append(old)
            stem = os.path.splitext(base)[0]
            if stem != base:
                self.by_name.setdefault(stem, []).append(old)

    def _lookup_unique(self, name: str) -> str | None:
        hits = self.by_name.get(name.lower(), [])
        return hits[0] if len(hits) == 1 else None

    def resolve(self, link: str, old_dir: str) -> tuple[str, str] | None:
        """Return (old_abs, base_dir) for a link, or None if it is not a path."""
        for base in (old_dir, self.root):
            candidate = os.path.normpath(os.path.join(base, link))
            if candidate in self.path_map:
                return candidate, base
        return None

    def fix_md_link(self, target: str, old_dir: str, new_dir: str) -> str:
        title = ""
        match = TITLE_RE.match(target)
        if match:
            target, title = match.groups()
        # CommonMark wraps targets containing spaces in angle brackets; kebab
        # names never need them, so unwrap and emit the bare path.
        angled = len(target) > 1 and target.startswith("<") and target.endswith(">")
        if angled:
            target = target[1:-1]
        anchor = ""
        if "#" in target:
            target, _, frag = target.partition("#")
            anchor = "#" + frag
        if not target or target.startswith(NON_PATH_PREFIXES):
            return None
        decoded = unquote(target)
        hit = self.resolve(decoded, old_dir)
        if hit is None:
            return None
        old_abs, base = hit
        new_base = new_dir if base == old_dir else self.root
        new = os.path.relpath(self.path_map[old_abs], new_base)
        # Only re-encode links that arrived encoded, so an untouched path is
        # left byte-identical instead of being silently rewritten.
        if decoded != target:
            new = quote(new, safe="/")
        if " " in new:
            new = f"<{new}>"
        return new + anchor + title

    def fix_wiki_link(self, target: str, old_dir: str) -> str | None:
        raw = target.strip()  # wikilink targets are literal paths, never encoded
        if not raw or raw.startswith(NON_PATH_PREFIXES):
            return None
        old_abs = None
        if "/" in raw:
            hit = self.resolve(raw, old_dir) or self.resolve(raw + ".md", old_dir)
            if hit:
                old_abs = hit[0]
        else:
            old_abs = self._lookup_unique(raw)
        if old_abs is None:
            return None
        new_abs = self.path_map[old_abs]
        new = os.path.relpath(new_abs, self.root) if "/" in raw else os.path.basename(new_abs)
        # Obsidian links written without an extension keep that shape.
        old_ext = os.path.splitext(old_abs)[1]
        if not (old_ext and raw.lower().endswith(old_ext.lower())):
            new = os.path.splitext(new)[0]
        return new

    def rewrite(self, text: str, old_dir: str, new_dir: str) -> tuple[str, int]:
        count = 0

        def md_sub(m):
            nonlocal count
            fixed = self.fix_md_link(m.group(2), old_dir, new_dir)
            if fixed is None or fixed == m.group(2):
                return m.group(0)
            count += 1
            return m.group(1) + fixed + m.group(3)

        def wiki_sub(m):
            nonlocal count
            fixed = self.fix_wiki_link(m.group(2), old_dir)
            if fixed is None or fixed == m.group(2):
                return m.group(0)
            count += 1
            return m.group(1) + fixed + (m.group(3) or "") + (m.group(4) or "") + m.group(5)

        def html_sub(m):
            nonlocal count
            fixed = self.fix_md_link(m.group(3), old_dir, new_dir)
            if fixed is None or fixed == m.group(3):
                return m.group(0)
            count += 1
            return m.group(1) + m.group(2) + fixed + m.group(2)

        text = MD_LINK_RE.sub(md_sub, text)
        text = WIKI_LINK_RE.sub(wiki_sub, text)
        text = HTML_ATTR_RE.sub(html_sub, text)
        return text, count


def fix_links(root: str, path_map: dict[str, str], dry_run: bool) -> tuple[int, int]:
    fixer = LinkFixer(root, path_map)
    files_changed = links_changed = 0
    for old, new in path_map.items():
        if not old.lower().endswith(".md") or not os.path.isfile(new if not dry_run else old):
            continue
        read_from = old if dry_run else new
        try:
            text = open(read_from, encoding="utf-8").read()
        except (UnicodeDecodeError, OSError) as exc:
            print(f"  ! skipping {read_from}: {exc}", file=sys.stderr)
            continue
        fixed, count = fixer.rewrite(text, os.path.dirname(old), os.path.dirname(new))
        if not count:
            continue
        files_changed += 1
        links_changed += count
        if dry_run:
            print(f"  {count:4d} links in {read_from}", file=sys.stderr)
        else:
            open(new, "w", encoding="utf-8").write(fixed)
    return files_changed, links_changed


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("target", help="file or folder to normalise")
    parser.add_argument("--root", help="vault root for link resolution (default: git root)")
    parser.add_argument("-n", "--dry-run", action="store_true")
    parser.add_argument(
        "--emit-target",
        action="store_true",
        help="print the target's post-rename path on stdout (all logs go to stderr)",
    )
    args = parser.parse_args()

    target = os.path.realpath(args.target)
    if not os.path.exists(target):
        parser.error(f"{args.target} does not exist")

    if args.root:
        root = os.path.realpath(args.root)
    else:
        root = target if os.path.isdir(target) else os.path.dirname(target)
        probe = root
        while probe != os.path.dirname(probe):
            if os.path.isdir(os.path.join(probe, ".git")):
                root = probe
                break
            probe = os.path.dirname(probe)
    if not under(target, root):
        parser.error(f"target {target} is outside root {root}")

    path_map = build_map(root, target)
    renamed = apply_renames(path_map, args.dry_run)
    files_changed, links_changed = fix_links(root, path_map, args.dry_run)

    verb = "would rename" if args.dry_run else "renamed"
    print(
        f"kebab-case: {verb} {renamed} entries, "
        f"rewrote {links_changed} links in {files_changed} markdown files",
        file=sys.stderr,
    )
    if args.emit_target:
        print(path_map.get(target, target))
    return 0


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