#!/usr/bin/env python3
"""Generate ``docs/EXIT_CODES.md`` from :mod:`sr.exit_codes`.

The exit codes are a frozen contract: shell scripts and CI jobs branch on them.
The document is therefore *derived* from the enum, so it can never disagree with
the code — and ``sr/exit_codes.py`` can point at it without the reference going
stale.

Usage::

    python3 scripts/gen_exit_codes.py            # rewrite docs/EXIT_CODES.md
    python3 scripts/gen_exit_codes.py --check    # fail when out of date
    python3 scripts/gen_exit_codes.py --stdout
"""

from __future__ import annotations

import argparse
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))

from sr.exit_codes import DESCRIPTIONS, ExitCode
from sr.version import __version__

TARGET = ROOT / "docs" / "EXIT_CODES.md"

HEADER = f"""<!-- Generated by scripts/gen_exit_codes.py from sr/exit_codes.py — do not edit. -->

# sr — exit codes

Frozen contract, sr {__version__}. Every code below is produced by exactly one
place in the code base (`sr/exit_codes.py`), and `tests/test_contracts.py`
fails if a value changes without the test being updated deliberately.

| Code | Constant | Meaning |
| --- | --- | --- |
"""

FOOTER = """
## Conventions

* **`0` vs `3`** follow the `grep`/`diff` convention: `0` means "there was
  something to do", `3` means "nothing matched". That lets a script write
  `sr find X || echo missing` without shell gymnastics, and makes
  `sr replace --dry-run` a usable predicate.
* **`1` vs `2`** separate "something went wrong while working" from "you asked
  for something impossible". A bad flag, an unreadable config file or a missing
  target directory is `2`; an I/O failure halfway through a run is `1`.
* **`130`** is the conventional `128 + SIGINT`, so shells report a normal
  Ctrl-C instead of a generic failure.
* Non-zero is never "partial success": if any file failed to write, the code is
  `1` even though other files were changed. The per-file detail is in the
  summary and in `--json` (`.totals.failed`, `.errors`).

## Reading the code in a script

```sh
sr replace "$OLD" "$NEW" -p '*.py' --yes -q
case $? in
  0) echo "changed" ;;
  3) echo "nothing to do" ;;
  2) echo "usage error — fix the command line" >&2; exit 2 ;;
  *) echo "failed" >&2; exit 1 ;;
esac
```

Note that `$?` must be captured before any other command runs, and that
`set -e` will abort on `3` as well — use `if sr …; then` or `|| true` when "no
match" is an acceptable outcome.

## Per-command behaviour

| Command | `0` | `3` | other |
| --- | --- | --- | --- |
| `replace` | at least one file changed (or would change, with `--dry-run`) | nothing matched | `1` write/session error · `2` usage |
| `find` | at least one match | no match | `2` usage |
| `diff` | at least one difference | no difference | `2` usage |
| `rename` | at least one rename executed (or planned, with `--dry-run`) | nothing to rename | `1` error · `2` usage |
| `rollback` | everything requested was restored | — | `4` at least one file could not be restored · `1` unknown session |
| `sessions` | the action succeeded | — | `2` usage (e.g. `delete` without `--yes`) |
| `update` | up to date, or the update was installed | — | `5` network/checksum/permission failure |
| `doctor` | every check passed | — | `1` at least one check failed |
| `selftest` | every check passed | — | `6` at least one check failed |
| `config` | the action succeeded | — | `2` invalid key, value or file |
| `completions` | script printed or installed | — | `2` unsupported shell |
| `help` | topic rendered | — | `2` unknown topic |

## See also

```sh
sr help exit-codes      # the same table, in the terminal
sr help json            # the --json document, including .exit_code
man sr                  # EXIT STATUS section
```
"""


def render() -> str:
    rows = [
        f"| `{int(code)}` | `{code.name}` | {DESCRIPTIONS[int(code)]} |" for code in ExitCode
    ]
    return HEADER + "\n".join(rows) + "\n" + FOOTER


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument("--check", action="store_true", help="Fail when the file is missing or out of date.")
    parser.add_argument("--stdout", action="store_true", help="Print the document and change nothing.")
    parser.add_argument("--output", "-o", type=Path, default=TARGET)
    args = parser.parse_args(argv)

    text = render()
    if args.stdout:
        print(text)
        return 0

    target: Path = args.output
    current = target.read_text(encoding="utf-8") if target.is_file() else None
    if args.check:
        if current is None:
            print(f"{target} is missing; run `make docs`", file=sys.stderr)
            return 1
        if _stable(current) != _stable(text):
            print(f"{target} is out of date; run `make docs`", file=sys.stderr)
            return 1
        print(f"{target} is up to date")
        return 0

    target.parent.mkdir(parents=True, exist_ok=True)
    target.write_text(text, encoding="utf-8")
    print(f"wrote {target} ({len(text.splitlines())} lines)")
    return 0


def _stable(text: str) -> str:
    """Compare ignoring the version stamp, so a version bump alone is not a diff."""
    return "\n".join(line for line in text.splitlines() if not line.startswith("Frozen contract"))


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