"""``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" } ]
"""
