#!/usr/bin/env python3
"""scripts/check-markdown-links.py

Verify every internal `[text](target)` link in every markdown file under
the repo resolves to an existing file.  Skips:
  * `http(s)://`, `mailto:`, `ftp://` — external schemes
  * pure-anchor links `#fragment`
  * code blocks fenced by ``` ... ```
  * link targets inside HTML tags that the markdown parser does not
    treat as links (we operate on raw text — false positives are
    explicitly excluded via the IGNORE patterns at the top of the file).

Walks `.` recursively.  Skips `code/deps/`, `build*`, `.git/`, the
generated `doc/doxygen/` tree, and `node_modules/`.

Exit codes:
  0  every internal link resolves
  1  at least one link is broken — prints `file:line: target` for each
"""
from __future__ import annotations
import os, re, sys

ROOT = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))

# Directories that are not part of our authored docs.
SKIP_DIR_PARTS = {"code/deps", ".git", "node_modules", "doc/doxygen"}
SKIP_DIR_PREFIXES = ("build", "build_T_", "test-reports")

# Files in those formats are sometimes generated.
SKIP_FILES = {"CHANGELOG-old.md"}

# Schemes to ignore (external).
EXTERNAL_SCHEME_RE = re.compile(r"^[a-z]+://|^mailto:", re.IGNORECASE)

# Standard markdown link: [text](target)
LINK_RE = re.compile(r"\[(?:[^\]]|\\\])+\]\(\s*<?([^)\s<>]+)>?\s*(?:\"[^\"]*\")?\)")

# Code-fence detection (``` or ~~~)
FENCE_RE = re.compile(r"^(?:```|~~~)")


def is_skipped_dir(rel: str) -> bool:
    parts = rel.split(os.sep)
    for skip in SKIP_DIR_PARTS:
        skip_parts = skip.split("/")
        for i in range(len(parts) - len(skip_parts) + 1):
            if parts[i : i + len(skip_parts)] == skip_parts:
                return True
    for p in parts:
        if any(p.startswith(pref) for pref in SKIP_DIR_PREFIXES):
            return True
    return False


def collect_md_files() -> list[str]:
    md: list[str] = []
    for dirpath, dirs, files in os.walk(ROOT):
        rel = os.path.relpath(dirpath, ROOT)
        if rel != "." and is_skipped_dir(rel):
            dirs[:] = []
            continue
        for f in files:
            if not f.lower().endswith(".md"):
                continue
            if f in SKIP_FILES:
                continue
            full = os.path.join(dirpath, f)
            md.append(full)
    return sorted(md)


def link_targets(text: str) -> list[tuple[int, str]]:
    """Yield (line_no, target) skipping code-fenced lines."""
    out: list[tuple[int, str]] = []
    in_fence = False
    for line_no, line in enumerate(text.splitlines(), start=1):
        if FENCE_RE.match(line.strip()):
            in_fence = not in_fence
            continue
        if in_fence:
            continue
        for m in LINK_RE.finditer(line):
            out.append((line_no, m.group(1)))
    return out


def resolve(src_path: str, target: str) -> tuple[bool, str | None]:
    """Return (ok, expected_path).

    ok=True  → link resolves (file exists, or external/anchor we skip).
    ok=False → file does not exist; expected_path is what we looked for.
    """
    if not target:
        return True, None
    if EXTERNAL_SCHEME_RE.match(target):
        return True, None
    if target.startswith("#"):
        # pure in-page anchor — we don't validate anchor existence
        return True, None
    # Strip any in-file anchor for file-existence check.
    target_file = target.split("#", 1)[0]
    if not target_file:
        return True, None
    if target_file.startswith("/"):
        # Absolute-from-repo-root link.
        full = os.path.join(ROOT, target_file.lstrip("/"))
    else:
        full = os.path.normpath(os.path.join(os.path.dirname(src_path), target_file))
    return os.path.exists(full), full


def main() -> int:
    files = collect_md_files()
    broken: list[tuple[str, int, str, str]] = []
    n_links = 0
    for src in files:
        try:
            with open(src, encoding="utf-8") as f:
                text = f.read()
        except OSError as e:
            print(f"WARN: cannot read {src}: {e}", file=sys.stderr)
            continue
        for line_no, target in link_targets(text):
            n_links += 1
            ok, expected = resolve(src, target)
            if not ok:
                broken.append((src, line_no, target, expected or "?"))
    print(f"Scanned {len(files)} markdown files, {n_links} internal links.")
    if broken:
        print(f"\nBROKEN: {len(broken)} link(s) do not resolve:")
        for src, line_no, target, expected in broken:
            rel = os.path.relpath(src, ROOT)
            print(f"  {rel}:{line_no}: ({target})  →  {os.path.relpath(expected, ROOT)} (missing)")
        return 1
    print("OK: every internal markdown link resolves.")
    return 0


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