#!/usr/bin/env python3
"""rein -- CLI for the Rein agentic kit.

Subcommands:
  rein doctor         environment probe (plugin wiring, CLIs, config, stack);
                      --json returns {"version", "pluginRoot", "project", "plan",
                      "verifyState", "ledger", "staleness"} -- staleness is the
                      installed-vs-marketplace verdict ("verdict", "reason",
                      "installedVersion", "availableVersion")
  rein setup          probe the recommended retrieval tools; --install adds what is missing
  rein detect         resolved stack + commands as JSON (config > runner > auto)
  rein verify         actually RUN each resolved command and report the truth
  rein tasks          parsed plan as JSON (tasks.md or openspec)
  rein plan-check     mechanical findings on a drafted plan's own text, as JSON
  rein context        detect + tasks in ONE call -- what the loop's first agent runs
  rein close <id>     tick a task's checkbox in the plan
  rein next           the DETERMINISTIC gate: is there a task to claim, and may it be
  rein review         record / check a review episode (approval is state-bound)
  rein token-report   real per-model token accounting for the last agent run
  rein event <name>   record a skill invocation as an event (D3, never a run)
  rein ledger         summary of recorded runs (per project / per session);
                      --json returns {"runs": [...], "events_by_project": {...}} --
                      runs is the unchanged array of run rows, events_by_project is
                      skill-invocation counts (D3), counted separately and never
                      folded into a run
  rein baseline       mark/show/clear the reference run compared against
  rein dashboard      serve (or --json print) the ledger as a view model

Kept dependency-free on purpose: a plugin that drags a toolchain along at install
time is a plugin nobody installs. Python 3 stdlib only.
"""

from __future__ import annotations

import json
import os
import shutil
import sys

VERSION = "0.12.0"

HERE = os.path.dirname(os.path.abspath(os.path.realpath(__file__)))
PLUGIN_ROOT = os.path.dirname(HERE)
sys.path.insert(0, os.path.join(PLUGIN_ROOT, "lib"))

import ansi as _ansi  # noqa: E402
import dashboard as _dash  # noqa: E402
import detect as _detect  # noqa: E402
import events as _events  # noqa: E402
import gate as _gate  # noqa: E402
import plan as _plan  # noqa: E402
import plan_check as _plan_check  # noqa: E402
import setup as _setup  # noqa: E402
import serve as _serve  # noqa: E402
import token_report as _tr  # noqa: E402
import verify as _verify  # noqa: E402
import version_staleness as _staleness  # noqa: E402

USAGE = __doc__


def _opt(argv: list[str], name: str, default: str = "") -> str:
    """Read `--name value` or `--name=value` without pulling in argparse."""
    for i, a in enumerate(argv):
        if a == f"--{name}" and i + 1 < len(argv):
            return argv[i + 1]
        if a.startswith(f"--{name}="):
            return a.split("=", 1)[1]
    return default


def _positional(argv: list[str]) -> str:
    for i, a in enumerate(argv):
        if a.startswith("-"):
            continue
        if i > 0 and argv[i - 1].startswith("--") and "=" not in argv[i - 1]:
            continue  # value of a preceding flag
        return a
    return ""


def cmd_detect(argv: list[str]) -> int:
    print(json.dumps(_detect.resolve(_positional(argv) or "."), indent=2))
    return 0


def cmd_verify(argv: list[str]) -> int:
    """Run every resolved command for real -- an inference is not a fact (D2).

    This command itself never repairs, installs, or writes anything -- it
    only runs and reports. But it runs the PROJECT'S OWN real test/lint/
    typecheck commands, in the operator's actual checkout, and those may
    write whatever they normally write (caches, coverage, build info): `rein
    verify` can leave a working tree dirtier than it found it, as a property
    of the commands being verified, not of this command.

    Exits non-zero only when a command is NOT INVOCABLE (missing binary, a
    shell resolution failure): a setup problem. A command that ran and
    reported failure, or one that timed out, is the ordinary state of a repo
    mid-change and does not fail this exit code -- conflating the two is
    exactly the misdirection this command exists to remove.

    `--only slot1,slot2` restricts execution to those slots -- the loop's
    Prepare precheck reads only test/lint/typecheck and has no use for
    running `build` (or the whole `test` suite a second time) in the
    operator's main checkout before Isolate.
    """
    root = _positional(argv) or "."
    timeout_raw = _opt(argv, "timeout", str(_verify.DEFAULT_TIMEOUT))

    # --plan: the per-task Verification commands, not the project's slots.
    # Nobody ran these before implementers were paid, and two tasks in a real
    # plan named a test module that did not exist -- they "passed" against
    # `no tests collected` and the defect surfaced two hours later at review.
    if "--plan" in argv:
        try:
            timeout = float(timeout_raw)
        except ValueError:
            timeout = _verify.DEFAULT_TIMEOUT
        resolved_root = os.path.abspath(root)
        plan_doc = _plan.read_plan(resolved_root, _opt(argv, "change", ""))
        report = _verify.verify_plan(resolved_root, plan_doc.get("tasks") or [], timeout=timeout)
        if "--json" in argv:
            print(json.dumps(report, indent=2))
        else:
            print(f"plan verifications for {report['root']}")
            if not report["results"]:
                print("  (the plan declares no tasks)")
            for tid, r in report["results"].items():
                mark = "✗" if r["outcome"] in (_verify.OUTCOME_PROVES_NOTHING,
                                               _verify.OUTCOME_NOT_INVOCABLE) else "·"
                print(f"  {mark} {tid:<6} {r['outcome']:<15} {r['command'][:56]}")
                if r.get("reason"):
                    print(f"           {r['reason'][:90]}")
            if report["unusable"]:
                print(f"\n  UNUSABLE: {', '.join(report['unusable'])} — these cannot confirm their "
                      f"own criteria, so an implementer would 'pass' without proving anything")
            else:
                print("\n  every verification can prove something (failing is fine; proving nothing is not)")
        # Non-zero ONLY for unusable. A verification that fails before the work
        # exists is the normal state of a plan and must not stop anything.
        return 0 if report["allUsable"] else 1
    try:
        timeout = float(timeout_raw)
    except ValueError:
        print(f"invalid --timeout: {timeout_raw!r}")
        return 2
    only_raw = _opt(argv, "only")
    only = {s.strip() for s in only_raw.split(",") if s.strip()} if only_raw else None

    resolved = _detect.resolve(root)
    report = _verify.verify_commands(resolved, timeout, only=only)
    _verify.write_state(resolved["root"], report)

    if "--json" in argv:
        print(json.dumps(report, indent=2))
        return 0 if report["allInvocable"] else 1

    print(f"verify: {resolved['root']}")
    if not report["results"]:
        print("  (no commands resolved -- nothing to verify)")
        return 0
    for slot, res in sorted(report["results"].items()):
        code = res["exitCode"] if res["exitCode"] is not None else "-"
        print(f"  {slot:<10} [{res['outcome']:<13}] exit={code}  {res['elapsedMs']}ms  $ {res['command']}")
        if res["outcome"] in (_verify.OUTCOME_NOT_INVOCABLE, _verify.OUTCOME_TIMEOUT) and res["error"]:
            print(f"    {res['error']}")
        for line in res["outputHead"][:5]:
            print(f"    | {line}")
    if not report["allInvocable"]:
        not_invocable = [s for s, r in report["results"].items() if not r["invocable"]]
        print(f"\n  NOT INVOCABLE: {', '.join(sorted(not_invocable))} -- a setup problem, fix the environment")
    return 0 if report["allInvocable"] else 1


def cmd_tasks(argv: list[str]) -> int:
    root = _positional(argv) or "."
    cfg = _detect.resolve(root)
    print(
        json.dumps(
            _plan.read_plan(
                root,
                source=_opt(argv, "source") or cfg["plan"]["source"],
                change=_opt(argv, "change"),
                configured=_opt(argv, "path") or cfg["plan"].get("path", ""),
            ),
            indent=2,
        )
    )
    return 0


def cmd_plan_check(argv: list[str]) -> int:
    """Mechanical, pre-agent findings on a drafted plan's own text (T001:
    the-plan-checks-itself) -- the shipped entry point for `plugins/rein/lib/
    plan_check.py`, which is otherwise unreachable in production.

    `rein plan-check <plan-file>` reads PLAN-FILE as plan markdown -- the
    drafted text before it is written to `plan.path`, or an already-written
    tasks.md -- and prints its BLOCKING/IMPORTANT findings as JSON. It never
    parses argv beyond the one positional path: no root resolution, no
    `flow.config.json` lookup, because the drafted text this exists to check
    usually is not on disk at `plan.path` yet.

    D5: unavailable is never a stop. A missing path, an unreadable file, or
    text that fails to parse each yield an empty `findings` list with the
    problem named in `error` -- this command always exits 0, so a caller can
    never be blocked by the check itself, only by what it finds.
    """
    path = _positional(argv)
    error = ""
    findings: list[dict] = []
    if not path:
        error = "usage: rein plan-check <plan-file>"
    elif not os.path.exists(path):
        error = f"no file at {path}"
    else:
        try:
            with open(path, encoding="utf-8", errors="replace") as fh:
                text = fh.read()
        except OSError as exc:
            error = str(exc)
        else:
            findings = _plan_check.mechanical_findings(text)

            # The half that needs the repository: a verification naming a test

            # module that neither exists nor is promised. Decidable, so BLOCKING.

            findings += _plan_check.unbacked_findings(text, os.path.dirname(os.path.abspath(path)) or '.')
    print(json.dumps({"path": path, "error": error, "findings": findings}, indent=2))
    return 0


def cmd_context(argv: list[str]) -> int:
    """Everything the loop needs to start, in one bash round-trip.

    The whole thesis is that turns cost context re-reads. Two commands is two
    round-trips; one is one.
    """
    root = _positional(argv) or "."
    cfg = _detect.resolve(root)
    plan = _plan.read_plan(
        root,
        source=_opt(argv, "source") or cfg["plan"]["source"],
        change=_opt(argv, "change"),
        configured=_opt(argv, "path") or cfg["plan"].get("path", ""),
    )
    ordered, stuck = _plan.order_by_dependencies(plan["pending"])
    print(
        json.dumps(
            {
                "config": cfg,
                "plan": {**plan, "tasks": plan["tasks"], "pending": ordered, "unresolvableDeps": stuck},
            },
            indent=2,
        )
    )
    return 0


def cmd_close(argv: list[str]) -> int:
    task_id = _positional(argv)
    if not task_id:
        print("usage: rein close <task-id> [root] [--change X]")
        return 2
    root = _opt(argv, "root", ".")
    cfg = _detect.resolve(root)
    plan = _plan.read_plan(root, source=cfg["plan"]["source"], change=_opt(argv, "change"),
                           configured=cfg["plan"].get("path", ""))
    if not plan["exists"]:
        print(plan.get("error") or f"no plan at {plan['path']}")
        return 1
    if _plan.close_task(plan["path"], task_id):
        print(f"closed {task_id} in {plan['path']}")
        return 0
    print(f"{task_id}: not found or already closed in {plan['path']}")
    return 1


def cmd_next(argv: list[str]) -> int:
    """The signal a bounded loop stops on. JSON by default: it is read by scripts."""
    root = _positional(argv) or "."
    cfg = _detect.resolve(root)
    result = _gate.next_task(
        root,
        source=_opt(argv, "source") or cfg["plan"]["source"],
        change=_opt(argv, "change"),
        configured=_opt(argv, "path") or cfg["plan"].get("path", ""),
    )
    print(json.dumps(result, indent=2))
    # Exit code carries the same signal, so `rein next && ...` works in a shell
    # without parsing anything.
    return 0 if result["ready"] else 1


def cmd_review(argv: list[str]) -> int:
    if not argv:
        print("usage: rein review <record|check> [...]")
        return 2
    sub, rest = argv[0], argv[1:]
    root = _opt(rest, "root", ".")
    change = _opt(rest, "change")

    if sub == "record":
        files = [f for f in _opt(rest, "files").split(",") if f.strip()]
        findings = [f for f in _opt(rest, "findings").split("|") if f.strip()]
        try:
            episode = _gate.record_review(
                root, change, _opt(rest, "verdict"), files, findings,
                _opt(rest, "reviewer"), _opt(rest, "agent"),
            )
        except (ValueError, OSError) as exc:
            print(f"refused to record: {exc}")
            return 1
        print(f"recorded {episode['verdict']} for {episode['change'] or '(change)'} -> {episode['path']}")
        print(f"  reviewed {len(episode['reviewed_files'])} file(s), state hash {episode['reviewed_state_hash'][:16]}...")
        return 0

    if sub == "check":
        result = _gate.check_review(root, change)
        if result["ok"]:
            print(f"APPROVED and current -- reviewed by {result['reviewer']} ({result['episode']})")
            for f in result.get("findings", [])[:10]:
                print(f"  finding [{f['severity']}]: {f['text']}")
            return 0
        print(f"gate NOT satisfied: {result['reason']}")
        if result["changed"]:
            print("  changed since approval: " + ", ".join(result["changed"][:10]))
        for f in result.get("findings", [])[:10]:
            print(f"  finding [{f['severity']}]: {f['text']}")
        return 1

    print(f"unknown review subcommand: {sub}\n\nusage: rein review <record|check> [...]")
    return 2


def cmd_setup(argv: list[str]) -> int:
    """Probe by default, install only when asked. A bare run changes nothing."""
    root = _positional(argv) or "."
    if "--json" in argv:
        print(json.dumps(_setup.probe(root), indent=2))
        return 0
    col = _ansi.enabled(argv)

    def print_summary(state: dict) -> None:
        print(_setup.render(state, color=col))
        ignore = _setup.gitignore_lines(root)
        if ignore:
            print(f"\n  these tools write local state into the repo — add to .gitignore: {', '.join(ignore)}")

    if "--install" not in argv:
        # --activate: the repo-scoped half ONLY -- no installs, and never a
        # write to a TRACKED file. It exists for the loop's worktree, which
        # is cut from a committed HEAD and therefore never carries
        # `.serena/` (gitignored, so it cannot travel). Without this, a
        # worktree is unactivated while the base repo's capability says
        # otherwise, and agents are handed serena tools for a directory
        # serena does not know as a project.
        #
        # D5 applies here too: activation IS work. The summary must be the
        # probe taken AFTER activating, not before -- otherwise this run
        # prints "present but inert: serena" and then, in the same run,
        # creates the marker that makes that statement false.
        if "--activate" in argv:
            res = _setup.activate_serena(root)
            print_summary(_setup.probe(root))
            print(f"\n  serena-activate: {'ok' if res['ok'] else 'FAILED'} — {res.get('reason', '')}")
            return 0  # never blocks a caller (D4): activation is a capability, not a gate

        state = _setup.probe(root)
        print_summary(state)
        return 0 if not state["missing"] else 1

    # `--activate` combined with `--install`: activation still runs before
    # install, and the summary is STILL a fresh probe taken after activating
    # -- the same ordering as the `--activate`-only branch above, so the two
    # cannot disagree about whether D5 applies (activation IS work; this run
    # can write `.serena/project.yml` and must not then report serena as
    # "present but inert").
    if "--activate" in argv:
        res = _setup.activate_serena(root)
        print_summary(_setup.probe(root))
        print(f"\n  serena-activate: {'ok' if res['ok'] else 'FAILED'} — {res.get('reason', '')}")
        return 0

    # D5: a summary describes the state AFTER the work, not before it -- a
    # repo whose index this run just built must not be reported inert, and
    # a .gitignore this run just wrote must not still be suggested. So the
    # work happens first; the summary below is a FRESH probe taken after.
    pre = _setup.probe(root)
    # Even with nothing "missing" there may still be work: a repo can carry
    # every binary and still not be activated for serena (D4/T004) -- so
    # --install always runs, it just has less to do when truly nothing
    # needs it.
    if pre["missing"]:
        print(f"installing: {', '.join(pre['missing'])}\n")
    report = _setup.install(root=root)
    failed = False
    for name, res in report["results"].items():
        print(f"  {name}: {'ok' if res['ok'] else 'FAILED'}")
        for step in res.get("steps", []):
            print(f"    $ {step['cmd']}  ->  {'ok' if step['ok'] else 'failed'}")
            if not step["ok"] and step["output"]:
                for line in step["output"].splitlines():
                    print(f"      {line}")
        if not res["ok"]:
            failed = True
            if res.get("reason"):
                print(f"    {res['reason']}")
        if res.get("caveat"):
            print(f"    note: {res['caveat']}")

    print()
    print_summary(_setup.probe(root))
    return 1 if failed else 0


def cmd_token_report(argv: list[str]) -> int:
    return _tr.main(argv)


def cmd_event(argv: list[str]) -> int:
    """Record a skill invocation as an EVENT (D3) -- never a run, never a
    failure for the caller (D4): always exits 0, even when the events file
    or its directory does not exist yet, or the directory is unwritable (the
    latter is reported on stderr so it is still visible, just never fatal).
    """
    name = _positional(argv)
    if not name:
        print("usage: rein event <name> [--root <path>]", file=sys.stderr)
        return 0
    root = _opt(argv, "root", ".")
    ok, error = _events.record_event(name, root)
    if not ok:
        print(f"rein event: could not record {name!r}: {error}", file=sys.stderr)
    return 0


def cmd_ledger(argv: list[str]) -> int:
    rows = _tr.read_ledger()
    # Events are counted separately (D3) and must never take the runs report
    # down with them: read_events() already degrades OSError/bad-JSON/bad-UTF8
    # to [], but guard the call itself too so ANY unexpected events-side
    # failure still leaves `rein ledger` reporting runs.
    try:
        events = _events.read_events()
        event_counts = _events.count_by_project(events)
    except Exception:
        event_counts = {}

    if not rows and not event_counts:
        print(f"ledger is empty ({_tr.LEDGER_PATH})")
        print("run `rein token-report` after a workflow run to record one")
        return 0

    as_json = "--json" in argv
    if as_json:
        print(json.dumps({"runs": rows, "events_by_project": event_counts}, indent=2, ensure_ascii=False))
        return 0

    if rows:
        try:
            baseline = _tr.read_baseline()
        except _tr.BaselineCorruptError as exc:
            print(str(exc))
            return 1
        print(_tr.render_ledger(rows, _tr.LEDGER_PATH, baseline))
    else:
        print(f"no runs recorded yet ({_tr.LEDGER_PATH})")

    if event_counts:
        # D3: skill invocations are counted here, separately -- never folded
        # into any run total above.
        print(f"\nskill invocations (events, not counted in runs) -- {_events.EVENTS_PATH}")
        for project, count in sorted(event_counts.items()):
            print(f"    {project}   {count} invocation(s)")

    return 0


def cmd_baseline(argv: list[str]) -> int:
    if not argv:
        print("usage: rein baseline <mark|show|clear> [wf_id]")
        return 2
    sub, rest = argv[0], argv[1:]

    if sub == "mark":
        try:
            record = _tr.mark_baseline(_positional(rest) or None)
        except (ValueError, OSError) as exc:
            print(str(exc))
            return 1
        project = record.get("project") or "unknown project"
        print(f"baseline marked: {record['wf_id']} ({project})")
        return 0

    if sub == "show":
        try:
            baseline = _tr.read_baseline()
        except _tr.BaselineCorruptError as exc:
            print(str(exc))
            return 1
        print(_tr.render_baseline(baseline))
        return 0

    if sub == "clear":
        print("baseline cleared" if _tr.clear_baseline() else "no baseline marked")
        return 0

    print(f"unknown baseline subcommand: {sub}\n")
    print("usage: rein baseline <mark|show|clear> [wf_id]")
    return 2


def cmd_serve_probe(argv: list[str]) -> int:
    """Start `--command`, poll `--url` until it accepts a TCP connection, tear down.

    Default (single-shot) form: start, poll, ALWAYS tear down, report. Prints
    exactly one JSON object and exits 0 only when ready is true, so an agent
    gets the signal from the exit code without parsing (D3-adjacent: a boolean
    the loop can check, not a sentence). Good for a throwaway boot check.

    `--start --pidfile <path>` starts the same way but leaves the process
    group running past this call (its pgid recorded in `pidfile`) so a render
    can happen against it. `--stop --pidfile <path>` is the matching teardown
    -- the SAME CLI, invoked a second time, still the one thing that owns the
    server's lifecycle (D2).
    """
    if "--stop" in argv:
        pidfile = _opt(argv, "pidfile")
        if not pidfile:
            print("usage: rein serve-probe --stop --pidfile <path>")
            return 2
        stopped, error = _serve.stop(pidfile)
        print(json.dumps({"stopped": stopped, "error": error}))
        return 0 if stopped else 1

    command = _opt(argv, "command")
    url = _opt(argv, "url")
    if not command or not url:
        print(
            "usage: rein serve-probe --command <c> --url <u> [--timeout N] [--cwd <dir>] "
            "[--start --pidfile <path>] | rein serve-probe --stop --pidfile <path>"
        )
        return 2
    timeout_raw = _opt(argv, "timeout", str(_serve.DEFAULT_TIMEOUT))
    try:
        timeout = float(timeout_raw)
    except ValueError:
        print(f"invalid --timeout: {timeout_raw!r}")
        return 2
    cwd = _opt(argv, "cwd", ".")

    if "--start" in argv:
        pidfile = _opt(argv, "pidfile")
        if not pidfile:
            print("usage: rein serve-probe --command <c> --url <u> --start --pidfile <path>")
            return 2
        result = _serve.start(command, cwd, url, timeout, pidfile)
        print(json.dumps(result.to_dict()))
        return 0 if result.ready else 1

    result = _serve.probe(command, cwd, url, timeout)
    print(json.dumps(result.to_dict()))
    return 0 if result.ready else 1


def cmd_dashboard(argv: list[str]) -> int:
    return _dash.main(argv)


def _annotated_verify_state(verify_state, resolved):
    """`verify_state` with each slot's freshness resolved, so `--json` says
    what the text output says.

    A persisted outcome is only about the command it was recorded against.
    flow.config.json, a lockfile-driven autodetect change or a newly-set
    `subproject` key can all rewrite the configured command out from under a
    report that already ran, and the text path has annotated that since it
    shipped. Emitting the raw report under --json made the two surfaces of
    one report disagree.
    """
    if not verify_state:
        return verify_state
    out = dict(verify_state)
    results = {}
    for slot, vr in (verify_state.get("results") or {}).items():
        cmd = (resolved.get("commands") or {}).get(slot)
        fresh = bool(vr) and vr.get("command") == cmd
        results[slot] = {
            **vr,
            "fresh": fresh,
            "freshness": "fresh" if fresh else "stale -- the command changed since `rein verify` last ran",
        }
    out["results"] = results
    return out


def cmd_doctor(argv: list[str]) -> int:
    root = argv[0] if argv and not argv[0].startswith("-") else "."
    resolved = _detect.resolve(root)
    ok = "ok"
    miss = "MISSING"

    plan = _plan.read_plan(
        root,
        source=resolved["plan"]["source"],
        change=_opt(argv, "change"),
        configured=resolved["plan"].get("path", ""),
    )
    if plan["exists"]:
        plan_state = f"{len(plan['pending'])} pending / {len(plan['tasks'])} tasks"
    elif plan["path"]:
        plan_state = f"NOT FOUND at {plan['path']}"
    else:
        # D3: no location to report a NOT FOUND at (an openspec source with
        # no change named) -- `error` names the changes that exist instead.
        plan_state = plan.get("error", "no plan")
    # Last-known verification state, read-only -- doctor never runs a command
    # itself (that is `rein verify`'s job). "known" means a prior `rein
    # verify` ran and persisted a report; nothing here re-derives it.
    verify_state = _verify.read_state(resolved["root"])
    verify_results = (verify_state or {}).get("results", {})
    runs = _tr.read_ledger()
    latest = _tr.latest_workflow_dir()

    # Is `rein` itself out of date? Local files only (D1): compares the
    # installed plugin's version against what its marketplace clone offers.
    # Never touches or even reads VERSION above -- the repo's own version is
    # a third fact that proves nothing about the clone (D3). Report, never
    # act (D2): doctor's exit code never reflects this verdict (D4).
    try:
        staleness, staleness_loaded = _staleness.resolve_verdict(PLUGIN_ROOT)
    except Exception as exc:  # noqa: BLE001 -- a staleness bug must never sink the whole report (D4)
        staleness = _staleness.StalenessResult(_staleness.UNKNOWN, f"staleness check failed: {exc}")
        staleness_loaded = _staleness.LoaderResult(
            installed_doc=None,
            marketplace_doc=None,
            plugin_key=None,
            plugin_name=None,
            marketplace_name=None,
            load_reason=str(exc),
        )

    if "--json" in argv:
        report = {
            "version": VERSION,
            "pluginRoot": PLUGIN_ROOT,
            "project": resolved,
            "plan": {**plan, "state": plan_state},
            # Annotated, not raw: the text path below compares each persisted
            # command against the currently resolved one and says "stale" when
            # they differ. Emitting the raw report here made the two surfaces
            # of the same data disagree -- a machine consumer read `ok` for a
            # command that had changed under it, the exact defect
            # TestChangedCommandIsStale exists to prevent for the text output.
            "verifyState": _annotated_verify_state(verify_state, resolved),
            "ledger": {
                "path": _tr.LEDGER_PATH,
                "runCount": len(runs),
                "latestWorkflowRun": latest,
            },
            "staleness": {
                **staleness.to_dict(),
                # The ORDER is the whole point of the change; a consumer that
                # has to re-derive it can get it wrong. Omitted entirely when
                # the names are unknown -- emitting `... update None` would be
                # a copy-pasteable command that cannot work, which is worse
                # than saying nothing (D3).
                **(
                    {"fixCommands": _staleness.fix_commands(
                        staleness_loaded.marketplace_name, staleness_loaded.plugin_name)}
                    if staleness_loaded.marketplace_name and staleness_loaded.plugin_name
                    else {}
                ),
            },
        }
        print(json.dumps(report, indent=2))
        return 0

    col = _ansi.enabled(argv)
    print(_ansi.paint(f"rein {VERSION}", "bold", on=col))
    print(f"  plugin root : {PLUGIN_ROOT}")
    print(f"  CLAUDE_PLUGIN_ROOT env : {os.environ.get('CLAUDE_PLUGIN_ROOT') or '(not set)'}")
    print(f"  `rein` on PATH : {shutil.which('rein') or '(not on PATH -- call it via $CLAUDE_PLUGIN_ROOT/bin/rein)'}")
    print(f"  version : {staleness.verdict} -- {staleness.reason}")
    if staleness.verdict == _staleness.STALE:
        for cmd in _staleness.fix_commands(staleness_loaded.marketplace_name, staleness_loaded.plugin_name):
            print(f"    $ {cmd}")
    elif staleness.verdict == _staleness.UP_TO_DATE:
        # D3: the clone offering the same version proves nothing about the
        # clone itself, so the refresh line is printed anyway.
        # fix_commands() is the ONE source of this string; spelling it out
        # again here put the same user-facing command in two files, each
        # pinned by a different test.
        refresh = _staleness.fix_commands(
            staleness_loaded.marketplace_name, staleness_loaded.plugin_name)[0]
        print(f"    $ {refresh}")
    print()
    print(f"  project root : {resolved['root']}")
    print(f"  flow.config.json : {'found' if resolved['configFound'] else 'not found (using autodetect)'}")
    print(f"  stack : {resolved['stack']}" + (f"  subtypes: {', '.join(resolved['subtypes'])}" if resolved["subtypes"] else ""))
    print(f"  package manager : {resolved['packageManager'] or '-'}")
    print(f"  task runner : {resolved['taskRunner'] or '-'}")
    print(f"  plan source : {resolved['plan']['source']}   tracker: {resolved['tracker']['kind']}")
    print(f"  plan        : {plan_state}")
    print()
    print(_ansi.paint("  resolved commands:", "bold", on=col))
    if resolved["commands"]:
        # Aligns on the LONGEST slot name actually present, not a fixed
        # width -- a long slot like `harnessTest` must not push the cmd/source
        # columns out of alignment for every other row (finding 4). Never
        # narrower than the historical width of 10, so a fixture with only
        # short names prints byte-identically to before this change.
        slot_width = max([10] + [len(s) for s in resolved["commands"]])
        # The command column was hardcoded at 40 while the single-subproject path
        # now emits `cd <dir> && <cmd>`, which is systematically longer -- so the
        # [source] column broke on exactly the repos this change added support for.
        cmd_width = max([40] + [len(c) for c in (resolved["commands"] or {}).values()])
        for slot, cmd in sorted(resolved["commands"].items()):
            vr = verify_results.get(slot)
            if vr is None:
                status = "  verified: unknown -- run `rein verify`"
            elif vr.get("command") == cmd:
                # Only trust the persisted outcome when it was recorded
                # against the SAME configured command we are printing --
                # flow.config.json, a lockfile-driven autodetect change, or a
                # newly-set `subproject` key can all rewrite `cmd` out from
                # under a report that already ran (finding 1).
                status = f"  verified: {vr['outcome']} (exit={vr['exitCode']})"
            else:
                status = "  verified: stale -- the command changed since `rein verify` last ran"
            source = _ansi.paint(f"[{resolved['commandSources'][slot]}]", "cyan", on=col)
            print(f"    {slot:<{slot_width}} {cmd:<{cmd_width}} {source}{status}")
    else:
        print("    (none)")
    if resolved["missingCommands"]:
        print(f"    {_ansi.paint(miss, 'red', on=col)}: {', '.join(resolved['missingCommands'])} -- set them in flow.config.json")
    if verify_state:
        import datetime as _dt

        checked = _dt.datetime.fromtimestamp(verify_state["checkedAt"]).isoformat(timespec="seconds")
        print(f"    last verified: {checked}")
    print()
    vp = resolved["verifyPolicy"]
    tools_str = ", ".join(vp["tools"]) if vp["tools"] else "(none reachable)"
    print(f"  verify : mode={vp['mode']}  tools={tools_str}")
    if resolved.get("verifyWarnings"):
        for w in resolved["verifyWarnings"]:
            print(f"    WARNING: {w}")
    serve = resolved.get("serve")
    if serve is not None:
        serve_cmd = serve["command"] or miss
        print(f"  serve  : {serve_cmd} @ {serve['url']}")
    print()
    print(f"  models : aux={resolved['models']['aux']}  impl={resolved['models']['impl']}  review={resolved['models']['review']}")
    print(f"  limits : maxTaskSteps={resolved['limits']['maxTaskSteps']}  maxReviewRounds={resolved['limits']['maxReviewRounds']}")
    print()
    print(f"  capabilities : {', '.join(resolved['capabilities']) or '(none)'}")
    for optional in ("graphify", "openspec", "serena", "codegraph"):
        if optional in resolved["capabilities"]:
            state = _ansi.paint(ok, "green", on=col)
        else:
            state = _ansi.paint("absent (optional -- flow degrades, never breaks)", "yellow", on=col)
        print(f"    {optional:<10} {state}")
    print()
    print(f"  ledger : {_tr.LEDGER_PATH} ({len(runs)} run(s) recorded)")
    print(f"  latest workflow run : {latest or '(none found)'}")
    return 0


COMMANDS = {
    "doctor": cmd_doctor,
    "setup": cmd_setup,
    "detect": cmd_detect,
    "verify": cmd_verify,
    "tasks": cmd_tasks,
    "plan-check": cmd_plan_check,
    "context": cmd_context,
    "close": cmd_close,
    "next": cmd_next,
    "review": cmd_review,
    "token-report": cmd_token_report,
    "event": cmd_event,
    "ledger": cmd_ledger,
    "baseline": cmd_baseline,
    "dashboard": cmd_dashboard,
    "serve-probe": cmd_serve_probe,
}


def main(argv: list[str]) -> int:
    if not argv or argv[0] in ("-h", "--help", "help"):
        print(USAGE)
        return 0
    if argv[0] in ("-V", "--version"):
        print(f"rein {VERSION}")
        return 0
    handler = COMMANDS.get(argv[0])
    if not handler:
        print(f"unknown subcommand: {argv[0]}\n")
        print(USAGE)
        return 2
    return handler(argv[1:])


if __name__ == "__main__":
    raise SystemExit(main(sys.argv[1:]))
