"""``rotate <id>`` — staged, per-kind credential rotation (SPEC-P5 §1, PROPOSAL §2.5).

A rotation is a **sequence of gated stages**, never a prose runbook: each stage runs
only if the prior one succeeded, so a rotation can never leave a surface pointing at a
credential that was never proven live, and the old credential is never retired until
the consumers have been verified on the new one.

    generate-new ─▶ register-alongside ─▶ probe-new ─▶ cutover
                              ─▶ verify-consumers ─▶ retire-old (gated)

* **generate-new** — obtain the new value: an injected ``value_provider`` (tests /
  library callers) or the CLI's ``--value-file`` (a web-minted credential). The library
  never mints a *provider* credential itself (that is a forge/devpi/OpenStack act); it
  stages the rotation around a value the operator supplies. Value-bearing kinds refuse
  ``--apply`` with no new value, pointing at ``--value-file``.
* **register-alongside** — put the new value on the surfaces **without removing the
  old**: ``store_put`` the new ciphertext (the store's git history is the audit log),
  update the live Woodpecker secret to serve the new value, register a new deploy key
  beside the old. Both credentials are valid during cutover.
* **probe-new** — the kind's declared liveness probe (:mod:`mgf.secrets.probes`) passes.
* **cutover** — point consumers at the new value (materialize the bare-value cache).
* **verify-consumers** — a scope audit (declared == deployed for this entry) + the
  *fire one pipeline per event class* reminder + the **sooth canary** where the entry is
  a cross-family contract surface (an org secret).
* **retire-old** — GATED: requires ``--allow-retire`` AND a green verify AND a non-held
  entry. It records the provider-revoke directive (revoke the old token / remove the old
  key at the provider) and marks the rotation complete. A ``state = "held"`` entry
  refuses — retiring a held credential is a ⚖ decision package with the founder, never a
  solo ``rotate``.

**Elimination over rotation** (SPEC-P5 §1): a ``jwt-minted`` credential has no stored
token to swap — its "rotation" is a **re-mint** (a signing-hash rotation invalidates
every minted JWT; the reconciler re-mints on demand). The verb enforces that: the plan
collapses to *re-mint ─▶ probe-new ─▶ verify-consumers*, with no store write and no
retire-old of a stored value.

**AG-18 is absolute.** No stage ever prints, logs, or records a secret value. A rotation
record carries only value-free metadata — targets, a ciphertext sha256, a fingerprint —
and the new value lives as ``bytes`` in-process only, handed to ``store_put`` / the
Woodpecker create body and nowhere else.
"""

from __future__ import annotations

import json
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any

from .errors import CredentialError, SurfaceUnavailableError, UsageError

if TYPE_CHECKING:
    from collections.abc import Callable

    from .context import Context
    from .model import Entry
    from .registry import Registry

# --------------------------------------------------------------------------- #
# Stages + per-kind playbooks.
# --------------------------------------------------------------------------- #

GENERATE_NEW = "generate-new"
REGISTER_ALONGSIDE = "register-alongside"
PROBE_NEW = "probe-new"
CUTOVER = "cutover"
VERIFY_CONSUMERS = "verify-consumers"
RETIRE_OLD = "retire-old"

#: The full staged sequence (value-bearing kinds).
STAGES: tuple[str, ...] = (
    GENERATE_NEW,
    REGISTER_ALONGSIDE,
    PROBE_NEW,
    CUTOVER,
    VERIFY_CONSUMERS,
    RETIRE_OLD,
)

#: The elimination sequence (mint-on-demand kinds — no stored token to swap/retire).
ELIMINATION_STAGES: tuple[str, ...] = ("re-mint", PROBE_NEW, VERIFY_CONSUMERS)

STATUS_OK = "ok"
STATUS_REFUSED = "refused"
STATUS_SKIPPED = "skipped"
STATUS_FAILED = "failed"
STATUS_PLANNED = "planned"


@dataclass(frozen=True)
class RotationPlaybook:
    """How a ``kind`` rotates. Data-only; the :class:`Rotator` reads these flags so the
    orchestration stays shared and every kind's rotation shape is declared in one place.

    ``elimination`` — mint-on-demand, no stored token (``jwt-minted``): re-mint, never
    a stored-token swap. ``keypair`` — the new value is an SSH keypair whose PUBLIC half
    is registered as a deploy key beside the old (``ssh-deploy-key``). ``break_glass`` —
    the documented per-kind break-glass verb/script. ``remint_note`` — the elimination
    playbook's one-line description.
    """

    kind: str
    elimination: bool = False
    keypair: bool = False
    break_glass: str = ""
    remint_note: str = ""


#: Every ``kind`` MUST have a rotation playbook — the kind-extension meta-test fails the
#: suite otherwise (SPEC §5.3: a new kind without a rotate playbook cannot grow an
#: unverifiable branch). Kept in step with :data:`mgf.secrets.probes.PROBES`.
ROTATION_PLAYBOOKS: dict[str, RotationPlaybook] = {
    "ssh-deploy-key": RotationPlaybook(
        "ssh-deploy-key",
        keypair=True,
        break_glass="mint a new keypair; register the new deploy key beside the old; "
        "rotate the org-secret private half; remove the old key after canary",
    ),
    "ssh-keypair": RotationPlaybook(
        "ssh-keypair",
        keypair=True,
        break_glass="mint a new keypair; add the new public key on the forge; swap the "
        "local private half; remove the old key after verify",
    ),
    "api-token": RotationPlaybook(
        "api-token",
        break_glass="mint a new forge/devpi token (old still valid); deploy the new "
        "value; revoke the old token after canary",
    ),
    "jwt-minted": RotationPlaybook(
        "jwt-minted",
        elimination=True,
        break_glass="rotate_woodpecker_token.sh — a signing-hash rotation invalidates "
        "every minted JWT; the reconciler re-mints on demand (MGF_INFRA §2)",
        remint_note="re-mint on demand (no stored token) — a hash rotation invalidates "
        "all minted JWTs; the reconciler re-mints",
    ),
    "password": RotationPlaybook(
        "password",
        break_glass="reset the password at the provider (old still valid); deploy the "
        "new value; invalidate the old after verify",
    ),
    "oauth-app": RotationPlaybook(
        "oauth-app",
        break_glass="rotate the OAuth app client secret at the forge; re-enter the "
        "server env; restart the consumer",
    ),
    "service-account": RotationPlaybook(
        "service-account",
        break_glass="issue a new SA token (old still valid); redistribute; revoke the "
        "old SA credential after verify",
    ),
    "openstack-appcred": RotationPlaybook(
        "openstack-appcred",
        break_glass="issue a new application credential on the OpenStack dashboard; "
        "deploy the new clouds.yaml; delete the old appcred after verify",
    ),
}


# --------------------------------------------------------------------------- #
# Plan + result model — value-free throughout (AG-18).
# --------------------------------------------------------------------------- #


@dataclass
class StagePlan:
    """One staged step of a rotation, as it would run. ``gated`` marks a step blocked on
    an operator gate (``retire-old`` without ``--allow-retire``); ``refusal`` explains a
    hard refusal (a held entry). ``detail`` is value-free."""

    name: str
    action: str
    gated: bool = False
    refusal: str = ""
    detail: dict[str, Any] = field(default_factory=dict)

    def to_json(self) -> dict[str, Any]:
        return {
            "name": self.name,
            "action": self.action,
            "gated": self.gated,
            "refusal": self.refusal,
            "detail": self.detail,
        }


@dataclass
class RotatePlan:
    id: str
    kind: str
    elimination: bool
    break_glass: str
    stages: list[StagePlan] = field(default_factory=list)
    refused: bool = False
    refusal: str = ""

    def to_json(self) -> dict[str, Any]:
        return {
            "id": self.id,
            "kind": self.kind,
            "elimination": self.elimination,
            "break_glass": self.break_glass,
            "stages": [s.to_json() for s in self.stages],
            "refused": self.refused,
            "refusal": self.refusal,
        }


@dataclass
class StageResult:
    name: str
    status: str  # ok | refused | skipped | failed
    message: str = ""
    detail: dict[str, Any] = field(default_factory=dict)

    def to_json(self) -> dict[str, Any]:
        return {
            "name": self.name,
            "status": self.status,
            "message": self.message,
            "detail": self.detail,
        }


@dataclass
class RotateResult:
    id: str
    kind: str
    elimination: bool
    stages: list[StageResult] = field(default_factory=list)
    rotation_last: str | None = None
    retired_old: bool = False
    record_path: str | None = None
    revoke_directive: str = ""

    @property
    def completed(self) -> bool:
        """Every stage that ran finished ``ok`` (a ``refused``/``failed`` stage stops
        the sequence, so its presence means the rotation did not complete)."""
        return bool(self.stages) and all(s.status == STATUS_OK for s in self.stages)

    def to_json(self) -> dict[str, Any]:
        return {
            "id": self.id,
            "kind": self.kind,
            "elimination": self.elimination,
            "completed": self.completed,
            "stages": [s.to_json() for s in self.stages],
            "rotation_last": self.rotation_last,
            "retired_old": self.retired_old,
            "record_path": self.record_path,
            "revoke_directive": self.revoke_directive,
        }


# --------------------------------------------------------------------------- #
# The staged rotator.
# --------------------------------------------------------------------------- #

ValueProvider = "Callable[[str], bytes]"


class Rotator:
    """Plan + apply a staged rotation against injected clients + the store.

    Every surface interaction goes through the same admin clients ``sync`` uses
    (:class:`WoodpeckerAdmin` / :class:`ForgeAdmin`) and the store, so a drill runs the
    whole lifecycle against in-proc fakes with zero network. A missing client means its
    surface stage is ``skipped`` (never a false success). ``now`` is injected for
    determinism (the CLI stamps it; tests pin it) — the core never calls the clock.
    """

    def __init__(
        self,
        registry: Registry,
        *,
        fed_root: Path,
        store_root: Path | None = None,
        identity_path: str | Path | None = None,
        ctx: Context | None = None,
        value_provider: Callable[[str], bytes] | None = None,
        canary: Callable[[], Any] | None = None,
        now: Callable[[], datetime] | None = None,
    ) -> None:
        self.reg = registry
        self.fed_root = Path(fed_root)
        self.store_root = store_root
        self.identity_path = identity_path
        self.ctx = ctx
        self._value_provider = value_provider
        self.canary = canary
        self._now = now or (lambda: datetime.now(UTC))

    # -- helpers ------------------------------------------------------------- #

    def _entry(self, entry_id: str) -> Entry:
        entry = self.reg.get(entry_id)
        if entry is None:
            raise UsageError(f"unknown id {entry_id!r} — run `mgf-secrets list` to see ids")
        return entry

    def _playbook(self, kind: str) -> RotationPlaybook:
        pb = ROTATION_PLAYBOOKS.get(kind)
        if pb is None:  # pragma: no cover - guarded by the module-level meta-test
            raise UsageError(f"no rotation playbook for kind {kind!r}")
        return pb

    def _store_root(self) -> Path:
        from .store import store_root as _sr

        return self.store_root if self.store_root is not None else _sr(fed_root=self.fed_root)

    def _has_org_secret(self, entry: Entry) -> bool:
        """The entry occupies a cross-family contract surface (an org secret) — its
        rotation must run the sooth canary in verify-consumers (SPEC §1, §5)."""
        return entry.has_surface("woodpecker-org-secret")

    # -- plan ---------------------------------------------------------------- #

    def plan(self, entry_id: str, *, allow_retire: bool = False) -> RotatePlan:
        entry = self._entry(entry_id)
        pb = self._playbook(entry.kind)
        plan = RotatePlan(
            id=entry.id,
            kind=entry.kind,
            elimination=pb.elimination,
            break_glass=pb.break_glass,
        )
        if pb.elimination:
            plan.stages = [
                StagePlan("re-mint", pb.remint_note, detail={"stored_token": False}),
                StagePlan(PROBE_NEW, f"{entry.kind}: liveness probe passes on the re-mint"),
                StagePlan(
                    VERIFY_CONSUMERS,
                    "scope audit + one pipeline per event class"
                    + (" + sooth canary" if self._has_org_secret(entry) else ""),
                ),
            ]
            return plan
        plan.stages = self._value_stage_plans(entry, allow_retire=allow_retire)
        if entry.state == "held":
            plan.refused = True
            plan.refusal = (
                f"{entry.id!r} is state=held — the founder's hold. A held credential's "
                "rotation (and any retire-old) is a ⚖ decision package fired with the "
                "founder, never a solo `rotate`."
            )
        return plan

    def _value_stage_plans(self, entry: Entry, *, allow_retire: bool) -> list[StagePlan]:
        keypair = self._playbook(entry.kind).keypair
        surfaces = sorted({s.type for s in entry.surfaces})
        register = (
            "register the new keypair's public half beside the old"
            if keypair
            else ("store the new value + update live surfaces (old still valid)")
        )
        retire = StagePlan(
            RETIRE_OLD,
            "revoke/remove the old credential at the provider",
            gated=not allow_retire,
            detail={"requires": "--allow-retire", "surfaces": surfaces},
        )
        if entry.state == "held":
            retire.refusal = "held — a ⚖ package with the founder, never solo"
        elif not allow_retire:
            retire.refusal = "gated — pass --allow-retire (and verify-consumers must be green)"
        return [
            StagePlan(GENERATE_NEW, "obtain the new value (--value-file / provider)"),
            StagePlan(REGISTER_ALONGSIDE, register, detail={"surfaces": surfaces}),
            StagePlan(PROBE_NEW, f"{entry.kind}: liveness probe passes on the new value"),
            StagePlan(CUTOVER, "materialize the new value; point consumers at it"),
            StagePlan(
                VERIFY_CONSUMERS,
                "scope audit + one pipeline per event class"
                + (" + sooth canary" if self._has_org_secret(entry) else ""),
            ),
            retire,
        ]

    # -- apply --------------------------------------------------------------- #

    def apply(
        self,
        entry_id: str,
        *,
        new_value: bytes | None = None,
        allow_retire: bool = False,
        skip_canary: bool = False,
    ) -> RotateResult:
        entry = self._entry(entry_id)
        pb = self._playbook(entry.kind)
        # A held entry never rotates solo — refuse before any surface is touched.
        if entry.state == "held" and not pb.elimination:
            raise UsageError(
                f"{entry.id!r} is state=held — rotating (or retiring) a held credential is a "
                "⚖ decision package fired with the founder, never a solo `rotate`."
            )
        result = RotateResult(id=entry.id, kind=entry.kind, elimination=pb.elimination)
        if pb.elimination:
            self._apply_elimination(entry, pb, result, skip_canary=skip_canary)
        else:
            self._apply_value_rotation(
                entry,
                pb,
                result,
                new_value=new_value,
                allow_retire=allow_retire,
                skip_canary=skip_canary,
            )
        # Stamp rotation.last + emit the value-free audit record only on a completed run.
        if result.completed:
            self._finalize(entry, result)
        return result

    def _apply_elimination(
        self, entry: Entry, pb: RotationPlaybook, result: RotateResult, *, skip_canary: bool
    ) -> None:
        result.stages.append(
            StageResult("re-mint", STATUS_OK, pb.remint_note, {"stored_token": False})
        )
        if not self._run_probe(entry, result):
            return
        self._run_verify(entry, result, skip_canary=skip_canary)

    def _apply_value_rotation(
        self,
        entry: Entry,
        pb: RotationPlaybook,
        result: RotateResult,
        *,
        new_value: bytes | None,
        allow_retire: bool,
        skip_canary: bool,
    ) -> None:
        # 1. generate-new — obtain the new value (never printed).
        value = self._resolve_new_value(entry, new_value)
        if value is None:
            result.stages.append(
                StageResult(
                    GENERATE_NEW,
                    STATUS_REFUSED,
                    "no new value supplied — pass --value-file with the newly-minted value "
                    "(the library never mints a provider credential itself)",
                )
            )
            return
        result.stages.append(
            StageResult(GENERATE_NEW, STATUS_OK, "new value obtained (value-free record)")
        )
        # 2. register-alongside — store the new value + update live surfaces.
        if not self._register_alongside(entry, pb, value, result):
            return
        # 3. probe-new.
        if not self._run_probe(entry, result):
            return
        # 4. cutover — materialize the new value into the bare-value cache.
        self._cutover(entry, result)
        if result.stages[-1].status != STATUS_OK:
            return
        # 5. verify-consumers.
        if not self._run_verify(entry, result, skip_canary=skip_canary):
            return
        # 6. retire-old — gated on --allow-retire + a non-held entry + a green verify.
        self._retire_old(entry, pb, result, allow_retire=allow_retire)

    # -- stage implementations ---------------------------------------------- #

    def _resolve_new_value(self, entry: Entry, new_value: bytes | None) -> bytes | None:
        if self._value_provider is not None:
            return self._value_provider(entry.id)
        return new_value

    def _register_alongside(
        self, entry: Entry, pb: RotationPlaybook, value: bytes, result: RotateResult
    ) -> bool:
        """Put the new value on the surfaces without removing the old. AG-18: ``value``
        is handed to ``store_put`` / the Woodpecker create body and never recorded."""
        from . import store as store_mod

        targets: list[str] = []
        new_ref: dict[str, Any] = {}
        # store surface — the new ciphertext (the store git history is the audit log).
        if entry.has_surface("store") or entry.has_surface("local-file"):
            root = self._store_root()
            try:
                recipients = store_mod.load_recipients(root)
            except CredentialError as exc:
                result.stages.append(StageResult(REGISTER_ALONGSIDE, STATUS_FAILED, str(exc)))
                return False
            stamped = self._now().strftime("%Y-%m-%dT%H:%M:%SZ")
            row = store_mod.store_put(
                entry.id,
                value,
                root=root,
                encrypted_at=stamped,
                recipients=recipients,
                family=entry.family,
                kind=entry.kind,
            )
            targets.append(f"store:{row['file']}")
            new_ref["sha256"] = row["sha256"]
        # woodpecker surface — update the live secret to serve the new value.
        wp = self.ctx.woodpecker if self.ctx else None
        if wp is not None:
            try:
                self._update_woodpecker(entry, value, wp, targets)
            except (CredentialError, SurfaceUnavailableError) as exc:
                result.stages.append(StageResult(REGISTER_ALONGSIDE, STATUS_FAILED, str(exc)))
                return False
        # forge deploy key — register the new PUBLIC key beside the old (keypair kinds).
        forge = self.ctx.forge if self.ctx else None
        if pb.keypair and forge is not None:
            self._register_new_deploy_key(entry, value, forge, targets, new_ref)
        detail: dict[str, Any] = {"targets": targets, "both_valid": True}
        detail.update(new_ref)
        result.stages.append(
            StageResult(
                REGISTER_ALONGSIDE, STATUS_OK, "new value registered; old still valid", detail
            )
        )
        return True

    def _update_woodpecker(self, entry: Entry, value: bytes, wp: Any, targets: list[str]) -> None:
        for s in entry.surfaces_of("woodpecker-org-secret"):
            name = str(s.get("name", entry.id))
            events = frozenset(s.events)
            live = wp.org_secret_states()
            images = tuple(live[name].images) if name in live else ()
            if name in live:
                wp.update_org_secret(name, events=events, images=images)
            else:
                wp.create_org_secret(name, value, events, images)
            targets.append(f"woodpecker-org:{name}")
        for s in entry.surfaces_of("woodpecker-repo-secret"):
            name = str(s.get("name", entry.id))
            repo = str(s["repo"])
            events = frozenset(s.events)
            repo_ids = {r.name: r.id for r in wp.activated_repos()}
            if repo not in repo_ids:
                continue
            rid = repo_ids[repo]
            live = wp.repo_secret_states(rid)
            images = tuple(live[name].images) if name in live else ()
            if name in live:
                wp.update_repo_secret(rid, name, events=events, images=images)
            else:
                wp.create_repo_secret(rid, name, value, events, images)
            targets.append(f"woodpecker-repo:{repo}/{name}")

    def _register_new_deploy_key(
        self, entry: Entry, value: bytes, forge: Any, targets: list[str], new_ref: dict[str, Any]
    ) -> None:
        """Register the NEW public key beside the old on every declared repo. The public
        half is the first line of an ``ssh-… …`` keypair value (public key material is
        not a secret); a value without a public line is skipped (no false success)."""
        pub = _public_key_line(value)
        if not pub:
            return
        from .managed import normalize_repos, resolve_all_managed_detail

        wp = self.ctx.woodpecker if self.ctx else None
        activated = [r.name for r in wp.activated_repos()] if wp else []
        managed = set(
            resolve_all_managed_detail(fed_root=self.fed_root, activated_repos=activated).repos
        )
        for s in entry.surfaces_of("forge-deploy-key"):
            new_title = f"{s['title']}-rot-{self._now().strftime('%Y%m%d')}"
            for repo in sorted(normalize_repos(s.get("repos"), managed=managed)):
                try:
                    forge.register_deploy_key(
                        repo, title=new_title, key=pub, read_only=bool(s["readonly"])
                    )
                except (CredentialError, SurfaceUnavailableError):
                    continue
                targets.append(f"forge-key:{repo}/{new_title}")
            new_ref["new_key_title"] = new_title

    def _run_probe(self, entry: Entry, result: RotateResult) -> bool:
        from .probes import run_probe

        if self.ctx is None:
            result.stages.append(
                StageResult(PROBE_NEW, STATUS_SKIPPED, "no clients — probe not run (metadata only)")
            )
            return True
        probe = run_probe(entry, self.ctx)
        if probe.status == "finding":
            result.stages.append(
                StageResult(PROBE_NEW, STATUS_FAILED, probe.message, dict(probe.evidence))
            )
            return False
        result.stages.append(StageResult(PROBE_NEW, STATUS_OK, probe.message, dict(probe.evidence)))
        return True

    def _cutover(self, entry: Entry, result: RotateResult) -> None:
        from . import materialize as mtz

        if not entry.has_surface("local-file"):
            result.stages.append(
                StageResult(CUTOVER, STATUS_OK, "no local cache to cut over (surface serves new)")
            )
            return
        root = self._store_root()
        items = [
            pi
            for pi in mtz.plan(self.reg, fed_root=self.fed_root, root=root, only=[entry.id])
            if pi.pending
        ]
        written = mtz.apply(
            items, fed_root=self.fed_root, root=root, identity_path=self.identity_path
        ).written
        result.stages.append(
            StageResult(
                CUTOVER,
                STATUS_OK,
                "consumers cut over to the new value",
                {"materialized": sorted(written)},
            )
        )

    def _run_verify(self, entry: Entry, result: RotateResult, *, skip_canary: bool) -> bool:
        """Scope audit (declared == deployed for this entry) + event-class reminder +
        the sooth canary where the entry is a cross-family org-secret surface."""
        audit_clean, residual = self._scope_audit(entry)
        detail: dict[str, Any] = {
            "scope_audit": "clean" if audit_clean else "residual-drift",
            "reminder": "fire one pipeline per event class for the rotated secret",
            "residual": residual,
        }
        if not audit_clean:
            result.stages.append(
                StageResult(
                    VERIFY_CONSUMERS,
                    STATUS_FAILED,
                    "scope audit shows residual drift after cutover",
                    detail,
                )
            )
            return False
        if self._has_org_secret(entry):
            if skip_canary:
                detail["canary"] = "skipped (--skip-canary; a real surface-1 rotation must run it)"
            elif self.canary is not None:
                cres = self.canary()
                detail["canary"] = "green" if cres.ok else "RED"
                detail["canary_detail"] = cres.detail
                if not cres.ok:
                    result.stages.append(
                        StageResult(VERIFY_CONSUMERS, STATUS_FAILED, "sooth canary RED", detail)
                    )
                    return False
            else:
                detail["canary"] = "no canary configured"
        result.stages.append(
            StageResult(VERIFY_CONSUMERS, STATUS_OK, "consumers verified on the new value", detail)
        )
        return True

    def _scope_audit(self, entry: Entry) -> tuple[bool, list[dict[str, Any]]]:
        """Reuse the reconciler's plan: after cutover a woodpecker plan must carry no
        create/update action for THIS entry (declared == deployed for it)."""
        wp = self.ctx.woodpecker if self.ctx else None
        if wp is None:
            return True, []
        from .sync import Reconciler

        rec = Reconciler(
            self.reg, fed_root=self.fed_root, store_root=self.store_root, woodpecker=wp
        )
        residual = [
            a.to_json() for a in rec.plan(["woodpecker"]).sorted_actions() if a.id == entry.id
        ]
        return (not residual), residual

    def _retire_old(
        self, entry: Entry, pb: RotationPlaybook, result: RotateResult, *, allow_retire: bool
    ) -> None:
        if entry.state == "held":
            result.stages.append(
                StageResult(
                    RETIRE_OLD,
                    STATUS_REFUSED,
                    f"{entry.id!r} is held — retiring the old credential is a ⚖ package with "
                    "the founder, never solo",
                )
            )
            return
        if not allow_retire:
            result.stages.append(
                StageResult(
                    RETIRE_OLD,
                    STATUS_REFUSED,
                    "retire-old is gated — pass --allow-retire to revoke the old credential "
                    "(the new is live + verified; the old stays valid until you do)",
                )
            )
            return
        directive = pb.break_glass or "revoke the old credential at the provider"
        result.revoke_directive = directive
        result.retired_old = True
        result.stages.append(
            StageResult(
                RETIRE_OLD,
                STATUS_OK,
                "old credential retire authorized",
                {"revoke_directive": directive},
            )
        )

    # -- finalize: rotation.last stamp + value-free audit record ------------- #

    def _finalize(self, entry: Entry, result: RotateResult) -> None:
        from .registry import default_paths
        from .tomledit import set_rotation_last_in_file

        date = self._now().strftime("%Y-%m-%d")
        for path in default_paths(self.fed_root):
            if path.is_file() and set_rotation_last_in_file(path, entry.id, date):
                result.rotation_last = date
                break
        result.record_path = self._write_record(entry, result, date)

    def _write_record(self, entry: Entry, result: RotateResult, date: str) -> str | None:
        """Append a value-free rotation record to ``<store>/rotations/<id>.jsonl`` — the
        store's git history is the audit log (SPEC §1). Best-effort: a store we cannot
        write is not a rotation failure (the surfaces already carry the new value)."""
        record = {
            "id": entry.id,
            "kind": entry.kind,
            "at": self._now().strftime("%Y-%m-%dT%H:%M:%SZ"),
            "date": date,
            "elimination": result.elimination,
            "stages": [{"name": s.name, "status": s.status} for s in result.stages],
            "retired_old": result.retired_old,
            "revoke_directive": result.revoke_directive,
        }
        # Pull the value-free new-ref (sha256 / key title) from the register stage detail.
        for s in result.stages:
            if s.name == REGISTER_ALONGSIDE:
                for k in ("sha256", "new_key_title", "targets"):
                    if k in s.detail:
                        record[k] = s.detail[k]
        try:
            rdir = self._store_root() / "rotations"
            rdir.mkdir(parents=True, exist_ok=True)
            rpath = rdir / f"{entry.id}.jsonl"
            with rpath.open("a", encoding="utf-8") as fh:
                fh.write(json.dumps(record, sort_keys=True) + "\n")
            rpath.chmod(0o600)
        except OSError:
            return None
        return str(rpath)


def _public_key_line(value: bytes) -> str:
    """The public ``ssh-… …`` line from an SSH keypair value, or '' if none. Public key
    material is not a secret; the private half is never returned."""
    try:
        text = value.decode("utf-8", "surrogateescape")
    except Exception:  # pragma: no cover - surrogateescape never raises
        return ""
    for line in text.splitlines():
        s = line.strip()
        if s.startswith(("ssh-", "ecdsa-", "sk-ssh-")):
            return s
    return ""
