"""``mgf-secrets`` CLI — P1 verbs (list/show/doctor/probe) + P2 value verbs.

P2 adds the value-handling surface (SPEC §4): ``show --value`` (bare value, TTY-
refused), ``add``/``set`` (value via file/stdin/hidden prompt — NEVER argv), ``exec``
(env-injected child), ``materialize`` (decrypt the store into the bare-value cache),
``export-offline`` (founder bundle), and ``history`` (git audit trail).

**Interface rules, tested not aspirational (SPEC §4):** no verb prints a value except
``show --value`` and ``exec`` injection; value input is never accepted on argv; every
non-value verb has a ``--json`` twin; exit codes are 0 clean · 1 findings · 2 usage/
credential/load error (``exec`` passes the child's code through). The redaction
property test drives this whole surface with planted canaries (SPEC §6.2).
"""

from __future__ import annotations

import argparse
import getpass
import io
import json
import subprocess
import sys
import tarfile
import time
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any

from . import __version__
from . import materialize as mtz
from . import retire as retire_mod
from . import store as store_mod
from . import sync as sync_mod
from . import watch as watch_mod
from .acknowledgements import (
    AckError,
    Acknowledgements,
    default_ack_paths,
    load_acknowledgements,
)
from .context import Context, build_context
from .doctor import (
    ALL_SURFACES,
    C9_WINDOW_HOURS,
    CONFIG_SOURCE_FORGE_RAW,
    CONFIG_SOURCE_LOCAL,
    CONFIG_SOURCES,
    DEFAULT_CONFIG_WORKERS,
    DEFAULT_SURFACES,
    Doctor,
)
from .errors import EXIT_ERROR, EXIT_FINDINGS, EXIT_OK, CredentialError, SecretsError, UsageError
from .probes import run_probe
from .registry import Registry, RegistryError, default_paths, load

if TYPE_CHECKING:
    from collections.abc import Callable, Sequence

    from .model import Entry

#: Verbs that operate purely on the store and need no registry checkout — so `exec`
#: is a valid `$(cat my_stuff/…)` replacement even outside the federation root.
_STORE_ONLY_VERBS = frozenset({"exec", "history", "export-offline"})
#: Verbs that need no registry loaded (store-only, plus ``ack`` which reads/writes only
#: the acknowledgements file).
_NO_REGISTRY_VERBS = _STORE_ONLY_VERBS | {"ack"}


def entry_to_json(e: Entry) -> dict[str, Any]:
    """Metadata-only serialization of an entry (never a value)."""
    return {
        "id": e.id,
        "family": e.family,
        "kind": e.kind,
        "state": e.state,
        "purpose": e.purpose,
        "surfaces": [{"type": s.type, **s.data} for s in e.surfaces],
        "consumers": list(e.consumers),
        "policy": {"allowed_repos": e.policy.allowed_repos, "blast_radius": e.policy.blast_radius},
        "rotation": dict(e.rotation),
        "verify": dict(e.verify),
        "recovery": e.recovery,
    }


def _require_registry(reg: Registry | None) -> Registry:
    if reg is None:  # pragma: no cover - guarded by main()'s verb routing
        raise UsageError("this verb needs the registry but none was loaded")
    return reg


# --------------------------------------------------------------------------- #
# P1 verbs.
# --------------------------------------------------------------------------- #


def _cmd_list(args: argparse.Namespace, reg: Registry | None, _ctx: Context | None) -> int:
    reg = _require_registry(reg)
    rows = reg.select(family=args.family, kind=args.kind, state=args.state)
    if args.json:
        print(json.dumps([entry_to_json(e) for e in rows], indent=2, sort_keys=True))
        return EXIT_OK
    if not rows:
        print("(no matching registry entries)")
        return EXIT_OK
    width = max(len(e.id) for e in rows)
    print(f"{'ID'.ljust(width)}  FAMILY  STATE      KIND               SURFACES  LAST-ROTATED")
    for e in rows:
        last = str(e.rotation.get("last", "-"))
        print(
            f"{e.id.ljust(width)}  {e.family:<6}  {e.state:<9}  {e.kind:<17}  "
            f"{len(e.surfaces):<8}  {last}"
        )
    print(f"\n{len(rows)} entr{'y' if len(rows) == 1 else 'ies'}.")
    return EXIT_OK


def _cmd_show(args: argparse.Namespace, reg: Registry | None, _ctx: Context | None) -> int:
    if args.value:
        return _show_value(args)
    reg = _require_registry(reg)
    entry = reg.get(args.id)
    if entry is None:
        raise UsageError(f"unknown id {args.id!r} — run `mgf-secrets list` to see ids")
    if args.json:
        print(json.dumps(entry_to_json(entry), indent=2, sort_keys=True))
        return EXIT_OK
    print(f"id:        {entry.id}")
    print(f"family:    {entry.family}")
    print(f"kind:      {entry.kind}")
    print(f"state:     {entry.state}")
    print(f"purpose:   {entry.purpose}")
    print(
        f"policy:    allowed_repos={entry.policy.allowed_repos!r} "
        f"blast_radius={entry.policy.blast_radius!r}"
    )
    print(f"rotation:  {dict(entry.rotation)}")
    print(f"verify:    {dict(entry.verify)}")
    print(f"recovery:  {entry.recovery}")
    print(f"consumers: {list(entry.consumers)}")
    print("surfaces:")
    for s in entry.surfaces:
        print(f"  - {s.type}: {s.data}")
    return EXIT_OK


def _show_value(args: argparse.Namespace) -> int:
    """``show <id> --value`` — the ONE metadata verb that may surface a value.

    Refuses a TTY (values off screens/transcripts) unless ``--tty-ok``; never with
    ``--json``; single value only. Writes raw bytes to stdout for ``$(…)``/pipes.
    """
    if args.json:
        raise UsageError("`show --value` emits a bare value, not JSON — drop --json")
    stdout = sys.stdout
    is_tty = bool(getattr(stdout, "isatty", lambda: False)())
    if is_tty and not args.tty_ok:
        raise UsageError(
            "refusing to print a secret value to a TTY (it would land on your screen "
            "and in the transcript) — pipe it, or pass --tty-ok to override"
        )
    root = store_mod.store_root(args.store, fed_root=args.root)
    value = store_mod.store_get(args.id, root=root, identity_path=args.identity)
    buf = getattr(stdout, "buffer", None)
    if buf is not None:
        buf.write(value)
        buf.flush()
    else:  # a text stream (tests capture via StringIO) — decode losslessly.
        stdout.write(value.decode("utf-8", "surrogateescape"))
        stdout.flush()
    return EXIT_OK


def _check_config_source(config_source: str, surfaces: set[str]) -> None:
    """``forge-raw`` is the source for the consumed-check (C2), which runs under the
    woodpecker surface — so it requires that surface. (The forge CLIENT it needs is
    built separately; see :func:`_client_surfaces`.)"""
    if config_source == CONFIG_SOURCE_FORGE_RAW and "woodpecker" not in surfaces:
        raise UsageError(
            "--config-source=forge-raw feeds the consumed-check (a woodpecker-surface "
            "check), so it requires the woodpecker surface (add `--surface woodpecker`)"
        )


def _client_surfaces(surfaces: set[str], config_source: str) -> set[str]:
    """The surfaces whose CLIENTS must be built: the requested ones, plus ``forge`` when
    ``forge-raw`` needs the forge contents API for the no-checkout consumed-check (the
    forge CHECKS still only run when ``forge`` is a requested surface)."""
    out = set(surfaces)
    if config_source == CONFIG_SOURCE_FORGE_RAW:
        out.add("forge")
    return out


def _load_acks(root: str | None) -> Acknowledgements:
    """The committed acknowledgements (absent files → empty; malformed → AckError→exit 2)."""
    return load_acknowledgements(root=root)


def _cmd_doctor(args: argparse.Namespace, reg: Registry | None, ctx: Context | None) -> int:
    reg = _require_registry(reg)
    surfaces = set(args.surface) if args.surface else set(DEFAULT_SURFACES)
    config_source = getattr(args, "config_source", CONFIG_SOURCE_LOCAL)
    _check_config_source(config_source, surfaces)
    if ctx is None:
        ctx = build_context(
            _client_surfaces(surfaces, config_source), root=args.root, best_effort=False
        )
    doctor = Doctor(
        reg,
        fed_root=ctx.fed_root,
        woodpecker=ctx.woodpecker,
        forge=ctx.forge,
        hosts=ctx.hosts,
        config_source=config_source,
        error_window_hours=args.error_window_hours,
        acknowledgements=_load_acks(args.root),
    )
    report = doctor.run(sorted(surfaces))
    if args.json:
        print(json.dumps(report.to_json(), indent=2, sort_keys=True))
        return report.exit_code()
    findings = report.sorted_findings()
    unverifiable = report.sorted_unverifiable()
    n_ack = len(report.acknowledged_findings())
    print(f"doctor — surfaces: {', '.join(report.surfaces)}")
    if not findings and not unverifiable:
        print("clean — declared == deployed == consumed across the checked surfaces.")
    elif findings:
        current = None
        for f in findings:
            if f.severity != current:
                current = f.severity
                print(f"\n[{f.severity.upper()}]")
            flag = "  [ack]" if f.acknowledged else ""
            print(f"  {f.check} {f.id}: {f.finding}{flag}")
        n_page = len(report.pageable_findings())
        suffix = f" ({n_ack} acknowledged, {n_page} pageable)" if n_ack else ""
        print(f"\n{len(findings)} finding(s){suffix}.")
    if unverifiable:
        print("\n[UNVERIFIABLE] (could not check — distinct from clean; exit 1)")
        for u in unverifiable:
            print(f"  {u.check} {u.target}: {u.reason}")
        print(f"\n{len(unverifiable)} unverifiable.")
    if report.skipped:
        print("\nskipped:")
        for s in report.skipped:
            print(f"  - {s}")
    return report.exit_code()


def _cmd_probe(args: argparse.Namespace, reg: Registry | None, ctx: Context | None) -> int:
    reg = _require_registry(reg)
    entry = reg.get(args.id)
    if entry is None:
        raise UsageError(f"unknown id {args.id!r}")
    if ctx is None:
        ctx = build_context(set(ALL_SURFACES), root=args.root, best_effort=True)
    result = run_probe(entry, ctx)
    if args.json:
        print(json.dumps(result.to_json(), indent=2, sort_keys=True))
    else:
        print(f"{result.status.upper():<8} {result.id} ({result.kind}): {result.message}")
        if result.evidence:
            print(f"  evidence: {result.evidence}")
    return result.exit_code()


# --------------------------------------------------------------------------- #
# The paging-doctor cron verb (SPEC-P4 §1) — the systems entrypoint the agent2
# /opt/watcher wrapper invokes. Routing lives in mgf.secrets.watch (unit-tested).
# --------------------------------------------------------------------------- #

#: The cron's surfaces: woodpecker (unmetered) + forge (budgeted, §7b). local/hosts
#: are opt-in — agent2 carries no ``my_stuff`` checkouts and the hosts SSH surface is
#: heavier; add them with ``--surface`` where the creds exist.
WATCH_DEFAULT_SURFACES = ("woodpecker", "forge")
_DEFAULT_PAGER_TOKEN_FILE = "/opt/watcher/.codeberg-token"  # noqa: S105 - path, not a secret
_DEFAULT_HEARTBEAT_URL_FILE = "/etc/mgf/backup-heartbeat.url"
_DEFAULT_HEARTBEAT_TOKEN_FILE = "/etc/mgf/backup-heartbeat.token"  # noqa: S105 - path, not a secret


def _read_secret_file(path: str | None) -> str | None:
    """Read a token/url file's stripped content, or ``None`` if absent. The value goes
    straight into a client header — never returned to a transcript by the caller."""
    if not path:
        return None
    p = Path(path)
    if not p.is_file():
        return None
    return p.read_text(encoding="utf-8").strip()


def _build_pager(args: argparse.Namespace) -> watch_mod.Pager | None:
    token = _read_secret_file(args.pager_token_file)
    if token is None:
        return None
    return watch_mod.CodebergIssuePager(token=token, repo=args.issues_repo)


def _build_heartbeat(args: argparse.Namespace) -> watch_mod.Heartbeat | None:
    url = _read_secret_file(args.heartbeat_url_file)
    token = _read_secret_file(args.heartbeat_token_file)
    if url is None or token is None:
        return None
    return watch_mod.OOHeartbeat(url=url, token=token)


def _cmd_watch(args: argparse.Namespace, reg: Registry | None, ctx: Context | None) -> int:
    reg = _require_registry(reg)
    surfaces = set(args.surface) if args.surface else set(WATCH_DEFAULT_SURFACES)
    config_source = args.config_source
    _check_config_source(config_source, surfaces)
    if ctx is None:
        ctx = build_context(
            _client_surfaces(surfaces, config_source),
            root=args.root,
            best_effort=False,
            forge_cache_dir=Path(args.forge_cache_dir) if args.forge_cache_dir else None,
            forge_cache_ttl=args.forge_cache_ttl,
            forge_budget=args.forge_budget,
        )
    doctor = Doctor(
        reg,
        fed_root=ctx.fed_root,
        woodpecker=ctx.woodpecker,
        forge=ctx.forge,
        hosts=ctx.hosts,
        config_source=config_source,
        error_window_hours=args.error_window_hours,
        config_workers=args.config_workers,
        acknowledgements=_load_acks(args.root),
    )
    report = doctor.run(sorted(surfaces))
    pager = _build_pager(args) if args.page else None
    heartbeat = _build_heartbeat(args)
    outcome = watch_mod.run_watch(
        report,
        pager=pager,
        heartbeat=heartbeat,
        page=args.page,
        heartbeat_on=args.heartbeat_on,
    )
    if args.json:
        out = outcome.to_json()
        out["exit_code"] = report.exit_code()
        out["pager_configured"] = pager is not None
        out["heartbeat_configured"] = heartbeat is not None
        print(json.dumps(out, indent=2, sort_keys=True))
    else:
        print(f"watch — surfaces: {', '.join(report.surfaces)} (config-source: {config_source})")
        if outcome.clean and outcome.acknowledged:
            status = f"clean-for-paging ({outcome.acknowledged} acknowledged)"
        elif outcome.clean:
            status = "clean"
        else:
            n_page = len(report.pageable_findings())
            status = f"PAGEABLE ({n_page} finding(s), {len(report.unverifiable)} unverifiable)"
            if outcome.acknowledged:
                status += f", {outcome.acknowledged} acknowledged"
        print(f"  doctor: {status}")
        print(
            f"  pager:  {outcome.pager_action}"
            + ("" if pager else " (no token file — not configured)")
        )
        print(
            f"  heartbeat: {'sent' if outcome.heartbeat_sent else 'not sent'}"
            + ("" if heartbeat else " (no url/token file — not configured)")
        )
        if not outcome.clean and not args.page:
            print("  --- value-free summary (PAGE=0; would have filed/updated) ---")
            print(outcome.body)
    # Exit reflects the doctor's finding state (0 clean · 1 findings/unverifiable); the
    # cron ignores it — the page + heartbeat ARE the signal — but CI/humans can read it.
    return report.exit_code()


# --------------------------------------------------------------------------- #
# ack — manage the committed acknowledgements (defer known findings from paging).
# --------------------------------------------------------------------------- #


def _toml_basic_string(s: str) -> str:
    return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'


def _cmd_ack(args: argparse.Namespace, _reg: Registry | None, _ctx: Context | None) -> int:
    """`ack --list` shows the committed acknowledgements (active/expired); `ack CHECK ID
    --reason ...` appends a new one to mgf-standard/acknowledgements.toml (values-free,
    git-audited). Removal (`unack`) is a deliberate hand-edit."""
    if args.check and args.id:
        return _ack_add(args)
    acks = _load_acks(args.root)
    now = time.time()
    if args.json:
        rows = [{**a.to_json(), "active": a.is_active(now)} for a in acks.acks]
        print(json.dumps(rows, indent=2, sort_keys=True))
        return EXIT_OK
    if not acks.acks:
        print("(no acknowledgements — nothing deferred)")
        return EXIT_OK
    print(f"acknowledgements ({len(acks.acks)}):")
    for a in acks.acks:
        state = "active" if a.is_active(now) else "EXPIRED"
        target = f"{a.check} {a.id}" + (f" @{a.repo}" if a.repo else "")
        exp = f" (expires {a.expires})" if a.expires else " (indefinite)"
        print(f"  [{state}] {target}{exp} — {a.reason}")
    return EXIT_OK


def _ack_add(args: argparse.Namespace) -> int:
    if not args.reason:
        raise UsageError("adding an ack requires --reason (the human justification)")
    if args.expires is not None:
        try:
            datetime.fromisoformat(args.expires)
        except ValueError:
            raise UsageError(f"--expires {args.expires!r} is not an ISO date/datetime") from None
    target = default_ack_paths(args.root)[0]  # mgf-standard/acknowledgements.toml
    lines = ["", "[[acknowledgement]]", f"check = {_toml_basic_string(args.check)}"]
    lines.append(f"id = {_toml_basic_string(args.id)}")
    if args.repo:
        lines.append(f"repo = {_toml_basic_string(args.repo)}")
    lines.append(f"reason = {_toml_basic_string(args.reason)}")
    if args.expires:
        lines.append(f"expires = {_toml_basic_string(args.expires)}")
    block = "\n".join(lines) + "\n"
    header = (
        "# acknowledgements.toml — deferred / known-accepted doctor findings.\n"
        "# Values-free + git-audited. Each ack: check + id (+ optional repo), a required\n"
        "# reason, and an optional ISO `expires` (past = inactive → the finding pages\n"
        "# again). The doctor STILL reports acked findings (flagged); the cron does not\n"
        "# page on them. See mgf-secrets `ack --list`.\n"
    )
    existed = target.is_file()
    with target.open("a", encoding="utf-8") as fh:
        if not existed:
            fh.write(header)
        fh.write(block)
    # Re-validate the whole file so a bad append fails loudly (never a silent bad ack).
    load_acknowledgements([target])
    print(f"acknowledged {args.check} {args.id} → {target}")
    return EXIT_OK


# --------------------------------------------------------------------------- #
# P2 value verbs.
# --------------------------------------------------------------------------- #


def _read_value_input(args: argparse.Namespace, entry_id: str) -> bytes:
    """Value from ``--value-file PATH`` / ``--value-file -`` (stdin) / hidden prompt.

    NEVER from argv (``--value X`` is trapped by the parser). AG-18: the value is
    returned as bytes and never logged.
    """
    if getattr(args, "value", None) is not None:  # the argv trap
        raise UsageError(
            "a secret value must never be passed on argv (it lands in ps/shell history) — "
            "use --value-file PATH, --value-file - (stdin), or the hidden prompt"
        )
    src = args.value_file
    if src == "-":
        data = (
            sys.stdin.buffer.read() if hasattr(sys.stdin, "buffer") else sys.stdin.read().encode()
        )
        return data[:-1] if data.endswith(b"\n") else data
    if src is not None:
        path = Path(src)
        if not path.is_file():
            raise UsageError(f"--value-file {src!r} not found")
        return path.read_bytes()
    # Hidden interactive prompt (no echo). getpass strips the trailing newline.
    entered = getpass.getpass(f"value for {entry_id} (hidden): ")
    return entered.encode("utf-8")


def _cmd_add_set(args: argparse.Namespace, reg: Registry | None, _ctx: Context | None) -> int:
    reg = _require_registry(reg)
    entry = reg.get(args.id)
    if entry is None:
        raise UsageError(
            f"unknown id {args.id!r} — declare it in SECRETS.toml first, then `add`. "
            "Scaffold: [<id>] with family/kind/purpose/surfaces([{type='store'},…])/"
            "policy/rotation/verify/recovery/state (see the kind template in an existing entry)"
        )
    if entry.state == "held":
        raise UsageError(
            f"{args.id!r} is state=held — the founder's hold forbids storing a value for it; "
            "un-hold deliberately in the registry first (never by arming)"
        )
    root = store_mod.store_root(args.store, fed_root=args.root)
    exists = store_mod.cipher_path(root, args.id).is_file()
    verb = args.verb
    if verb == "add" and exists:
        raise UsageError(f"{args.id!r} already has a stored value — use `set` to update/rotate it")
    if verb == "set" and not exists:
        raise UsageError(f"{args.id!r} has no stored value yet — use `add` to create it")

    value = _read_value_input(args, args.id)
    recipients = store_mod.load_recipients(root)
    stamped = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
    row = store_mod.store_put(
        args.id,
        value,
        root=root,
        encrypted_at=stamped,
        recipients=recipients,
        family=entry.family,
        kind=entry.kind,
    )
    # Report metadata only — never the value.
    summary = {
        "id": args.id,
        "action": "stored" if verb == "add" else "updated",
        "file": row["file"],
        "sha256": row["sha256"],
        "recipients": len(recipients),
        "encrypted_at": row["encrypted_at"],
    }
    if args.json:
        print(json.dumps(summary, indent=2, sort_keys=True))
    else:
        print(
            f"{summary['action']} {args.id} → {row['file']} "
            f"(encrypted to {len(recipients)} recipients; sha256 {row['sha256'][:12]}…)"
        )
    return EXIT_OK


def _parse_with_spec(spec: str) -> tuple[str, str]:
    """``id`` or ``id:ENV_VAR`` → (id, env_var). Default env var = ``ID`` (uppercased)."""
    if ":" in spec:
        entry_id, env = spec.split(":", 1)
        if not entry_id or not env:
            raise UsageError(f"--with {spec!r} malformed — want id or id:ENV_VAR")
        return entry_id, env
    return spec, spec.upper().replace("-", "_")


def _cmd_exec(args: argparse.Namespace, _reg: Registry | None, _ctx: Context | None) -> int:
    """Run a child with store values injected as env vars — nothing on disk/argv/log."""
    cmd = list(args.cmd)
    if cmd and cmd[0] == "--":
        cmd = cmd[1:]
    if not cmd:
        raise UsageError("exec needs a command after `--`: mgf-secrets exec --with ID -- CMD …")
    if not args.with_:
        raise UsageError("exec needs at least one --with ID[:ENV_VAR]")
    root = store_mod.store_root(args.store, fed_root=args.root)
    import os

    child_env = dict(os.environ)
    for spec in args.with_:
        entry_id, env_var = _parse_with_spec(spec)
        value = store_mod.store_get(entry_id, root=root, identity_path=args.identity)
        # Inject as a str env var (values only in the child's environment).
        child_env[env_var] = value.decode("utf-8", "surrogateescape")
    try:
        completed = subprocess.run(cmd, env=child_env, check=False)  # noqa: S603 - declared argv, no shell
    except FileNotFoundError:
        raise UsageError(f"exec: command not found: {cmd[0]!r}") from None
    return completed.returncode


def _cmd_materialize(args: argparse.Namespace, reg: Registry | None, _ctx: Context | None) -> int:
    reg = _require_registry(reg)
    from .registry import fed_root as _fed_root

    fed = _fed_root(args.root)
    root = store_mod.store_root(args.store, fed_root=args.root)
    items = mtz.plan(reg, fed_root=fed, root=root, only=args.only)
    if args.apply:
        result = mtz.apply(items, fed_root=fed, root=root, identity_path=args.identity)
        if args.json:
            print(json.dumps(result.to_json(), indent=2, sort_keys=True))
        else:
            print(f"materialize --apply: wrote {len(result.written)} bare-value file(s)")
            for wid in sorted(result.written):
                print(f"  wrote  {wid}")
            for hid in sorted(result.refused_held):
                print(f"  held   {hid} (refused — never materialized)")
            for sid in sorted(result.skipped_no_store):
                print(f"  skip   {sid} (no stored value yet — `add` it first)")
            if result.leftovers:
                print("  leftovers (NOT deleted — prune deliberately):")
                for p in sorted(result.leftovers):
                    print(f"    {p}")
        return EXIT_OK
    # --plan (default): key-free, zero values, zero writes.
    if args.json:
        print(json.dumps([pi.to_json() for pi in items], indent=2, sort_keys=True))
        return EXIT_OK
    pending = [pi for pi in items if pi.pending]
    print(f"materialize --plan: {len(items)} declared local-file target(s), {len(pending)} pending")
    for pi in items:
        print(f"  {pi.status:<8} {pi.id}  →  {pi.target}  ({pi.declared_mode})")
    if not any(pi.store_present for pi in items):
        print("\n(store is empty for these ids — `add` values, then `materialize --apply`)")
    return EXIT_OK


def _cmd_export_offline(
    args: argparse.Namespace, _reg: Registry | None, _ctx: Context | None
) -> int:
    """Bundle the whole store into ONE age file encrypted to the founder key only."""
    out = args.out
    if out in ("-", "/dev/stdout"):
        raise UsageError("export-offline refuses to print the bundle — pass a real --out PATH")
    root = store_mod.store_root(args.store, fed_root=args.root)
    founder = store_mod.founder_recipient(root)
    # Tar the store's committed artifacts in-memory, then age-encrypt to the founder.
    blob = io.BytesIO()
    members = 0
    with tarfile.open(fileobj=blob, mode="w") as tar:
        for name in ("manifest.json", "recipients.txt"):
            p = Path(root) / name
            if p.is_file():
                tar.add(p, arcname=name)
                members += 1
        sdir = store_mod.secrets_dir(root)
        if sdir.is_dir():
            for age_file in sorted(sdir.glob("*.age")):
                tar.add(age_file, arcname=f"secrets/{age_file.name}")
                members += 1
    bundle = store_mod.encrypt(blob.getvalue(), [founder])
    outp = Path(out)
    import os

    fd = os.open(outp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
    try:
        os.write(fd, bundle)
    finally:
        os.close(fd)
    summary = {
        "out": str(outp),
        "bytes": len(bundle),
        "members": members,
        "recipient": "founder",
    }
    if args.json:
        print(json.dumps(summary, indent=2, sort_keys=True))
    else:
        print(
            f"export-offline: wrote {len(bundle)} bytes ({members} member(s)) to {outp}, "
            "encrypted to the founder recipient only"
        )
    return EXIT_OK


def _cmd_history(args: argparse.Namespace, _reg: Registry | None, _ctx: Context | None) -> int:
    """Rotation/audit trail for ``<id>.age`` from the store's git log — metadata only."""
    root = store_mod.store_root(args.store, fed_root=args.root)
    rel = f"secrets/{args.id}.age"
    try:
        proc = subprocess.run(  # noqa: S603 - fixed argv, no shell
            ["git", "-C", str(root), "log", "--format=%H%x09%aI%x09%s", "--", rel],  # noqa: S607
            capture_output=True,
            text=True,
            check=False,
        )
    except FileNotFoundError:
        raise CredentialError("git not found — history needs git to read the store log") from None
    if proc.returncode != 0:
        raise CredentialError(
            f"cannot read git history for {rel} in {root} "
            f"(is it a git checkout?): {proc.stderr.strip()}"
        )
    rows = []
    for line in proc.stdout.splitlines():
        parts = line.split("\t", 2)
        if len(parts) == 3:
            rows.append({"commit": parts[0], "date": parts[1], "subject": parts[2]})
    if args.json:
        print(json.dumps({"id": args.id, "file": rel, "history": rows}, indent=2, sort_keys=True))
        return EXIT_OK
    if not rows:
        print(f"no history for {rel} (never committed to the store)")
        return EXIT_OK
    print(f"history — {rel} ({len(rows)} revision(s), newest first):")
    for r in rows:
        print(f"  {r['commit'][:12]}  {r['date']}  {r['subject']}")
    return EXIT_OK


# --------------------------------------------------------------------------- #
# P3 verbs — sync (the reconciler) + retire (state flip).
# --------------------------------------------------------------------------- #


def _default_canary(root: str | None) -> Callable[[], sync_mod.CanaryResult]:
    """The playbook's sooth canary: shell out to ``ping_sooth_ci.py`` if present
    (triggers + polls the sooth pipelines). A real surface-1 apply must run it."""
    from .registry import fed_root as _fed_root

    script = _fed_root(root) / "mgf-standard" / "scripts" / "ping_sooth_ci.py"

    def run() -> sync_mod.CanaryResult:
        if not script.is_file():
            return sync_mod.CanaryResult(False, f"canary script not found at {script}")
        proc = subprocess.run(  # noqa: S603 - fixed argv (sys.executable), no shell
            [sys.executable, str(script)],
            capture_output=True,
            text=True,
            check=False,
        )
        tail = (proc.stdout or proc.stderr).strip().splitlines()[-1:] or [""]
        return sync_mod.CanaryResult(proc.returncode == 0, tail[0])

    return run


def _sync_surfaces(args: argparse.Namespace) -> set[str]:
    return set(args.surface) if args.surface else set(sync_mod.DEFAULT_SYNC_SURFACES)


def _cmd_sync(args: argparse.Namespace, reg: Registry | None, ctx: Context | None) -> int:
    reg = _require_registry(reg)
    surfaces = _sync_surfaces(args)
    if ctx is None:
        ctx = build_context(surfaces, root=args.root, best_effort=False)
    store_root = store_mod.store_root(args.store, fed_root=args.root)
    canary = None if args.skip_canary else _default_canary(args.root)
    rec = sync_mod.Reconciler(
        reg,
        fed_root=ctx.fed_root,
        store_root=store_root,
        identity_path=args.identity,
        woodpecker=ctx.woodpecker,
        forge=ctx.forge,
        hosts=ctx.hosts,
        canary=canary,
    )
    if args.apply:
        report = rec.apply(
            sorted(surfaces), allow_retire=args.allow_retire, skip_canary=args.skip_canary
        )
        if args.json:
            print(json.dumps(report.to_json(), indent=2, sort_keys=True))
        else:
            _print_apply(report)
        if report.failed:
            return EXIT_ERROR
        if report.playbook and report.playbook.canary_ok is False:
            return EXIT_FINDINGS
        return EXIT_OK
    plan = rec.plan(sorted(surfaces))
    if args.json:
        print(json.dumps(plan.to_json(), indent=2, sort_keys=True))
        return EXIT_OK
    _print_plan(plan)
    return EXIT_OK


def _print_plan(plan: sync_mod.SyncPlan) -> None:
    print(f"sync --plan — surfaces: {', '.join(plan.surfaces)}")
    actions = plan.sorted_actions()
    if not actions:
        print("empty — declared == deployed across the checked surfaces (nothing to do).")
    else:
        print(f"\n{len(actions)} action(s) (value-free):")
        for a in actions:
            print(f"  {a.line()}")
            if a.detail:
                print(f"      {a.detail}")
    if plan.notes:
        print("\nnotes:")
        for n in plan.notes:
            print(f"  [{n.kind}] {n.id}: {n.message}")


def _print_apply(report: sync_mod.ApplyReport) -> None:
    print(f"sync --apply — surfaces: {', '.join(report.surfaces)}")
    print(f"applied {len(report.applied)} action(s):")
    for a in report.applied:
        print(f"  {a.line()}")
    for f in report.failed:
        print(f"  FAILED {f['op']} {f['target']}: {f['error']}")
    if report.playbook:
        pb = report.playbook
        print("\ncontract-surface playbook (org secret touched):")
        print(f"  scope audit: {'clean' if pb.audit_clean else 'RESIDUAL DRIFT'}")
        print(f"  reminder:    {pb.reminder}")
        if pb.canary_ran:
            print(f"  sooth canary: {'GREEN' if pb.canary_ok else 'RED'} — {pb.canary_detail}")
        else:
            print(f"  sooth canary: not run — {pb.canary_detail}")


def _cmd_retire(args: argparse.Namespace, reg: Registry | None, _ctx: Context | None) -> int:
    reg = _require_registry(reg)
    from .registry import fed_root as _fed_root

    fed = _fed_root(args.root)
    store_root = store_mod.store_root(args.store, fed_root=args.root)
    if args.apply:
        result = retire_mod.apply_retire(
            reg,
            args.id,
            fed_root=fed,
            toml_paths=default_paths(args.root),
            store_root=store_root,
            allow_retire=args.allow_retire,
        )
        if args.json:
            print(json.dumps(result.to_json(), indent=2, sort_keys=True))
        else:
            print(f"retire --apply: {result.id} {result.from_state} → {result.to_state}")
            print(f"  state flipped: {result.state_flipped} ({result.toml_file})")
            print(f"  store removed: {result.store_removed}")
        return EXIT_OK
    plan = retire_mod.plan_retire(reg, args.id, fed_root=fed, store_root=store_root)
    if args.json:
        print(json.dumps(plan.to_json(), indent=2, sort_keys=True))
        return EXIT_OK
    print(f"retire --plan: {plan.id} {plan.from_state} → {plan.to_state}")
    print(f"  store present: {plan.store_present}")
    if plan.references:
        print(f"  live references: {', '.join(plan.references)}")
    if plan.refused:
        print(f"  REFUSED: {plan.refusal}")
    else:
        print("  ready — `retire <id> --apply --allow-retire` to complete.")
    return EXIT_OK


_VERBS = {
    "list": _cmd_list,
    "show": _cmd_show,
    "doctor": _cmd_doctor,
    "probe": _cmd_probe,
    "watch": _cmd_watch,
    "ack": _cmd_ack,
    "add": _cmd_add_set,
    "set": _cmd_add_set,
    "exec": _cmd_exec,
    "materialize": _cmd_materialize,
    "export-offline": _cmd_export_offline,
    "history": _cmd_history,
    "sync": _cmd_sync,
    "retire": _cmd_retire,
}


# --------------------------------------------------------------------------- #
# Parser + entry point.
# --------------------------------------------------------------------------- #


def _add_value_input_args(p: argparse.ArgumentParser) -> None:
    p.add_argument(
        "--value-file",
        metavar="PATH",
        help="read the value from PATH, or '-' for stdin (bare value; NEVER argv)",
    )
    # The argv trap: `--value X` is explicitly recognised so it can be REJECTED with a
    # pointed message (rather than a bare argparse 'unrecognized argument').
    p.add_argument("--value", help=argparse.SUPPRESS)


def _build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(
        prog="mgf-secrets",
        description="Foundation credential registry + doctor + encrypted store (P2).",
    )
    p.add_argument("--version", action="version", version=f"mgf-secrets {__version__}")
    p.add_argument("--root", default=None, help="federation root ($MGF_ROOT / ~/PycharmProjects)")
    p.add_argument(
        "--store",
        default=None,
        help="store checkout ($MGF_SECRETS_STORE / <root>/mgf-secrets-store)",
    )
    sub = p.add_subparsers(dest="verb", required=True)

    lst = sub.add_parser("list", help="registry table (metadata)")
    lst.add_argument("--family")
    lst.add_argument("--kind")
    lst.add_argument("--state")
    lst.add_argument("--json", action="store_true")

    show = sub.add_parser("show", help="full metadata for one entry, or --value for the bare value")
    show.add_argument("id")
    show.add_argument("--json", action="store_true")
    show.add_argument(
        "--value", action="store_true", help="emit the bare value (refuses a TTY without --tty-ok)"
    )
    show.add_argument("--tty-ok", action="store_true", help="allow --value to write to a TTY")
    show.add_argument("--identity", help="decrypting key (default: the operator key)")

    doc = sub.add_parser("doctor", help="declared == deployed == consumed (read-only)")
    doc.add_argument(
        "--surface",
        action="append",
        choices=list(ALL_SURFACES),
        help="restrict/opt-in surfaces (default: woodpecker + local)",
    )
    doc.add_argument(
        "--config-source",
        choices=list(CONFIG_SOURCES),
        default=CONFIG_SOURCE_LOCAL,
        help="consumed-check pipeline source: local checkout (default) or forge-raw "
        "(no-checkout: the raw committed .woodpecker.yml from the forge — agent2; needs "
        "the woodpecker surface)",
    )
    doc.add_argument(
        "--error-window-hours",
        type=float,
        default=C9_WINDOW_HOURS,
        help="C9 recency window: only flag error-status-0-steps pipelines terminated within "
        f"this many hours (default {C9_WINDOW_HOURS:g}) — keeps the pager on NEW events",
    )
    doc.add_argument("--json", action="store_true")

    wa = sub.add_parser(
        "watch", help="cron: doctor → deduped issue on findings, heartbeat on run (SPEC-P4 §1)"
    )
    wa.add_argument(
        "--surface",
        action="append",
        choices=list(ALL_SURFACES),
        help="surfaces to watch (default: woodpecker + forge)",
    )
    wa.add_argument(
        "--config-source",
        choices=list(CONFIG_SOURCES),
        default=CONFIG_SOURCE_FORGE_RAW,
        help="consumed-check source (default: forge-raw — the raw committed .woodpecker.yml "
        "from the forge; agent2 has no checkouts)",
    )
    wa.add_argument(
        "--no-page",
        dest="page",
        action="store_false",
        default=True,
        help="PAGE=0 escape hatch: compute the summary but file NO issue (fire-testing)",
    )
    wa.add_argument(
        "--heartbeat-on",
        choices=["always", "clean"],
        default="always",
        help="'always' (default) beats on every completed run so a drift run stays distinct "
        "from a dead cron; 'clean' is the SPEC-literal heartbeat-only-on-clean",
    )
    wa.add_argument("--pager-token-file", default=_DEFAULT_PAGER_TOKEN_FILE)
    wa.add_argument("--issues-repo", default=watch_mod.DEFAULT_ISSUES_REPO)
    wa.add_argument("--heartbeat-url-file", default=_DEFAULT_HEARTBEAT_URL_FILE)
    wa.add_argument("--heartbeat-token-file", default=_DEFAULT_HEARTBEAT_TOKEN_FILE)
    wa.add_argument("--forge-cache-dir", default=None, help="persistent forge coverage cache (§7b)")
    wa.add_argument(
        "--forge-cache-ttl", type=float, default=None, help="forge cache TTL seconds (§7b)"
    )
    wa.add_argument(
        "--forge-budget", type=int, default=None, help="max live Codeberg reads per run (§7b)"
    )
    wa.add_argument(
        "--error-window-hours",
        type=float,
        default=C9_WINDOW_HOURS,
        help=f"C9 recency window in hours (default {C9_WINDOW_HOURS:g}) — page on NEW "
        "error-status-0-steps events, not the fleet's archive",
    )
    wa.add_argument(
        "--config-workers",
        type=int,
        default=DEFAULT_CONFIG_WORKERS,
        help=f"parallel Woodpecker per-repo fetches (default {DEFAULT_CONFIG_WORKERS}) — the "
        "unmetered config/secret/pipeline sweeps run concurrently",
    )
    wa.add_argument("--json", action="store_true")

    ak = sub.add_parser(
        "ack", help="list / add acknowledgements (defer a known finding from paging)"
    )
    ak.add_argument("check", nargs="?", help="finding check to acknowledge (e.g. C2); omit to list")
    ak.add_argument("id", nargs="?", help="finding id/target (the entry id)")
    ak.add_argument("--repo", help="pin the ack to a specific repo")
    ak.add_argument("--reason", help="human justification (REQUIRED to add)")
    ak.add_argument(
        "--expires", metavar="ISO", help="ISO date the ack lapses (default: indefinite)"
    )
    ak.add_argument("--list", action="store_true", help="list current acknowledgements")
    ak.add_argument("--json", action="store_true")

    prb = sub.add_parser("probe", help="one entry's liveness probe")
    prb.add_argument("id")
    prb.add_argument("--json", action="store_true")

    for name in ("add", "set"):
        ap = sub.add_parser(
            name,
            help=f"{'store a new' if name == 'add' else 'update the'} value (file/stdin/prompt)",
        )
        ap.add_argument("id")
        _add_value_input_args(ap)
        ap.add_argument("--json", action="store_true")

    ex = sub.add_parser("exec", help="run a child with store values injected as env vars")
    ex.add_argument(
        "--with",
        dest="with_",
        action="append",
        metavar="ID[:ENV_VAR]",
        help="inject secret ID as $ENV_VAR (default $ID); repeatable",
    )
    ex.add_argument("--identity", help="decrypting key (default: the operator key)")
    ex.add_argument("cmd", nargs=argparse.REMAINDER, help="-- COMMAND [ARGS…]")

    mz = sub.add_parser("materialize", help="decrypt the store into the bare-value my_stuff/ cache")
    mz.add_argument(
        "--only", action="append", metavar="ID", help="restrict to these ids; repeatable"
    )
    mz.add_argument(
        "--apply", action="store_true", help="write files (default is --plan, key-free)"
    )
    mz.add_argument("--plan", action="store_true", help="show the plan (default; explicit is fine)")
    mz.add_argument("--identity", help="decrypting key for --apply (default: the operator key)")
    mz.add_argument("--json", action="store_true")

    eo = sub.add_parser(
        "export-offline", help="ONE age bundle (founder key) for the password manager"
    )
    eo.add_argument(
        "--out", required=True, metavar="PATH", help="write the bundle here (never stdout)"
    )
    eo.add_argument("--json", action="store_true")

    hi = sub.add_parser("history", help="git rotation/audit trail for <id>.age (metadata only)")
    hi.add_argument("id")
    hi.add_argument("--json", action="store_true")

    sy = sub.add_parser("sync", help="reconcile declared → deployed per surface (plan-first)")
    sy.add_argument(
        "--surface",
        action="append",
        choices=list(sync_mod.ALL_SYNC_SURFACES),
        help="restrict surfaces (default: all — woodpecker/forge/hosts/local)",
    )
    sy.add_argument("--apply", action="store_true", help="execute the plan (default is --plan)")
    sy.add_argument("--plan", action="store_true", help="show the plan (default; explicit is fine)")
    sy.add_argument(
        "--allow-retire",
        action="store_true",
        help="permit removals (P3 sync is forward-only; retirement is the `retire` verb)",
    )
    sy.add_argument(
        "--skip-canary",
        action="store_true",
        help="skip the playbook sooth canary (dry/local only — a real surface-1 apply must run it)",
    )
    sy.add_argument("--identity", help="decrypting key for value/key injection on --apply")
    sy.add_argument("--json", action="store_true")

    rt = sub.add_parser("retire", help="state-flip a credential active→retiring (completion-gated)")
    rt.add_argument("id")
    rt.add_argument("--apply", action="store_true", help="perform the flip + store removal")
    rt.add_argument(
        "--allow-retire", action="store_true", help="required for --apply (completion gate)"
    )
    rt.add_argument("--json", action="store_true")
    return p


def main(
    argv: Sequence[str] | None = None,
    *,
    registry: Registry | None = None,
    context: Context | None = None,
) -> int:
    parser = _build_parser()
    args = parser.parse_args(argv)
    try:
        reg: Registry | None
        if registry is not None:
            reg = registry
        elif args.verb in _NO_REGISTRY_VERBS:
            reg = None
        else:
            reg = load(root=args.root)
        return _VERBS[args.verb](args, reg, context)
    except RegistryError as exc:
        print("mgf-secrets: registry failed to validate (load error):", file=sys.stderr)
        for problem in exc.problems:
            print(f"  - {problem}", file=sys.stderr)
        return EXIT_ERROR
    except AckError as exc:
        print("mgf-secrets: acknowledgements.toml failed to validate:", file=sys.stderr)
        for problem in exc.problems:
            print(f"  - {problem}", file=sys.stderr)
        return EXIT_ERROR
    except (CredentialError, UsageError) as exc:
        print(f"mgf-secrets: {exc}", file=sys.stderr)
        return EXIT_ERROR
    except SecretsError as exc:  # pragma: no cover - defensive catch-all
        print(f"mgf-secrets: {exc}", file=sys.stderr)
        return EXIT_ERROR


if __name__ == "__main__":  # pragma: no cover
    raise SystemExit(main())
