#!/usr/bin/env python3
"""Compile every example, run it, and hold its output to a recorded answer key.

This is the spine of the library. A lesson page never hand-types what a program
prints; it marks the spot and this tool fills it from a real run:

    <!-- output:option_vs_result -->
    <!-- /output -->

Inside the markers is generated, outside is yours.

Three modes
-----------
    python3 tools/run_examples.py             verify + refill the .md blocks
    python3 tools/run_examples.py --update    accept current output as the key
    python3 tools/run_examples.py --check     write nothing; fail on any drift  (CI)
    python3 tools/run_examples.py --only X    touch example X and nothing else
                                 --only X,Y  or --only X --only Y for several

``--only`` exists because this checkout is sometimes open in two sessions at once.
A full ``--update`` re-records *every* answer key and refills *every* page, so
recording your own key adopts whatever a colleague's half-finished example happens
to print. ``--only`` narrows both halves to the stems you name. It is never right in
CI: a partial run cannot see repo-wide drift, which is the whole job there.

An example is any ``*.rs`` under a folder named ``examples/``. Its answer key is
the sibling ``<stem>.out``. Stems must be unique repo-wide, because a Markdown
block names a bare stem with no path — the same rule the star-voting-library uses
for its generated case pages.

Stdlib only, on purpose: the docs build already needs Python, and a teaching repo
should not make you install a toolchain to read it.
"""

from __future__ import annotations

import argparse
import os
import re
import subprocess
import sys
import tempfile
from pathlib import Path

REPO = Path(__file__).resolve().parent.parent
EDITION = "2024"

# <!-- output:stem -->  ...generated...  <!-- /output -->
# <!-- source:stem -->  ...generated...  <!-- /source -->
#
# Two kinds, one mechanism. `output` pastes what the program printed; `source`
# pastes the program itself. The second exists for kata solutions: a solution
# hand-copied into a fence is a solution that can quietly stop compiling, which
# is the one thing a practice page must never do.
BLOCK = re.compile(
    r"(?P<open><!--\s*(?P<kind>output|source):(?P<stem>[A-Za-z0-9_\-]+)\s*-->)"
    r"(?P<body>.*?)"
    r"(?P<close><!--\s*/(?P=kind)\s*-->)",
    re.DOTALL,
)

SKIP_DIRS = {".git", "site", ".venv", "target", "__pycache__", ".github"}

# A fenced code block, opened or closed. Needed because the pages that DOCUMENT
# this mechanism (README.md, CONTRIBUTING.md) show the markers as examples inside
# a fence — those must be left alone, not treated as blocks to fill.
FENCE = re.compile(r"^[ \t]*(?P<f>`{3,}|~{3,})", re.MULTILINE)


def fenced_spans(text: str) -> list[tuple[int, int]]:
    """Character ranges covered by fenced code blocks."""
    spans: list[tuple[int, int]] = []
    open_at: int | None = None
    open_fence = ""
    for m in FENCE.finditer(text):
        fence = m.group("f")
        if open_at is None:
            open_at, open_fence = m.start(), fence
        elif fence[0] == open_fence[0] and len(fence) >= len(open_fence):
            spans.append((open_at, m.end()))
            open_at = None
    if open_at is not None:
        spans.append((open_at, len(text)))
    return spans


def walk(root: Path):
    for dirpath, dirnames, filenames in os.walk(root):
        dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
        for name in filenames:
            yield Path(dirpath) / name


def find_examples() -> dict[str, Path]:
    """Map stem -> path for every .rs under an examples/ folder. Stems are unique."""
    found: dict[str, Path] = {}
    for path in sorted(walk(REPO)):
        if path.suffix != ".rs" or path.parent.name != "examples":
            continue
        stem = path.stem
        if stem in found:
            sys.exit(
                f"ERROR: duplicate example stem {stem!r}\n"
                f"  {found[stem].relative_to(REPO)}\n  {path.relative_to(REPO)}\n"
                "Stems are named bare in Markdown blocks, so they must be unique."
            )
        found[stem] = path
    return found


def run_example(src: Path, workdir: Path) -> str:
    """Compile and run one example; return its stdout. Exits on failure."""
    binary = workdir / src.stem
    build = subprocess.run(
        ["rustc", "--edition", EDITION, str(src), "-o", str(binary)],
        capture_output=True,
        text=True,
    )
    if build.returncode != 0:
        sys.exit(f"ERROR: {src.relative_to(REPO)} failed to compile\n{build.stderr}")
    if build.stderr.strip():
        print(f"  note: {src.relative_to(REPO)} compiled with warnings:\n{build.stderr}")

    run = subprocess.run([str(binary)], capture_output=True, text=True, timeout=60)
    if run.returncode != 0:
        sys.exit(
            f"ERROR: {src.relative_to(REPO)} exited {run.returncode}\n{run.stderr}"
        )
    return run.stdout


def rendered_block(kind: str, src: Path, output: str, page: Path) -> str:
    """The generated body that goes between the markers on `page`."""
    href = os.path.relpath(src, page.parent)
    if kind == "source":
        body = src.read_text(encoding="utf-8").strip("\n")
        return (
            f"\n*[`{src.name}`]({href}) in full — pasted here by "
            f"`tools/run_examples.py` from the file CI compiles and runs.*\n\n"
            f"```rust\n{body}\n```\n"
        )
    body = output.strip("\n")
    return (
        f"\n*Verified output of [`{src.name}`]({href}) — regenerated by "
        f"`tools/run_examples.py`, never hand-typed.*\n\n"
        f"```text\n{body}\n```\n"
    )


def fill_pages(
    outputs: dict[str, str],
    sources: dict[str, Path],
    write: bool,
    problems: list[str],
    only: set[str] | None = None,
) -> list[str]:
    """Refill every output block on every Markdown page. Returns drift descriptions.

    A block naming a stem that no longer exists is recorded in `problems` and left
    untouched, rather than exiting on the spot. It is still a failure — but dying
    on the first one would leave every *other* page unfilled, which matters when a
    page mid-rename is sitting in the working tree beside work that is ready.

    With `only` set, a block naming any other stem is left exactly as it is, and is
    reported as neither drift nor a problem. That holds for `source:` blocks as
    well as `output:` ones — the selection is checked before the kind is, so a run
    scoped to one stem cannot rewrite either half of somebody else's page. That is the point of `--only`: the
    blocks it does not fill belong to an example it did not run, and rewriting one
    from a key this run never verified is how a colleague's page gets edited by
    somebody who was working two folders away.
    """
    drift: list[str] = []
    for page in sorted(walk(REPO)):
        if page.suffix != ".md":
            continue
        text = page.read_text(encoding="utf-8")
        if "<!-- output:" not in text and "<!-- source:" not in text:
            continue
        skip = fenced_spans(text)

        def replace(m: re.Match) -> str:
            # A marker shown as an example inside a code fence is documentation,
            # not a block to fill.
            if any(lo <= m.start() < hi for lo, hi in skip):
                return m.group(0)
            stem = m.group("stem")
            kind = m.group("kind")
            if only is not None and stem not in only:
                return m.group(0)
            # A source block only needs the file; an output block needs the run.
            known = sources if kind == "source" else outputs
            if stem not in known:
                problems.append(
                    f"{page.relative_to(REPO)}: asks for {kind} block {stem!r}, "
                    "but no examples/*.rs has that stem"
                )
                return m.group(0)
            return (
                m.group("open")
                + rendered_block(kind, sources[stem], outputs.get(stem, ""), page)
                + m.group("close")
            )

        new = BLOCK.sub(replace, text)
        if new != text:
            drift.append(str(page.relative_to(REPO)))
            if write:
                page.write_text(new, encoding="utf-8")
    return drift


def examples_under(token: Path, examples: dict[str, Path]) -> set[str]:
    """Every example stem inside `token`, if `token` names a directory.

    Empty set when it is not a directory or holds no examples, so the caller can
    fall through to matching it as a stem and then to the unknown-token error.
    """
    for base in (token, REPO / token):
        try:
            if not base.is_dir():
                continue
            resolved = base.resolve()
        except OSError:
            continue
        held = {stem for stem, path in examples.items() if resolved in path.parents}
        if held:
            return held
    return set()


def resolve_selection(raw: list[str], examples: dict[str, Path]) -> set[str]:
    """Turn the `--only` values into stems.

    Accepts what you are likely to have on the clipboard: a bare stem, a path to
    the `.rs`, or a folder — which selects *every* example under it, lesson folder
    and whole section alike. Comma-separate them, repeat the
    flag, or mix the two — `action="append"` is there because argparse's default is
    last-wins, and `--only a --only b` silently verifying only `b` is precisely the
    quiet partial run this flag exists to prevent.

    A token that names nothing is an error rather than an empty selection, for the
    same reason: a typo that records nothing looks exactly like a successful run.
    """
    wanted: set[str] = set()
    unknown: list[str] = []
    tokens = [t for value in raw for t in value.split(",")]
    for token in (t.strip() for t in tokens):
        if not token:
            continue
        as_path = Path(token)
        held = examples_under(as_path, examples)
        if held:
            # A folder selects everything it holds. Matching it to the single
            # stem that happens to share its name would quietly skip the
            # `<topic>_kata.rs` sitting beside the main example — a half-run
            # that looks exactly like a clean one, which is the failure this
            # whole flag exists to prevent.
            wanted |= held
            continue
        for candidate in (token, as_path.stem, as_path.name):
            if candidate in examples:
                wanted.add(candidate)
                break
        else:
            unknown.append(token)
    if unknown:
        sys.exit(
            f"ERROR: --only names no such example: {', '.join(unknown)}\n"
            f"Known stems: {', '.join(sorted(examples))}"
        )
    return wanted


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--update", action="store_true", help="record current output as the answer key")
    ap.add_argument("--check", action="store_true", help="write nothing; fail on drift (CI)")
    ap.add_argument(
        "--only",
        action="append",
        metavar="STEM[,STEM…]",
        help="restrict to these example stems (a path to the .rs works too, and a "
        "folder selects every example under it); repeat the flag or comma-separate. "
        "Everything else is neither "
        "run, re-recorded, nor refilled. "
        "Use it with --update when someone else is working in the tree, so you "
        "record your own answer key without touching theirs. Not for CI.",
    )
    args = ap.parse_args()

    examples = find_examples()
    if not examples:
        print("No examples found (looked for *.rs under any examples/ folder).")
        return 0

    selected: set[str] | None = None
    if args.only:
        selected = resolve_selection(args.only, examples)

    outputs: dict[str, str] = {}
    failures: list[str] = []

    with tempfile.TemporaryDirectory() as tmp:
        workdir = Path(tmp)
        for stem, src in examples.items():
            key = src.with_suffix(".out")

            # Outside the selection: not ours. Not run, and deliberately not even
            # read — an output in hand is an output that `fill_pages` would write
            # into somebody else's page.
            if selected is not None and stem not in selected:
                continue

            actual = run_example(src, workdir)
            outputs[stem] = actual

            if args.update:
                key.write_text(actual, encoding="utf-8")
                print(f"  recorded  {key.relative_to(REPO)}")
                continue

            if not key.exists():
                failures.append(f"{src.relative_to(REPO)}: no answer key — run with --update")
                continue
            if key.read_text(encoding="utf-8") != actual:
                failures.append(
                    f"{src.relative_to(REPO)}: output differs from {key.name}"
                )
            else:
                print(f"  ok        {src.relative_to(REPO)}")

    drift = fill_pages(
        outputs, examples, write=not args.check, problems=failures, only=selected
    )

    if args.check and drift:
        failures.append(
            "Markdown output blocks are stale: " + ", ".join(drift)
            + " — run tools/run_examples.py"
        )
    elif drift:
        for page in drift:
            print(f"  filled    {page}")

    if failures:
        print("\nFAILED:")
        for f in failures:
            print(f"  - {f}")
        return 1

    if selected is not None:
        print(
            f"\n{len(selected)} of {len(examples)} example(s) verified. --only was in "
            f"effect: the other {len(examples) - len(selected)}, and every block that "
            "names one of them, were left untouched. Do a full run before committing."
        )
        return 0

    print(f"\n{len(examples)} example(s) verified against their recorded output.")
    return 0


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