"""Human command-line entry point mirroring the MCP tools.

``citadel`` with its subcommands::

    citadel init [DIR]           # scaffold a workspace (citadel.toml marker, .env, raw/, wiki/)
    citadel ingest [paths ...]   # fold raw/ (or explicit paths) into the wiki
    citadel curate [--dry-run] [--limit N] [--stale-rules] [--diff PATH] [--retry]
                   [--guidance TEXT] [paths ...]   # improve existing pages
    citadel refresh [--limit N] [--min-age-days D] [--dry-run]  # re-verify the least-recently-checked sources
    citadel status               # per-source corpus state (ingested/failed/skipped/ignored/pending; mirrors wiki_status)
    citadel doctor               # read-only environment/setup health check (OK/WARN/FAIL lines)
    citadel serve [--http [--host H] [--port P] [--path /mcp] [--read-only]]  # run the MCP server (stdio, or Streamable HTTP)
    citadel capture <text> [--from WHO] [--topic T]  # append a note to the raw/ capture log (mirrors wiki_capture)
    citadel search <query> [--limit N] [--tag T]
    citadel define <term>        # glossary lookup: what a term / abbreviation stands for (mirrors wiki_define)
    citadel read <rel_path>      # print one page's full OKF text (mirrors wiki_read)
    citadel raw <key> [--locator L]  # print the raw source behind a citation (mirrors wiki_raw)
    citadel neighbors <rel_path> # a page's links out / backlinks / cited sources (mirrors wiki_neighbors)
    citadel index                # print the generated wiki/index.md (mirrors wiki_index)
    citadel sources              # print the generated wiki/sources/index.md (mirrors wiki_sources)
    citadel tags [tag]           # browse pages by tag
    citadel lint [--stale-days N]
    citadel check [paths ...]    # validate links/format/required-fields (the ingest gate)
    citadel view [--out PATH] [--no-open] [--obsidian]   # offline single-file HTML viewer
    citadel rules list|show|eject   # inspect / fork the rules files the ingest agent reads

The define/read/raw/neighbors/index/sources/capture/lint/status subcommands give an AI without MCP
access full parity with the server's tools (`view` stays CLI-only; `wiki_lint`/`wiki_status` close
the `lint`/`status` gaps from the MCP side).

Every subcommand except ``init`` and ``rules`` needs a resolved WORKSPACE (see config's discovery
order); ``main`` fails loud with exit 2 — pointing at ``citadel init`` and ``CITADEL_WORKSPACE``
— when none was found, instead of silently operating on a random CWD. ``rules list``/``show``
work workspace-less over the packaged defaults (handy for a pip user before ``init``); ``rules
eject`` checks for a workspace itself (the copy has nowhere to land without one).

Exit codes are CI-friendly: ingest returns 1 if any source errored OR a structural
problem remains (a broken cross-link or a page that failed validation — a missing or
unusable LLM CLI also surfaces as a per-source error), lint returns 3 if the report is
not clean (its OWN code, so "wiki has structural problems" is distinguishable from the
usage/no-workspace exit 2 argparse and the workspace guard use), check returns 1 on any
validation error, and any RuntimeError raised by a subcommand prints to stderr and
returns 1.

``serve`` imports the MCP server lazily inside cmd_serve, so merely importing
this module never requires the ``mcp`` package.
"""

from __future__ import annotations

import argparse
import sys

from . import __version__


def build_parser() -> argparse.ArgumentParser:
    """Build the ``citadel`` argument parser with its subcommands."""
    parser = argparse.ArgumentParser(
        prog="citadel", description="An LLM-maintained personal wiki in Google OKF, with an MCP search server."
    )
    # `--version` exits inside parse_args (like --help), so it works everywhere — including
    # a bare CWD with no workspace: main's fail-loud guard is never reached.
    parser.add_argument("--version", action="version", version=f"citadel {__version__}")
    # Every subcommand needs a resolved workspace unless it opts out (init, which CREATES one).
    parser.set_defaults(needs_workspace=True)
    sub = parser.add_subparsers(dest="command", required=True)

    p_init = sub.add_parser("init", help="Scaffold a citadel workspace (citadel.toml marker, .env, raw/, wiki/).")
    p_init.add_argument(
        "dir",
        nargs="?",
        default=".",
        help="Directory to initialize (created if missing; default: the current directory).",
    )
    p_init.set_defaults(func=cmd_init, needs_workspace=False)

    p_ingest = sub.add_parser("ingest", help="Fold new/changed raw files into the wiki (default: all of raw/).")
    p_ingest.add_argument(
        "paths",
        nargs="*",
        help="Specific files or directories to ingest (default: every file under raw/, recursively, any type).",
    )
    p_ingest.add_argument(
        "--quiet", action="store_true", help="Suppress the live per-file progress output (just print the final report)."
    )
    p_ingest.add_argument(
        "--verbose",
        "-v",
        action="store_true",
        help="Stream each LLM agent session's output live to the terminal (see exactly what the "
        "model does). copilot/agy show their full transcript; the claude CLI only emits its "
        "final result envelope — use --log-dir for a full claude record. Disables the spinner.",
    )
    p_ingest.add_argument(
        "--log-dir",
        default=None,
        metavar="DIR",
        help="Write a transcript file per source (prompt + full CLI output + exit/duration) to "
        "DIR, so you can inspect what the model did even in headless mode. Overrides "
        "CITADEL_LLM_LOG_DIR.",
    )
    p_ingest.add_argument(
        "--full-rescan",
        action="store_true",
        help="Distrust the manifest's stat cache and re-hash every tracked source (sha256 still "
        "decides: an unchanged source is re-stamped, not re-ingested). Use after moving a "
        "workspace or when the cache is suspect.",
    )
    p_ingest.add_argument(
        "--force",
        action="store_true",
        help="Deliberately re-read the given sources even when already ingested and unchanged: "
        "each runs a reconcile session (re-verifying the wiki's facts under the current rules) "
        "and its manifest entry is re-stamped with the current model + rules version; a stuck "
        "unreadable/errored/duplicate record is retried. Requires explicit paths — refused "
        "without them, so a whole-corpus re-read (one agent session per source) can never "
        "happen by accident.",
    )
    p_ingest.add_argument(
        "--reingest",
        action="store_true",
        help="Re-import the given already-ingested sources FRESH: first a delete cleanup session "
        "strips each source's previous facts from the wiki (its manifest entry is dropped), then "
        "the same run ingests it as a brand-new source under the current model + rules — the "
        "full re-think --force's reconcile deliberately avoids (reconcile keeps the existing "
        "treatment). Costs two sessions per source. Requires explicit paths, like --force.",
    )
    p_ingest.add_argument(
        "--retry",
        action="store_true",
        help="Retry everything stuck, without naming paths: re-run every FAILED source "
        "(errored/timed-out/unreadable, from the failures catalog) that is still on disk, and "
        "force-reconcile every ingested source that NO wiki page cites (it produced 0 entries — "
        "the `NO PAGES` marker in `citadel status`). Prints the retry set first; refuses "
        "explicit paths and --force (use those for a hand-picked re-read).",
    )
    p_ingest.add_argument(
        "--guidance",
        default=None,
        metavar="TEXT",
        help="Free-text steer for this run's agent sessions (e.g. \"create one machine registry "
        "from the maintenance lists\"), appended to each source-reading session's prompt. "
        "Guidance steers routing and emphasis WITHIN the rules — it can never override the "
        "citation/provenance rules. Keep it short (a couple of sentences; max 2000 chars). "
        "Overrides CITADEL_INGEST_GUIDANCE; permanent house rules belong in rules/local.md.",
    )
    p_ingest.add_argument(
        "--jobs",
        "-j",
        type=int,
        default=None,
        metavar="N",
        help="Fold up to N sources CONCURRENTLY (default 1 = serial, or CITADEL_JOBS). Each "
        "source keeps its own staging copy and its own all-or-nothing promote; promotes are "
        "serialized, and a source that raced another over the same page is re-run serially. "
        "Faster on a large backlog of unrelated sources; the trade-off is cross-linking, since "
        "concurrent sessions cannot see each other's new pages.",
    )
    p_ingest.set_defaults(func=cmd_ingest)

    p_curate = sub.add_parser(
        "curate",
        help="Improve EXISTING pages (re-sort, split, re-ground, resolve contradictions) against a "
        "recomputed findings checklist — the second wiki lifecycle beside ingest.",
    )
    p_curate.add_argument(
        "--dry-run",
        action="store_true",
        help="Recompute and print the plan only; run zero agent sessions and leave the wiki untouched.",
    )
    p_curate.add_argument(
        "--limit", type=int, default=None, metavar="N", help="Curate at most the first N clusters of the plan."
    )
    p_curate.add_argument(
        "--stale-rules",
        action="store_true",
        help="Restrict the plan to pages whose source was ingested under an older rulebook.",
    )
    p_curate.add_argument(
        "--diff",
        default=None,
        metavar="PATH",
        help="Write a per-page change report (unified diffs) for this run to PATH.",
    )
    p_curate.add_argument("--retry", action="store_true", help="Include attempt-capped clusters in this run.")
    p_curate.add_argument(
        "--guidance",
        default=None,
        metavar="TEXT",
        help="Free-text steer for this run's curate sessions (e.g. \"retitle the machine pages to "
        "'<machine no> <model>' and move them into registries/\"), written into every cluster's "
        "findings checklist. A STRUCTURAL steer only — naming, typing, routing, splitting, "
        "merging, cross-linking; it can never change, add, or re-attribute a fact. Naming PATHS "
        "alongside it curates exactly those pages, even ones no detector flagged. Overrides "
        "CITADEL_CURATE_GUIDANCE; max 2000 chars.",
    )
    p_curate.add_argument(
        "paths",
        nargs="*",
        help="Restrict the run to these pages: a page path (concepts/x.md), a FOLDER (registries/) "
        "or a glob (registries/machine-*.md). Without --guidance they filter the detector plan; "
        "with it they are the ask itself, so each named page gets a curate session.",
    )
    p_curate.set_defaults(func=cmd_curate)

    p_refresh = sub.add_parser(
        "refresh",
        help="Re-verify the least-recently-checked sources under the current model + rules, on a "
        "budget you choose (--limit N sources = N agent sessions) — the third lifecycle beside "
        "ingest and curate, for keeping an aging wiki current without ever regenerating it.",
    )
    p_refresh.add_argument(
        "--limit",
        type=int,
        default=1,
        metavar="N",
        help="Refresh at most the N least-recently-checked sources (default 1; each source is one "
        "agent session, so N is your per-run budget).",
    )
    p_refresh.add_argument(
        "--min-age-days",
        type=int,
        default=0,
        metavar="D",
        help="Only consider sources last checked more than D days ago (default 0: no age floor). "
        "Makes a scheduled run self-limiting: once everything is fresh it becomes a no-op.",
    )
    p_refresh.add_argument(
        "--dry-run",
        action="store_true",
        help="Print which sources WOULD be refreshed (oldest-checked first) and run zero agent sessions.",
    )
    p_refresh.add_argument(
        "--quiet", action="store_true", help="Suppress the live per-file progress output (just print the final report)."
    )
    p_refresh.add_argument(
        "--verbose",
        "-v",
        action="store_true",
        help="Stream each LLM agent session's output live to the terminal (see `citadel ingest --verbose`).",
    )
    p_refresh.add_argument(
        "--log-dir",
        default=None,
        metavar="DIR",
        help="Write a transcript file per source to DIR (see `citadel ingest --log-dir`). "
        "Overrides CITADEL_LLM_LOG_DIR.",
    )
    p_refresh.add_argument(
        "--jobs",
        "-j",
        type=int,
        default=None,
        metavar="N",
        help="Re-verify up to N sources CONCURRENTLY (default 1 = serial, or CITADEL_JOBS) — the "
        "same knob, and the same trade-off, as `citadel ingest --jobs`. A refresh slice is ordered "
        "by last-checked time rather than by topic, so its sources are usually unrelated and "
        "parallelism costs little cross-linking here.",
    )
    p_refresh.set_defaults(func=cmd_refresh)

    p_status = sub.add_parser(
        "status",
        help="Show per-source corpus state: ingested / failed / skipped-duplicate / ignored / pending "
        "(read-only; reads the manifest + failures catalog, never re-hashes).",
    )
    p_status.add_argument(
        "--json",
        action="store_true",
        help="Emit the report as JSON (one object with the five buckets + rules_version) for scripts.",
    )
    p_status.add_argument(
        "--exit-code",
        action="store_true",
        help="Exit 1 when any source is failed or pending (a CI gate for 'corpus fully ingested'); "
        "default stays 0 regardless.",
    )
    p_status.set_defaults(func=cmd_status)

    p_doctor = sub.add_parser(
        "doctor",
        help="Diagnose the setup (workspace / rules / agent CLI / raw roots / manifest / billing) "
        "with OK/WARN/FAIL lines; read-only, exits 1 only on a FAIL. Runs without a workspace.",
    )
    p_doctor.set_defaults(func=cmd_doctor, needs_workspace=False)

    p_serve = sub.add_parser(
        "serve", help="Run the MCP server (13 tools: 11 read-only + the mutating wiki_capture/wiki_ingest)."
    )
    p_serve.add_argument(
        "--http",
        action="store_true",
        help="Serve over MCP's Streamable HTTP transport instead of stdio, so a client that is not "
        "on this machine (claude.ai, a phone via a tunnel) can reach the wiki. Requires "
        "CITADEL_HTTP_TOKEN; every request must send 'Authorization: Bearer <token>'.",
    )
    p_serve.add_argument(
        "--host",
        default=None,
        help="Bind address for --http (default CITADEL_HTTP_HOST, else 127.0.0.1). A non-loopback "
        "bind warns: this transport is plain HTTP, so put a TLS-terminating tunnel in front.",
    )
    p_serve.add_argument(
        "--port", type=int, default=None, help="Bind port for --http (default CITADEL_HTTP_PORT, else 8765)."
    )
    p_serve.add_argument(
        "--path", default=None, help="MCP endpoint path for --http (default CITADEL_HTTP_PATH, else /mcp)."
    )
    p_serve.add_argument(
        "--read-only",
        action="store_true",
        default=None,
        help="Disable the two mutating tools (wiki_capture, wiki_ingest) for this server — the "
        "eleven readers stay. --http only; also CITADEL_HTTP_READ_ONLY=1.",
    )
    p_serve.set_defaults(func=cmd_serve)

    p_capture = sub.add_parser(
        "capture",
        help="Append one attributed note from a conversation to the raw/ capture log "
        "(raw/captures/YYYY-MM.md; mirrors wiki_capture) — the next ingest folds it into the wiki.",
    )
    p_capture.add_argument(
        "text", help="The note to capture, or '-' to read it from stdin (e.g. piping a statement in)."
    )
    p_capture.add_argument(
        "--from",
        dest="source",
        default="",
        metavar="WHO",
        help="Attribution: who said it / where it came from (e.g. 'Kim, chat 2026-07-24').",
    )
    p_capture.add_argument("--topic", default="", help="Optional topic hint for the entry heading.")
    p_capture.set_defaults(func=cmd_capture)

    p_read = sub.add_parser("read", help="Print the full OKF text of one wiki page (mirrors the wiki_read MCP tool).")
    p_read.add_argument("rel_path", help="Page to print, e.g. concepts/transformer.md.")
    p_read.set_defaults(func=cmd_read)

    p_raw = sub.add_parser(
        "raw", help="Print the raw source behind a citation, optionally a locator slice (mirrors wiki_raw)."
    )
    p_raw.add_argument("source_key", help="Cited source key, e.g. raw/notes.md (as shown in a page's ## Sources).")
    p_raw.add_argument("--locator", default="", help="Citation locator tail, e.g. 'lines 76-83' or '§ Method'.")
    p_raw.set_defaults(func=cmd_raw)

    p_neighbors = sub.add_parser(
        "neighbors", help="Print a page's links out, backlinks, and cited sources (mirrors wiki_neighbors)."
    )
    p_neighbors.add_argument("rel_path", help="Page whose neighborhood to print, e.g. concepts/transformer.md.")
    p_neighbors.set_defaults(func=cmd_neighbors)

    p_index = sub.add_parser(
        "index", help="Print wiki/index.md — the generated catalog of every page (mirrors wiki_index)."
    )
    p_index.set_defaults(func=cmd_index)

    p_sources = sub.add_parser(
        "sources", help="Print wiki/sources/index.md — the generated provenance catalog (mirrors wiki_sources)."
    )
    p_sources.set_defaults(func=cmd_sources)

    p_search = sub.add_parser(
        "search", help="Ranked BM25 search across the wiki pages (terms AND-matched; tag:x/type:y filter)."
    )
    p_search.add_argument("query", help="Search query.")
    p_search.add_argument(
        "--limit", type=int, default=8, help="Maximum number of hits to return (default: 8; <= 0 falls back to 8)."
    )
    p_search.add_argument(
        "--tag", default=None, help="Restrict the search to pages carrying this tag (case-insensitive)."
    )
    p_search.set_defaults(func=cmd_search)

    p_define = sub.add_parser(
        "define", help="Glossary lookup: what a term/abbreviation stands for or means (mirrors wiki_define)."
    )
    p_define.add_argument("term", help="Term to define, e.g. an abbreviation short form or a page title.")
    p_define.set_defaults(func=cmd_define)

    p_tags = sub.add_parser("tags", help="List all tags and the pages under each (browse the wiki by topic).")
    p_tags.add_argument("tag", nargs="?", default=None, help="Show only this tag's pages (default: list every tag).")
    p_tags.set_defaults(func=cmd_tags)

    p_lint = sub.add_parser(
        "lint",
        help="Static health check (contradictions/orphans/missing-cites/broken-links/missing-type/stale). "
        "Exit codes: 0 clean, 3 report not clean (2 stays the usage/no-workspace code).",
    )
    p_lint.add_argument(
        "--stale-days", type=int, default=365, help="Pages older than this many days are flagged stale (default: 365)."
    )
    p_lint.set_defaults(func=cmd_lint)

    p_check = sub.add_parser("check", help="Validate page links, file format, and required fields (the ingest gate).")
    p_check.add_argument(
        "paths", nargs="*", help="Wiki pages to check (rel_path or file path); default: the whole wiki."
    )
    p_check.set_defaults(func=cmd_check)

    p_view = sub.add_parser("view", help="Generate and open a single-file, offline HTML viewer for the wiki.")
    p_view.add_argument("--out", default=None, help="Where to write the .html (default: wiki/.citadel_viewer.html).")
    p_view.add_argument(
        "--no-open", dest="open_browser", action="store_false", help="Write the file but do not launch a browser."
    )
    p_view.add_argument(
        "--obsidian",
        action="store_true",
        help="Open the wiki folder as an Obsidian vault instead (best-effort deep link).",
    )
    p_view.set_defaults(func=cmd_view, open_browser=True)

    p_rules = sub.add_parser("rules", help="Inspect and customize the rules files the ingest agent reads.")
    rules_sub = p_rules.add_subparsers(dest="rules_command", required=True)
    p_rules_list = rules_sub.add_parser(
        "list", help="List every effective rules file (a workspace rules/ override shadows the packaged one)."
    )
    p_rules_list.set_defaults(func=cmd_rules_list, needs_workspace=False)
    p_rules_show = rules_sub.add_parser("show", help="Print one effective rules file.")
    p_rules_show.add_argument("relname", help="Tree-relative rules file name, e.g. core.md or genres/email.md.")
    p_rules_show.set_defaults(func=cmd_rules_show, needs_workspace=False)
    p_rules_eject = rules_sub.add_parser(
        "eject",
        help="Copy a packaged rules file into the workspace rules/ for editing (the copy shadows the "
        "packaged file and is yours; it no longer updates with pip). Refuses to overwrite.",
    )
    p_rules_eject.add_argument("relname", help="Tree-relative rules file name, e.g. core.md or genres/email.md.")
    p_rules_eject.set_defaults(func=cmd_rules_eject, needs_workspace=False)

    return parser


def cmd_init(args: argparse.Namespace) -> int:
    """Scaffold a workspace at DIR (default: CWD). Idempotent — existing files/dirs are reported
    as skipped, never overwritten — and always exits 0 (a fully-initialized workspace is not an
    error)."""
    from pathlib import Path

    from . import workspace

    root, results = workspace.init_workspace(Path(args.dir))
    print(f"Workspace: {root}")
    for status, name in results:
        print(f"  {status} {name}")
    if all(status == "skipped" for status, _ in results):
        print("Nothing to do — the workspace is already initialized.")
    return 0


def _effective_guidance(flag: str | None, env_value: str, env_name: str) -> tuple[str, str | None]:
    """Resolve one lifecycle's ``--guidance`` flag against its env twin, returning
    ``(steer, error message or None)`` — the ONE owner of the convention both ``ingest`` and
    ``curate`` follow:

    - the flag wins when given, and an explicit ``--guidance ""`` DISABLES an env-set steer for
      this run (the same convention as ``--log-dir ""``); omitting it keeps the env value;
    - whitespace, newlines included, collapses to single spaces (a steer is line-shaped: ingest's
      lands on a prompt bullet, curate's on a findings line);
    - the length cap is checked on the EFFECTIVE steer — flag or env alike — because the cap exists
      to keep an agent invocation argv-safe (the prompt travels as a ``-p`` argument on the
      copilot/agy backends) and an env-set oversize steer would hit that exact limit; a steer that
      long belongs in ``rules/local.md``, which every session already reads."""
    from . import config

    guidance = " ".join(flag.split()) if flag is not None else env_value
    if len(guidance) > config.GUIDANCE_MAX_CHARS:
        source = "--guidance" if flag is not None else env_name
        return guidance, (
            f"error: {source} is {len(guidance)} chars (max {config.GUIDANCE_MAX_CHARS}); a run "
            "steer is a couple of sentences — put anything longer in the workspace "
            "rules/local.md, which every session already reads."
        )
    return guidance, None


def cmd_ingest(args: argparse.Namespace) -> int:
    """Run an ingest pass (with live progress unless --quiet); 1 if any source errored.

    ``--verbose`` and ``--log-dir`` flip the observability knobs in ``config`` (read at call time by
    ``llm._run_session``): verbose streams each agent session live to the terminal, and log-dir
    writes a per-source transcript. With ``--verbose`` the spinner is suppressed so the streamed
    transcript is not overwritten by the in-place progress line.

    ``--force`` requires explicit paths: a forced re-read runs one
    agent session per source, so forcing the ENTIRE corpus must never happen by accident — the
    flag alone is refused with exit 2, before ``ingest.ingest`` is ever called.

    ``--reingest`` is force's bigger sibling — strip the named sources' previous facts (a delete
    cleanup session each), then re-import them fresh in the same run. Same explicit-paths rule as
    ``--force`` (exit 2 without them), and the two flags refuse each other: reconcile-in-place
    and strip-and-re-import are different intents, and combining them would blur which one wins.

    ``--retry`` is the no-typing complement: it computes its own bounded set —
    ``ingest.retry_candidates()``, the failed sources still on disk plus the ingested ones no
    wiki page cites — prints it, and runs those as a FORCED re-read. It therefore refuses
    explicit paths, ``--force`` and ``--reingest`` (exit 2): the point of the flag is that
    citadel picks the set, and combining them would blur which one wins. Nothing to retry is a
    clean exit 0.

    ``--jobs N`` is a usage error below 1 (exit 2, like ``--force`` without paths) rather than an
    exception out of the API layer; omitted, the run takes ``CITADEL_JOBS`` (default 1, serial)."""
    from . import config, ingest

    if args.jobs is not None and args.jobs < 1:
        print(f"error: --jobs must be at least 1 (got {args.jobs}); 1 means the serial default.", file=sys.stderr)
        return 2

    if args.retry and (args.paths or args.force or args.reingest):
        print(
            "error: --retry computes its own retry set (every failed source plus every ingested "
            "source no wiki page cites) and cannot be combined with explicit paths, --force, or "
            "--reingest; use `citadel ingest --force <paths>` (or `--reingest <paths>`) for a "
            "hand-picked re-read instead.",
            file=sys.stderr,
        )
        return 2

    if args.force and args.reingest:
        print(
            "error: --force and --reingest are different intents — --force reconciles the "
            "source's facts in place (keeping its existing treatment), --reingest strips them "
            "and re-imports the source fresh. Pick one.",
            file=sys.stderr,
        )
        return 2

    if args.force and not args.paths:
        print(
            "error: --force requires explicit paths (a forced re-read runs one agent session per "
            "source; name the files or directories to force, e.g. `citadel ingest --force raw/notes.md`).",
            file=sys.stderr,
        )
        return 2

    if args.reingest and not args.paths:
        print(
            "error: --reingest requires explicit paths (each source runs a delete cleanup "
            "session plus a fresh ingest session; name the files or directories to re-import, "
            "e.g. `citadel ingest --reingest raw/notes.md`).",
            file=sys.stderr,
        )
        return 2

    paths = args.paths or None
    force = args.force
    if args.retry:
        failed, uncited = ingest.retry_candidates()
        if not failed and not uncited:
            print("Nothing to retry: no failed sources and every ingested source is cited by at least one page.")
            return 0
        if failed:
            print(f"Retrying {len(failed)} failed source(s):")
            for key in failed:
                print(f"  - {config.display_key(key)}")
        if uncited:
            print(f"Force-reconciling {len(uncited)} ingested source(s) that no wiki page cites:")
            for key in uncited:
                print(f"  - {config.display_key(key)}")
        paths = [str(config.source_path_for_key(key)) for key in failed + uncited]
        force = True

    if args.verbose:
        config.LLM_VERBOSE = True
    # `is not None` (not truthiness) so an explicit `--log-dir ""` is honored as "disable logging"
    # — the documented override — rather than silently falling through to CITADEL_LLM_LOG_DIR.
    if args.log_dir is not None:
        config.LLM_LOG_DIR = args.log_dir
    guidance, error = _effective_guidance(args.guidance, config.INGEST_GUIDANCE, "CITADEL_INGEST_GUIDANCE")
    if error:
        print(error, file=sys.stderr)
        return 2
    if args.guidance is not None:
        config.INGEST_GUIDANCE = guidance

    progress = None
    if not args.quiet:
        from .progress import ConsoleProgress

        # Base spinner suppression on the RESOLVED verbose state (config), so a session enabled via
        # CITADEL_LLM_VERBOSE — not just the --verbose flag — also drops the spinner that would
        # otherwise clobber the streamed transcript.
        progress = ConsoleProgress(spinner=not config.LLM_VERBOSE)
    report = ingest.ingest(
        paths, progress=progress, full_rescan=args.full_rescan, force=force, jobs=args.jobs, reingest=args.reingest
    )
    print(report.render())
    # Non-zero on a per-source error OR a structural problem left behind (a broken
    # cross-link the agent introduced) — so ingest gates the wiki's integrity in CI.
    return 1 if (report.errors or report.broken_links) else 0


def cmd_curate(args: argparse.Namespace) -> int:
    """Run one curate pass and print the report. ``--dry-run`` recomputes
    the plan and runs zero sessions; ``--limit``/``--stale-rules``/the positional paths shape the
    plan; ``--diff`` writes a change report; ``--retry`` includes attempt-capped clusters (maps to
    ``curate(force=True)``); ``--guidance`` is this run's structural steer, landing in every
    cluster's findings checklist — and, together with paths, planning exactly those pages.
    Returns 1 when a cluster failed its gate (surfaced for CI), else 0."""
    from . import config, curate

    # Same convention as `ingest --guidance`: an explicit `--guidance ""` disables an env-set steer
    # for this run, and the cap is checked on the EFFECTIVE steer (flag or env alike).
    guidance, error = _effective_guidance(args.guidance, config.CURATE_GUIDANCE, "CITADEL_CURATE_GUIDANCE")
    if error:
        print(error, file=sys.stderr)
        return 2
    if args.guidance is not None:
        config.CURATE_GUIDANCE = guidance

    report = curate.curate(
        args.paths or None,
        dry_run=args.dry_run,
        limit=args.limit,
        stale_rules=args.stale_rules,
        diff=args.diff,
        force=args.retry,
    )
    print(report.render())
    return 1 if report.failed else 0


def cmd_refresh(args: argparse.Namespace) -> int:
    """Run one budget-controlled refresh pass: re-verify the ``--limit`` least-recently-checked
    sources (oldest ``ingested_at`` first) through forced reconcile sessions, so an aging wiki is
    brought up to the current model + rules a slice at a time instead of ever being regenerated.
    ``--dry-run`` prints the head of the queue and runs nothing. Mirrors ingest's exit contract:
    1 when a refreshed source errored or left a broken link, else 0 (a no-op plan is 0)."""
    from . import config, refresh

    if args.limit < 1:
        print(
            "error: --limit must be >= 1 (each refreshed source runs one agent session; "
            "the budget is always explicit).",
            file=sys.stderr,
        )
        return 2
    if args.jobs is not None and args.jobs < 1:
        print(f"error: --jobs must be at least 1 (got {args.jobs}); 1 means the serial default.", file=sys.stderr)
        return 2
    if args.verbose:
        config.LLM_VERBOSE = True
    if args.log_dir is not None:
        config.LLM_LOG_DIR = args.log_dir
    progress = None
    if not args.quiet and not args.dry_run:
        from .progress import ConsoleProgress

        progress = ConsoleProgress(spinner=not config.LLM_VERBOSE)
    report = refresh.refresh(
        limit=args.limit, min_age_days=args.min_age_days, dry_run=args.dry_run, progress=progress, jobs=args.jobs
    )
    print(report.render(), end="")
    ing = report.ingest_report
    return 1 if ing is not None and (ing.errors or ing.broken_links) else 0


def cmd_status(args: argparse.Namespace) -> int:
    """Print the per-source corpus state — ingested / failed /
    skipped-duplicate / ignored / pending, read from the manifest + failures catalog with one
    stat-only walk. Read-only. ``--json`` emits the same report machine-readably (the buckets +
    rules_version as one object) instead of the table; ``--exit-code`` returns 1 when any source
    is failed or pending — the opt-in CI gate for "corpus fully ingested" — while the default
    stays 0 so existing scripts never break."""
    import json as _json

    from . import status

    report = status.build_status()
    if args.json:
        print(_json.dumps(report.as_dict(), indent=2, sort_keys=True))
    else:
        print(report.render(), end="")
    if args.exit_code and (report.failed or report.pending):
        return 1
    return 0


def cmd_doctor(args: argparse.Namespace) -> int:
    """Print the environment/setup diagnostics — one OK/WARN/FAIL line
    per check (workspace resolution, rules tree, agent CLI on PATH, raw-root reachability, manifest
    parse + workspace-stamp match, failures summary, the API-key billing-shadow and PDF-vision
    advisories). Read-only and workspace-optional (``needs_workspace=False``) so it can diagnose a
    MISSING workspace; returns 1 only when some check FAILs, else 0."""
    from . import doctor

    report = doctor.run()
    print(report.render(), end="")
    return 0 if report.ok else 1


def cmd_read(args: argparse.Namespace) -> int:
    """Print one wiki page's full OKF text (the CLI twin of the wiki_read MCP tool). Returns 1 on a
    missing page or an unsafe path, mirroring the tool's error contract as a CLI exit code."""
    from . import okf, store

    try:
        text = store.read_page_text(args.rel_path)
    except FileNotFoundError:
        print(f"error: page not found: {args.rel_path}", file=sys.stderr)
        return 1
    except okf.OKFError as e:
        print(f"error: {e}", file=sys.stderr)  # the OKFError text already says "unsafe path: …"
        return 1
    print(text, end="" if text.endswith("\n") else "\n")
    return 0


def cmd_raw(args: argparse.Namespace) -> int:
    """Print the raw SOURCE behind a citation (the CLI twin of the wiki_raw MCP tool). Returns 1 when
    the key is not a cited source, missing on disk, or its locator does not resolve — mirroring the
    tool's error contract as a CLI exit code."""
    from . import rawsource

    try:
        text = rawsource.raw_text(args.source_key, args.locator or "")
    except rawsource.SourceError as e:
        print(f"error: {e}", file=sys.stderr)
        return 1
    print(text, end="" if text.endswith("\n") else "\n")
    return 0


def cmd_neighbors(args: argparse.Namespace) -> int:
    """Print a page's link neighborhood — links out, backlinks, cited sources (the CLI twin of
    wiki_neighbors). Returns 1 on a missing page or unsafe path, mirroring the tool's error contract."""
    from . import okf, store

    try:
        text = store.neighbors_text(args.rel_path)
    except FileNotFoundError:
        print(f"error: page not found: {args.rel_path}", file=sys.stderr)
        return 1
    except okf.OKFError as e:
        print(f"error: {e}", file=sys.stderr)  # the OKFError text already says "unsafe path: …"
        return 1
    print(text, end="" if text.endswith("\n") else "\n")
    return 0


def cmd_index(args: argparse.Namespace) -> int:
    """Print the generated wiki/index.md catalog (the CLI twin of wiki_index)."""
    from . import store

    try:
        text = store.index_text()
    except FileNotFoundError:
        print("No wiki index yet (run `citadel ingest` first).")
        return 0
    print(text, end="" if text.endswith("\n") else "\n")
    return 0


def cmd_sources(args: argparse.Namespace) -> int:
    """Print the generated wiki/sources/index.md provenance catalog (the CLI twin of wiki_sources)."""
    from . import store

    try:
        text = store.sources_text()
    except FileNotFoundError:
        print("No sources catalog yet (run `citadel ingest` first).")
        return 0
    print(text, end="" if text.endswith("\n") else "\n")
    return 0


def cmd_serve(args: argparse.Namespace) -> int:
    """Launch the MCP server — stdio by default, Streamable HTTP with ``--http`` (lazy imports so
    importing cli needs no mcp).

    The HTTP transport refuses to start without a usable ``CITADEL_HTTP_TOKEN``; that refusal is a
    usage error (exit 2, matching the argparse convention), never a traceback. The stdio-only flags
    are rejected the same way rather than silently ignored — a ``--port`` that did nothing would be
    a security surprise waiting to happen."""
    if not getattr(args, "http", False):
        stdio_only = [
            flag
            for flag, value in (
                ("--host", args.host),
                ("--port", args.port),
                ("--path", args.path),
                ("--read-only", args.read_only),
            )
            if value is not None
        ]
        if stdio_only:
            print(
                f"error: {', '.join(stdio_only)} apply to --http only (stdio has no network surface).", file=sys.stderr
            )
            return 2
        from .server import main as serve_main

        serve_main()
        return 0

    from . import httpserve

    try:
        httpserve.serve(host=args.host, port=args.port, path=args.path, read_only=args.read_only)
    except httpserve.HttpServeError as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    return 0


def cmd_capture(args: argparse.Namespace) -> int:
    """Append one note to the raw/ capture log (the CLI twin of wiki_capture) and print where it
    landed. ``-`` reads the text from stdin. Returns 2 on empty/oversized text — a usage error,
    matching the argparse convention — so a scripted capture that lost its payload fails loud."""
    from . import capture as capture_mod

    text = sys.stdin.read() if args.text == "-" else args.text
    try:
        result = capture_mod.capture(text, source=args.source, topic=args.topic)
    except ValueError as e:
        print(f"error: {e}", file=sys.stderr)
        return 2
    print(result.render(), end="")
    return 0


def cmd_search(args: argparse.Namespace) -> int:
    """Print ranked search hits for the query (optionally filtered to a tag); return 0.
    A ``--limit`` <= 0 is treated as unset and falls back to the default (8) — matching
    ``wiki_search``, so a miscomputed limit never reads as "no matches"."""
    from . import store

    pages = None
    if args.tag:
        want = args.tag.strip().lower()
        pages = [p for p in store.load() if want in [str(t).lower() for t in p.tags]]
        if not pages:
            print(f"No pages tagged {args.tag!r}.")
            return 0

    hits = store.search(args.query, pages=pages, limit=args.limit if args.limit > 0 else 8)
    if not hits:
        scope = f" (tag {args.tag!r})" if args.tag else ""
        print(f"No matches for {args.query!r}{scope}.")
        return 0
    for page, score in hits:
        snippet = " ".join(page.body.split())[:120]
        tags = (" [" + ", ".join(page.tags) + "]") if page.tags else ""
        print(f"{page.rel_path}\t{page.title}\t{score:.2f}{tags}")
        if snippet:
            print(f"    {snippet}")
    return 0


def cmd_define(args: argparse.Namespace) -> int:
    """Print a glossary definition for the term (the CLI twin of wiki_define): an Abbreviation
    glossary hit, an exact-title/alias page, or the closest search hits as a fallback; return 0."""
    from . import store

    text = store.define_text(args.term)
    print(text, end="" if text.endswith("\n") else "\n")
    return 0


def cmd_tags(args: argparse.Namespace) -> int:
    """List all tags and their pages (or just one tag's pages); return 0."""
    from . import store

    catalog = store.tag_catalog()
    if not catalog:
        print("No tags yet.")
        return 0

    if args.tag:
        want = args.tag.strip().lower()
        pages = catalog.get(want)
        if not pages:
            print(f"No pages tagged {args.tag!r}.")
            return 0
        print(f"# {want} ({len(pages)})")
        for page in pages:
            print(f"- {page.rel_path}\t{page.title}")
        return 0

    for tag in sorted(catalog):
        pages = catalog[tag]
        print(f"{tag} ({len(pages)})")
        for page in pages:
            print(f"    {page.rel_path}\t{page.title}")
    return 0


def cmd_lint(args: argparse.Namespace) -> int:
    """Print the lint report; return 0 if clean, else 3 — lint's OWN not-clean code, so a CI
    consumer can tell "structural wiki problems found" (3) from "invoked wrong / no workspace"
    (2, the argparse convention) and "unexpected error" (1) by exit code alone."""
    from . import lint

    report = lint.lint(stale_days=args.stale_days)
    print(report.render())
    return 0 if report.ok() else 3


def cmd_check(args: argparse.Namespace) -> int:
    """Validate the wiki (or specific pages) for links/format/required-fields; print the
    issues and return 1 if any error. This is the strict per-page gate the ingest agent runs
    on its own edits before finishing."""
    import os
    from pathlib import Path

    from . import config, okf, store, validate

    pages = store.load()
    issues = validate.validate_all(pages)
    missing: list[str] = []
    if args.paths:
        wiki_root = config.wiki_dir().resolve()
        wanted: set[str] = set()
        for arg in args.paths:
            rel = arg.replace(os.sep, "/")
            try:
                resolved = Path(arg).resolve()
                if resolved.is_relative_to(wiki_root):
                    rel = str(resolved.relative_to(wiki_root)).replace(os.sep, "/")
            except (OSError, ValueError):
                pass
            wanted.add(rel)
        # A named page that does not exist must be an error, not a silent "OK" — a page with
        # zero issues and a typo'd path would otherwise be indistinguishable (false green in CI).
        known = {p.rel_path for p in pages}
        for rel in sorted(wanted - known):
            try:
                on_disk = okf.safe_join(config.wiki_dir(), rel).is_file()
            except okf.OKFError:
                on_disk = False
            if on_disk:
                missing.append(f"error: not a validated page: {rel} (generated/reserved files are not checked)")
            else:
                missing.append(f"error: no such page: {rel}")
        issues = [i for i in issues if i.rel_path in wanted]
    for line in missing:
        print(line)
    print(validate.render_issues(issues))
    return 1 if missing or validate.has_errors(issues) else 0


def _invalid_rules_name(arg: str) -> RuntimeError:
    """The user-facing error for a rules name the path guard rejected (``okf.safe_join`` via
    ``config.rules_join`` at the join points — absolute paths, drive letters, any ``..`` step)."""
    return RuntimeError(f"invalid rules file name: {arg!r} (expected e.g. core.md or genres/email.md)")


def _rules_layer(path, workspace_rules) -> str:
    """Which layer an effective rules path resolved from: ``workspace`` or ``packaged``."""
    from pathlib import Path

    if workspace_rules is not None and Path(path).resolve().is_relative_to(Path(workspace_rules).resolve()):
        return "workspace"
    return "packaged"


def cmd_rules_list(args: argparse.Namespace) -> int:
    """One line per EFFECTIVE rules file — tree-relative name, origin layer (packaged|workspace),
    and its first (description) line. Works without a workspace: it then lists the packaged
    defaults only."""
    from . import config

    ws = config.workspace_rules_dir()
    names = config.rules_relnames()
    if not names:
        print("No rules files found.")
        return 0
    for rel in names:
        path = config.effective_rules_file(rel)
        try:
            first = next((line.lstrip("#").strip() for line in path.read_text(encoding="utf-8").splitlines()), "")
        except OSError:
            first = ""
        print(f"{rel}\t{_rules_layer(path, ws)}\t{first}")
    return 0


def cmd_rules_show(args: argparse.Namespace) -> int:
    """Print ONE effective rules file's content (workspace override when present, else the
    packaged default). Works without a workspace (packaged defaults)."""
    from . import config, okf

    rel = config.rules_relname(args.relname)
    try:
        path = config.effective_rules_file(rel)
    except okf.OKFError:
        raise _invalid_rules_name(args.relname) from None
    try:
        text = path.read_text(encoding="utf-8")
    except OSError:
        raise RuntimeError(f"no rules file named {rel!r} (see `citadel rules list`)") from None
    print(text, end="" if text.endswith("\n") else "\n")
    return 0


def cmd_rules_eject(args: argparse.Namespace) -> int:
    """Copy a PACKAGED rules file into the workspace ``rules/`` so it can be edited — the copy
    then shadows the packaged file (first-hit-wins) and is owned by the user (it no longer
    updates with pip). Never overwrites an existing workspace file."""
    from . import config, okf

    rel = config.rules_relname(args.relname)
    ws = config.workspace_rules_dir()
    if ws is None:
        raise RuntimeError(
            "`citadel rules eject` needs a workspace (the copy lands in <workspace>/rules/) — "
            "run `citadel init [DIR]` or set CITADEL_WORKSPACE first."
        )
    try:
        src = config.rules_join(config.PACKAGED_RULES_DIR, rel)
        dest = config.rules_join(ws, rel)
    except okf.OKFError:
        raise _invalid_rules_name(args.relname) from None
    if not src.is_file():
        raise RuntimeError(f"no packaged rules file named {rel!r} (see `citadel rules list`)")
    if dest.exists():
        raise RuntimeError(f"refusing to overwrite {dest} - it is already ejected; edit or remove it")
    config.robust_mkdir(dest.parent)
    dest.write_bytes(src.read_bytes())
    print(f"ejected {rel} -> {dest}")
    print("This copy now shadows the packaged file and is yours to edit; it no longer updates with pip.")
    return 0


def cmd_view(args: argparse.Namespace) -> int:
    """Generate the offline single-file HTML viewer and open it (unless --no-open)."""
    from . import viewer

    return viewer.view(out=args.out, open_browser=args.open_browser, obsidian=args.obsidian)


def main(argv: list[str] | None = None) -> int:
    """Parse arguments and dispatch to the chosen subcommand.

    Every subcommand that operates on a workspace declares ``needs_workspace=True`` (the parser
    default), so when discovery found none (``config.WORKSPACE_FOUND`` is False and
    ``WORKSPACE_ROOT`` is only the bare-CWD fallback) main aborts with an actionable error
    instead of silently reading/writing a wiki in whatever directory the process happens to run
    from (the pip-installed phantom-workspace bug class). ``--help`` and ``--version`` never
    reach the guard (argparse exits first), and ``init`` opts out — it CREATES the workspace.

    On a RuntimeError raised by a subcommand, print the message to stderr and
    return 1. (Ingest collects per-source LLM-CLI failures into its report
    instead of raising, so those exit 1 via report.errors.)
    """
    parser = build_parser()
    args = parser.parse_args(argv)

    from . import config

    if getattr(args, "needs_workspace", True) and not config.WORKSPACE_FOUND:
        print(
            "error: no citadel workspace found — this command needs one.\n"
            f"  No {config.WORKSPACE_MARKER} marker exists in the current directory or any parent, and no\n"
            "  CITADEL_WIKI_DIR + CITADEL_RAW_DIR pair is set.\n"
            "  Fix: run `citadel init [DIR]` to create a workspace, `cd` into an existing one, or set\n"
            "  CITADEL_WORKSPACE=/path/to/workspace (e.g. in your MCP host config for `citadel serve`).",
            file=sys.stderr,
        )
        return 2

    try:
        return args.func(args)
    except RuntimeError as e:
        print(f"error: {e}", file=sys.stderr)
        return 1


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