"""``rotate`` — staged, per-kind rotation (SPEC-P5 §1, §6).

Core behavior: the six-stage lifecycle runs in order, each gated on the prior; a
failed/refused stage stops the sequence; ``retire-old`` refuses without ``--allow-retire``
and for a held entry; ``jwt-minted`` collapses to the elimination (re-mint) path; a
completed rotation stamps ``rotation.last`` + writes a value-free audit record; and no
secret value ever reaches any output (AG-18).
"""

from __future__ import annotations

import io
import json
from contextlib import redirect_stdout
from datetime import UTC, datetime

import pytest

from mgf.secrets import cli
from mgf.secrets import rotate as rot
from mgf.secrets import store as store_mod
from mgf.secrets.context import Context
from mgf.secrets.errors import UsageError
from mgf.secrets.registry import load
from tests._store import make_store_env

FIXED = datetime(2026, 7, 22, 12, 0, 0, tzinfo=UTC)
CANARY = b"CANARY_ROT_new_secret_value_9f3c"


def _clock():
    return FIXED


def _plant_old_local(env, rel: str, value: bytes = b"OLD_value_present") -> None:
    """Plant the OLD credential's local file so probe-new sees a present credential
    (both old + new valid during cutover)."""
    p = env.fed_root / "mgf" / "my_stuff" / rel
    p.parent.mkdir(parents=True, exist_ok=True)
    p.write_text(value.decode(), encoding="utf-8")
    p.chmod(0o600)


def _rotator(env, **kw) -> rot.Rotator:
    return rot.Rotator(
        env.registry,
        fed_root=env.fed_root,
        store_root=env.store_root,
        identity_path=env.operator_key,
        now=_clock,
        **kw,
    )


# --------------------------------------------------------------------------- #
# Plan.
# --------------------------------------------------------------------------- #


def test_plan_lists_six_staged_steps_for_a_value_kind(tmp_path) -> None:
    env = make_store_env(tmp_path)
    plan = _rotator(env).plan("test_token")
    names = [s.name for s in plan.stages]
    assert names == list(rot.STAGES)
    assert not plan.elimination
    # retire-old is gated by default (no --allow-retire).
    retire = plan.stages[-1]
    assert retire.name == rot.RETIRE_OLD and retire.gated is True


def test_plan_marks_retire_ungated_with_allow_retire(tmp_path) -> None:
    env = make_store_env(tmp_path)
    plan = _rotator(env).plan("test_token", allow_retire=True)
    assert plan.stages[-1].gated is False


def test_plan_held_is_refused(tmp_path) -> None:
    env = make_store_env(tmp_path)
    plan = _rotator(env).plan("held_secret")
    assert plan.refused and "held" in plan.refusal and "⚖" in plan.refusal


def test_plan_jwt_minted_is_elimination(tmp_path) -> None:
    env = make_store_env(tmp_path, extra=_JWT_ENTRY)
    plan = _rotator(env).plan("wp_reconciler")
    assert plan.elimination
    assert [s.name for s in plan.stages] == list(rot.ELIMINATION_STAGES)
    assert "re-mint" in plan.stages[0].name
    # No retire-old of a stored token in the elimination path.
    assert rot.RETIRE_OLD not in [s.name for s in plan.stages]


def test_unknown_id_raises(tmp_path) -> None:
    env = make_store_env(tmp_path)
    with pytest.raises(UsageError, match="unknown id"):
        _rotator(env).plan("nope")


# --------------------------------------------------------------------------- #
# Apply — staged gating.
# --------------------------------------------------------------------------- #


def test_apply_completes_and_stamps_and_records(tmp_path) -> None:
    env = make_store_env(tmp_path)
    _plant_old_local(env, "tokens/test_token.txt")
    ctx = Context(fed_root=env.fed_root)
    result = _rotator(env, ctx=ctx).apply("test_token", new_value=CANARY, allow_retire=True)
    assert result.completed, [(s.name, s.status, s.message) for s in result.stages]
    assert [s.name for s in result.stages] == list(rot.STAGES)
    assert result.retired_old is True
    # rotation.last stamped surgically in the owning SECRETS.toml.
    assert result.rotation_last == "2026-07-22"
    reloaded = load([env.fed_root / "mgf-standard" / "SECRETS.toml"])
    assert reloaded.get("test_token").rotation["last"] == "2026-07-22"
    # a value-free audit record landed in the store git tree.
    assert result.record_path and (env.store_root / "rotations" / "test_token.jsonl").is_file()
    rec = json.loads((env.store_root / "rotations" / "test_token.jsonl").read_text().strip())
    assert rec["id"] == "test_token" and rec["retired_old"] is True
    assert CANARY.decode() not in json.dumps(rec)  # value-free record


def test_retire_old_refused_without_allow_retire(tmp_path) -> None:
    env = make_store_env(tmp_path)
    _plant_old_local(env, "tokens/test_token.txt")
    ctx = Context(fed_root=env.fed_root)
    result = _rotator(env, ctx=ctx).apply("test_token", new_value=CANARY, allow_retire=False)
    # every earlier stage ran; retire-old refused (gated) → rotation did not complete.
    assert not result.completed
    retire = result.stages[-1]
    assert retire.name == rot.RETIRE_OLD and retire.status == rot.STATUS_REFUSED
    assert "gated" in retire.message
    # verify-consumers ran green before the gate.
    verify = [s for s in result.stages if s.name == rot.VERIFY_CONSUMERS]
    assert verify and verify[0].status == rot.STATUS_OK
    # rotation NOT stamped (only a completed rotation stamps).
    assert result.rotation_last is None


def test_probe_new_failure_stops_before_cutover(tmp_path) -> None:
    env = make_store_env(tmp_path)
    # No old local file planted → the local-presence probe finds the credential MISSING,
    # so probe-new fails and cutover / verify / retire never run.
    ctx = Context(fed_root=env.fed_root)
    result = _rotator(env, ctx=ctx).apply("test_token", new_value=CANARY, allow_retire=True)
    assert not result.completed
    names = [s.name for s in result.stages]
    assert names == [rot.GENERATE_NEW, rot.REGISTER_ALONGSIDE, rot.PROBE_NEW]
    assert result.stages[-1].status == rot.STATUS_FAILED
    assert rot.CUTOVER not in names and rot.RETIRE_OLD not in names


def test_generate_new_refused_when_no_value_supplied(tmp_path) -> None:
    env = make_store_env(tmp_path)
    ctx = Context(fed_root=env.fed_root)
    result = _rotator(env, ctx=ctx).apply("test_token", new_value=None, allow_retire=True)
    assert not result.completed
    assert result.stages == result.stages[:1]  # only generate-new ran
    assert result.stages[0].name == rot.GENERATE_NEW
    assert result.stages[0].status == rot.STATUS_REFUSED
    assert "value-file" in result.stages[0].message


def test_apply_held_raises_before_touching_a_surface(tmp_path) -> None:
    env = make_store_env(tmp_path)
    with pytest.raises(UsageError, match="held"):
        _rotator(env, ctx=Context(fed_root=env.fed_root)).apply(
            "held_secret", new_value=CANARY, allow_retire=True
        )
    # nothing was stored for the held entry.
    assert not store_mod.cipher_path(env.store_root, "held_secret").is_file()


def test_jwt_minted_elimination_apply_has_no_store_write(tmp_path) -> None:
    env = make_store_env(tmp_path, extra=_JWT_ENTRY)
    ctx = Context(fed_root=env.fed_root)
    result = _rotator(env, ctx=ctx).apply("wp_reconciler")
    assert result.completed
    assert [s.name for s in result.stages] == list(rot.ELIMINATION_STAGES)
    # elimination never stores a token.
    assert not store_mod.cipher_path(env.store_root, "wp_reconciler").is_file()


# --------------------------------------------------------------------------- #
# Redaction over rotate (SPEC §6) — the sim value never reaches stdout/JSON.
# --------------------------------------------------------------------------- #


def test_no_value_in_result_json_or_cli_output(tmp_path) -> None:
    env = make_store_env(tmp_path)
    _plant_old_local(env, "tokens/test_token.txt")
    ctx = Context(fed_root=env.fed_root)
    result = _rotator(env, ctx=ctx).apply("test_token", new_value=CANARY, allow_retire=True)
    assert CANARY.decode() not in json.dumps(result.to_json())
    # Drive the CLI --apply --json path with the value on stdin; assert absence.
    vfile = tmp_path / "newval"
    vfile.write_bytes(CANARY)
    out = io.StringIO()
    with redirect_stdout(out):
        rc = cli.main(
            [
                "--root",
                str(env.fed_root),
                "--store",
                str(env.store_root),
                "rotate",
                "test_token",
                "--apply",
                "--allow-retire",
                "--value-file",
                str(vfile),
                "--identity",
                str(env.operator_key),
                "--json",
            ],
            registry=env.registry,
            context=ctx,
        )
    assert rc == 0
    assert CANARY.decode() not in out.getvalue()


def test_cli_rejects_value_on_argv(tmp_path) -> None:
    env = make_store_env(tmp_path)
    rc = cli.main(
        [
            "--root",
            str(env.fed_root),
            "--store",
            str(env.store_root),
            "rotate",
            "test_token",
            "--apply",
            "--value",
            "SECRET_ON_ARGV",
        ],
        registry=env.registry,
        context=Context(fed_root=env.fed_root),
    )
    assert rc == 2  # EXIT_ERROR — argv value trapped


# A jwt-minted (elimination) entry appended to the store fixture registry.
_JWT_ENTRY = """\
[wp_reconciler]
family = "mgf"
kind = "jwt-minted"
purpose = "woodpecker reconciler token — minted on demand (no stored token)"
consumers = ["agent-reconciler.py"]
recovery = "mint per MGF_INFRA §2"
state = "active"
policy = { allowed_repos = "*", blast_radius = "woodpecker admin" }
rotation = { class = "jwt", staged = false }
verify = { probe = "woodpecker-user" }
surfaces = [ { type = "local-file", repo = "mgf", path = "tokens/wp_reconciler.txt", mode = "0600" } ]
"""


# --------------------------------------------------------------------------- #
# The store commit — a rotation that is not committed is not durable.
# --------------------------------------------------------------------------- #


def test_register_alongside_commits_the_store(tmp_path, monkeypatch) -> None:
    """`rotate` must commit the ciphertext, exactly as `add`/`set` do.

    It used to write and not commit. That is worse here than anywhere else in the
    plane: the lifecycle ends by REVOKING the old credential, so an uncommitted
    rotation leaves the only copy of the working value in one working tree while
    the value that still authenticates is being torn down. A lost machine at that
    moment is a lockout, not an inconvenience.

    Observed 2026-07-29 rotating `agent_codeberg_write`: the store had five
    commits and none was made by the verb that produced the writes.
    """
    env = make_store_env(tmp_path)
    seen: list[tuple[str, str]] = []
    monkeypatch.setattr(
        store_mod,
        "store_commit",
        lambda root, message: (seen.append((str(root), message)), True)[1],
    )
    result = _rotator(env, ctx=Context(fed_root=env.fed_root)).apply("test_token", new_value=CANARY)
    assert result.stages, "expected staged results"
    assert len(seen) == 1, "the store write must be committed exactly once"
    root, message = seen[0]
    assert str(env.store_root) in root
    assert message == "rotate: test_token (mgf-secrets rotate)"
    assert "test_token" in message and CANARY.decode() not in message


def test_store_commit_failure_is_reported_not_fatal(tmp_path, monkeypatch) -> None:
    """A store that is not a git repo must not abort the rotation.

    The ciphertext is already written either way; failing the rotation here would
    strand the operator mid-lifecycle with the new value on disk and the old one
    still live — strictly worse than reporting and continuing.
    """
    env = make_store_env(tmp_path)
    monkeypatch.setattr(store_mod, "store_commit", lambda root, message: False)
    result = _rotator(env, ctx=Context(fed_root=env.fed_root)).apply("test_token", new_value=CANARY)
    reg = [s for s in result.stages if s.name == rot.REGISTER_ALONGSIDE]
    assert reg and reg[0].status == rot.STATUS_OK
    assert reg[0].detail.get("store_committed") is False
