"""ServiceSecrets tests — and the NEGATIVE CONTROLS are the point of this file.

A suite that only proves the happy path is worthless for a credential loader: every failure this
contract exists to prevent (infra#263's eight lost days, rm#209's 401-three-days-later, rm#334's
anonymous-200 reload endpoint) is a case where the HAPPY path kept working perfectly while the
service quietly held a revoked or absent secret. So the load-bearing tests here are:

  * absent secret            -> fail CLOSED (503, never open, never a soft 200)
  * .env beats stale process env    -> the rm#209 inversion, asserted on PROVENANCE
  * a BAD value on reload    -> applies NOTHING and does NOT blank the good credential it replaces
  * a name withdrawn upstream-> is NOT unset by default, but IS reported
  * an unreachable source    -> is distinguishable from a REJECTED one and from an empty one

⚑ NO TEST IN THIS FILE ASSERTS ON A CREDENTIAL VALUE (rm#206). Assertions are on presence,
provenance (`source_of`), change (`digest` inequality against a previously captured digest), status
code and posture. Where a test needs to prove "the file won", it proves it by SOURCE, not by value.
"""
import json
import os
import time

import pytest
from django.test import override_settings
from rest_framework.test import APIRequestFactory

from andrea_svc_web import creds as credsmod
from andrea_svc_web.config import ConfigFetch
from andrea_svc_web.creds import MissingCredential, ServiceSecrets
from andrea_svc_web.permissions import (CredentialsNotProvisioned, ServiceKeyRequired,
                                        ServiceKeyRequiredIfConfigured)
from andrea_svc_web.views import StormSafeAPIView

rf = APIRequestFactory()

# Sentinels that are obviously NOT real credentials, so a leak in a report is unmistakable in a
# failure message. They are never compared against — only their digests/sources are.
STALE = "stale-process-env-sentinel"
FRESH = "fresh-file-sentinel"


def _envfile(tmp_path, name, body):
    p = tmp_path / name
    p.write_text(body)
    return str(p)


def _mk(tmp_path, **kw):
    """A ServiceSecrets with an EXPLICIT process-env snapshot, so no host env leaks into a test."""
    kw.setdefault("env", {})
    kw.setdefault("state_path", str(tmp_path / "stamp.json"))
    return ServiceSecrets(kw.pop("service", "test-svc"), **kw)


# ══════════════════════════════════════════════════════════════════════════════════════════════
# NEGATIVE CONTROL 1 — the rm#209 inversion: .env BEATS a stale process env
# ══════════════════════════════════════════════════════════════════════════════════════════════
def test_env_file_beats_stale_process_env(tmp_path):
    """The exact infra#263 shape: the process was spawned with the OLD secret in its environment and
    the rotation landed in the file. A loader that reads env first serves a REVOKED credential for
    the life of the process."""
    f = _envfile(tmp_path, "svc.env", f"TOK={FRESH}\n")
    s = _mk(tmp_path, env_files=[f], env={"TOK": STALE})

    assert s.source_of("TOK") == f                  # the re-readable tier won — provenance, not value
    assert s.get("TOK") is not None                 # presence, never the value
    assert s.digest("TOK") == credsmod.digest_of(FRESH)
    assert s.digest("TOK") != credsmod.digest_of(STALE)


def test_process_env_is_the_last_resort_not_a_winner(tmp_path):
    """Tier 3 still ANSWERS for a name no re-readable tier carries — it is a fallback, not a loser."""
    f = _envfile(tmp_path, "svc.env", "OTHER=x\n")
    s = _mk(tmp_path, env_files=[f], env={"TOK": STALE})
    assert s.source_of("TOK") == credsmod.PROCESS_ENV
    assert s.get("TOK") is not None


def test_process_env_is_a_snapshot_and_is_never_re_read(tmp_path):
    """Tier 3 is the SPAWN snapshot. Mutating os.environ afterwards must not change what a
    long-lived process resolves — that is the whole reason tier 3 ranks last."""
    s = _mk(tmp_path, env={"TOK": STALE})
    before = s.digest("TOK")
    os.environ["TOK"] = "mutated-after-spawn"
    try:
        assert s.digest("TOK") == before
    finally:
        os.environ.pop("TOK", None)


def test_config_service_outranks_the_env_file(tmp_path, monkeypatch):
    """OPERATOR CHOICE 1 as implemented: config-service ABOVE .env. Both are re-readable; the
    central rotation surface wins, so a host file cannot silently override a fleet rotation."""
    f = _envfile(tmp_path, "svc.env", f"TOK={FRESH}\n")
    monkeypatch.setattr(credsmod, "fetch_config_result",
                        lambda *a, **k: ConfigFetch(status="ok", payload={"TOK": "from-central"}))
    s = _mk(tmp_path, env_files=[f], config_service=True, env={"TOK": STALE})
    s.load()
    assert s.source_of("TOK") == credsmod.CONFIG_SERVICE


def test_first_declared_env_file_wins(tmp_path):
    a = _envfile(tmp_path, "a.env", "TOK=from-a\n")
    b = _envfile(tmp_path, "b.env", "TOK=from-b\n")
    s = _mk(tmp_path, env_files=[a, b], env={})
    assert s.source_of("TOK") == a


def test_ignore_list_refuses_to_resolve_a_name(tmp_path):
    """task-ingest's posture, as a DECLARATION rather than a shell `unset` in an entrypoint — a
    declaration survives a future loader, an `unset` does not."""
    f = _envfile(tmp_path, "svc.env", "ANDREA_SVC_API_KEY=k\n")
    s = _mk(tmp_path, env_files=[f], ignore=["ANDREA_SVC_API_KEY"],
            env={"ANDREA_SVC_API_KEY": "also-here"})
    assert s.get("ANDREA_SVC_API_KEY") is None
    assert s.source_of("ANDREA_SVC_API_KEY") == ""
    assert s.posture == "unconfigured"


def test_ignore_can_be_declared_by_env(tmp_path):
    f = _envfile(tmp_path, "svc.env", "SEC=x\n")
    s = _mk(tmp_path, env_files=[f], env={"ANDREA_SECRETS_IGNORE": "SEC"})
    assert s.get("SEC") is None


# ══════════════════════════════════════════════════════════════════════════════════════════════
# NEGATIVE CONTROL 2 — a BAD value on reload applies NOTHING and blanks NOTHING
# ══════════════════════════════════════════════════════════════════════════════════════════════
def test_reload_with_a_rejected_value_does_not_blank_the_good_credential(tmp_path):
    """"Must NOT leave the service with no credential." The pre-0.3 loop wrote each changed key into
    os.environ as it went, so a failure halfway through left a mix of old and new that the next
    reload then diffed against a baseline that no longer matched reality."""
    f = _envfile(tmp_path, "svc.env", f"TOK={FRESH}\n")
    s = _mk(tmp_path, env_files=[f], validators={"TOK": lambda v: v.startswith("fresh")})
    s.load()
    good = s.digest("TOK")
    gen = s.generation
    assert good != ""

    (tmp_path / "svc.env").write_text("TOK=totally-invalid\n")
    report = s.reload()

    assert report.applied is False
    assert report.reason == "rejected"
    assert "TOK" in report.rejected
    assert s.generation == gen                       # generation did NOT move
    assert s.digest("TOK") == good                   # the live credential is UNTOUCHED
    assert s.get("TOK") is not None                  # and above all: still present


def test_a_raising_validator_rejects_rather_than_crashes(tmp_path):
    def boom(_v):
        raise RuntimeError("validator exploded")

    f = _envfile(tmp_path, "svc.env", "TOK=a\n")
    s = _mk(tmp_path, env_files=[f], validators={"TOK": boom})
    s.load()
    before = s.digest("TOK")
    (tmp_path / "svc.env").write_text("TOK=b\n")
    report = s.reload()
    assert report.applied is False and "TOK" in report.rejected
    assert s.digest("TOK") == before


def test_a_reload_that_would_strip_a_required_credential_is_rejected(tmp_path):
    """All-or-nothing: not 'apply the good ones and skip the bad'. A partial apply is the state that
    cannot be rolled back. Here the withdrawal carry-forward is DISABLED, so the candidate really
    would remove a declared-required name — and the whole reload is refused."""
    f = _envfile(tmp_path, "svc.env", "TOK=a\nOTHER=1\n")
    s = _mk(tmp_path, env_files=[f], required={"TOK": "the service token"},
            env={"ANDREA_CREDS_HONOR_WITHDRAWAL": "1"})
    s.load()
    before = s.digest("TOK")
    (tmp_path / "svc.env").write_text("OTHER=2\n")            # TOK vanished AND OTHER changed
    report = s.reload()
    assert report.applied is False and report.reason == "rejected"
    assert "TOK" in report.rejected
    assert s.digest("TOK") == before                 # nothing blanked, not even the unrelated one
    assert s.digest("OTHER") == credsmod.digest_of("1")


def test_withdrawal_carry_forward_means_a_required_name_is_never_stripped(tmp_path):
    """DEFAULT posture (ANDREA_CREDS_HONOR_WITHDRAWAL off): the vanished name is carried forward, so
    there is nothing to reject — the unrelated change applies and the credential survives."""
    f = _envfile(tmp_path, "svc.env", "TOK=a\nOTHER=1\n")
    s = _mk(tmp_path, env_files=[f], required={"TOK": "the service token"})
    s.load()
    before = s.digest("TOK")
    (tmp_path / "svc.env").write_text("OTHER=2\n")
    report = s.reload()
    assert report.applied is True
    assert "TOK" in report.withdrawn                 # visible
    assert s.digest("TOK") == before                 # and NOT blanked
    assert s.digest("OTHER") == credsmod.digest_of("2")


def test_an_already_missing_required_credential_does_not_freeze_the_service(tmp_path):
    """⚑ The bootstrap case (§4c). A service degraded by ONE missing credential must still be able
    to apply the key drop that fixes it — a reload that refuses everything until the missing name
    appears would block the very mechanism that would make it appear."""
    f = _envfile(tmp_path, "svc.env", "OTHER=1\n")
    s = _mk(tmp_path, env_files=[f], required={"NEVER_PROVISIONED": "some purpose"})
    s.load()
    (tmp_path / "svc.env").write_text("OTHER=2\nANDREA_SVC_API_KEY=bootstrapped\n")
    report = s.reload()
    assert report.applied is True                    # progress is NOT blocked
    assert report.missing == ["NEVER_PROVISIONED"]   # but the gap is on the same line
    assert s.source_of("ANDREA_SVC_API_KEY") == f    # the key landed


def test_reload_applies_a_good_rotation_and_bumps_the_generation(tmp_path):
    f = _envfile(tmp_path, "svc.env", f"TOK={FRESH}\n")
    s = _mk(tmp_path, env_files=[f])
    s.load()
    before, gen = s.digest("TOK"), s.generation
    (tmp_path / "svc.env").write_text("TOK=rotated-sentinel\n")
    report = s.reload()
    assert report.applied is True and report.reason == "applied"
    assert report.changed["other"] == ["TOK"]        # "TOK" matches no secret-ish word -> other
    assert report.changed["db"] == []                # ...and above all NOT db: no reconnect
    assert s.generation == gen + 1
    assert s.digest("TOK") != before and s.digest("TOK") != ""


# ══════════════════════════════════════════════════════════════════════════════════════════════
# NEGATIVE CONTROL 3 — withdrawal is NOT a silent unset
# ══════════════════════════════════════════════════════════════════════════════════════════════
def test_withdrawn_name_is_not_unset_by_default_but_is_reported(tmp_path):
    f = _envfile(tmp_path, "svc.env", "TOK=a\nOTHER=1\n")
    s = _mk(tmp_path, env_files=[f])
    s.load()
    before = s.digest("TOK")
    (tmp_path / "svc.env").write_text("OTHER=2\n")
    report = s.reload()
    assert "TOK" in report.withdrawn                 # VISIBLE
    assert s.get("TOK") is not None                  # but NOT stripped
    assert s.digest("TOK") == before


def test_honor_withdrawal_opt_in_really_removes(tmp_path):
    f = _envfile(tmp_path, "svc.env", "TOK=a\nOTHER=1\n")
    s = _mk(tmp_path, env_files=[f], env={"ANDREA_CREDS_HONOR_WITHDRAWAL": "1"})
    s.load()
    (tmp_path / "svc.env").write_text("OTHER=2\n")
    report = s.reload()
    assert "TOK" in report.withdrawn
    assert s.get("TOK") is None


def test_a_withdrawal_alone_is_a_change_when_honoured(tmp_path):
    """Negative control for the test above: with NOTHING else changing, the opt-in must still take
    effect. A removal that scores zero changed names would be reported as 'no-change' and skipped —
    the flag would silently do nothing.

    ⚑ The removal is spelled by REWRITING the file without TOK, not by TRUNCATING it to zero bytes.
    That was the original spelling and it was the D3 fail-open in test form: an empty read is not
    evidence of removal (rules/an-empty-read-is-not-evidence-of-absence), so a truncation race
    blanked live credentials — see tests/test_failopen_controls.py. OTHER is unchanged here, so
    the only CHANGE is still the withdrawal, which is what this test is about."""
    seen = []
    f = _envfile(tmp_path, "svc.env", "TOK=a\nOTHER=1\n")
    s = _mk(tmp_path, env_files=[f], env={"ANDREA_CREDS_HONOR_WITHDRAWAL": "1"})
    s.load()
    s.subscribe("TOK", lambda n, old, new: seen.append((n, old, new)))
    (tmp_path / "svc.env").write_text("OTHER=1\n")        # the ONLY change is the removal of TOK
    report = s.reload()
    assert report.applied is True and report.reason == "applied"
    assert report.withdrawn == ["TOK"]
    assert s.get("TOK") is None
    assert seen and seen[0][2] == ""                      # subscriber saw new_digest == "" (absent)


# ══════════════════════════════════════════════════════════════════════════════════════════════
# NEGATIVE CONTROL 4 — unreachable ≠ rejected ≠ empty
# ══════════════════════════════════════════════════════════════════════════════════════════════
def test_config_service_failure_modes_are_distinguishable(tmp_path, monkeypatch):
    for status_, expect_reason in (("unreachable", "source-unreachable"),
                                   ("rejected", "source-rejected-our-key"),
                                   ("ok", "no-change")):
        monkeypatch.setattr(credsmod, "fetch_config_result",
                            lambda *a, _s=status_, **k: ConfigFetch(status=_s, payload={},
                                                                    detail="HTTP 401"))
        s = _mk(tmp_path, config_service=True)
        s.load()
        report = s.reload()
        assert report.applied is False
        assert report.reason == expect_reason, f"{status_} collapsed to {report.reason}"
        assert report.tiers[credsmod.CONFIG_SERVICE] == status_


def test_config_service_is_not_consulted_when_not_opted_in(tmp_path, monkeypatch):
    """Pre-0.3 reload_config() fetched unconditionally, so a service that deliberately did NOT
    enable the overlay still had its env overwritten by a POST."""
    called = {"n": 0}

    def _spy(*a, **k):
        called["n"] += 1
        return ConfigFetch(status="ok", payload={"TOK": "central"})

    monkeypatch.setattr(credsmod, "fetch_config_result", _spy)
    s = _mk(tmp_path, config_service=False)
    s.load()
    report = s.reload()
    assert called["n"] == 0
    assert report.tiers[credsmod.CONFIG_SERVICE] == "off"


def test_config_service_outage_does_not_wipe_the_live_set(tmp_path, monkeypatch):
    payload = {"TOK": "central"}
    monkeypatch.setattr(credsmod, "fetch_config_result",
                        lambda *a, **k: ConfigFetch(status="ok", payload=payload))
    s = _mk(tmp_path, config_service=True)
    s.load()
    before = s.digest("TOK")
    assert before != ""
    monkeypatch.setattr(credsmod, "fetch_config_result",
                        lambda *a, **k: ConfigFetch(status="unreachable", detail="URLError"))
    report = s.reload()
    assert report.applied is False
    assert s.digest("TOK") == before                 # a source outage is NOT "everything changed"


# ══════════════════════════════════════════════════════════════════════════════════════════════
# NEGATIVE CONTROL 5 — absent DECLARED-REQUIRED secret fails CLOSED
# ══════════════════════════════════════════════════════════════════════════════════════════════
def test_require_raises_missing_credential_when_absent(tmp_path):
    s = _mk(tmp_path, required={"DOCSTORE_TOKEN": "docstore auth"})
    assert s.missing == ["DOCSTORE_TOKEN"]
    with pytest.raises(MissingCredential) as ei:
        s.require("DOCSTORE_TOKEN")
    assert ei.value.name == "DOCSTORE_TOKEN"
    assert ei.value.purpose == "docstore auth"


def test_require_does_not_return_an_empty_string_as_a_credential(tmp_path):
    """An empty value is ABSENT, not 'a credential that happens to be blank'. Returning "" here is
    how a service ends up sending an empty header and getting a 401 it cannot explain."""
    f = _envfile(tmp_path, "svc.env", "TOK=\n")
    s = _mk(tmp_path, env_files=[f], required={"TOK": "svc"})
    with pytest.raises(MissingCredential):
        s.require("TOK")


def test_strict_mode_aborts_load_instead_of_starting_degraded(tmp_path):
    with pytest.raises(MissingCredential):
        _mk(tmp_path, required={"TOK": "svc"}, env={"ANDREA_SECRETS_STRICT": "1"}).load()


def test_default_is_start_and_503_not_refuse_to_start(tmp_path):
    """The DEFAULT (no STRICT): the service starts. Under Restart=always a hard exit is a
    crash-loop, and a crash-loop destroys the observability this contract is building."""
    s = _mk(tmp_path, required={"TOK": "svc"})
    report = s.load()
    assert report.missing == ["TOK"]
    assert s.posture in ("unconfigured", "degraded")


class _NeedsCredential(StormSafeAPIView):
    authentication_classes: list = []
    permission_classes: list = []

    def get(self, request):
        raise MissingCredential("DOCSTORE_TOKEN", "docstore auth")


def test_missing_credential_renders_503_not_a_soft_200():
    """StormSafeAPIView softens unexpected errors to 200 so a caller does not retry-storm (#250).
    A never-provisioned credential must NOT be softened — hiding it behind an ok-looking body is the
    silence that cost infra#263 eight days."""
    resp = _NeedsCredential.as_view()(rf.get("/x"))
    assert resp.status_code == 503
    assert resp.data["credential"] == "DOCSTORE_TOKEN"
    assert resp.data["purpose"] == "docstore auth"
    assert resp.data["detail"] == "credential not provisioned"


# ══════════════════════════════════════════════════════════════════════════════════════════════
# NEGATIVE CONTROL 6 — the control plane is fail-CLOSED (rm#334)
# ══════════════════════════════════════════════════════════════════════════════════════════════
def test_service_key_required_is_503_when_unconfigured_never_open():
    """The defect this closes: `if not configured: return True`. word-detect answered
    `POST /admin/reload-config -> 200 ANONYMOUS` because of it."""
    perm = ServiceKeyRequired()
    req = rf.post("/x")
    req.user = None
    with override_settings(ANDREA_SVC_KEYS=None, OIDC_ISSUER="", OIDC_JWKS_URL=""):
        with pytest.raises(CredentialsNotProvisioned) as ei:
            perm.has_permission(req, None)
    assert ei.value.status_code == 503
    assert ei.value.detail["posture"] == "unconfigured"


def test_service_key_required_denies_when_configured_and_uncredentialed():
    perm = ServiceKeyRequired()
    req = rf.post("/x")
    req.user = None
    with override_settings(ANDREA_SVC_KEYS="k"):
        assert perm.has_permission(req, None) is False          # -> DRF 401


def test_service_key_required_is_not_openable_by_dev_open_mode():
    """An escape hatch that can re-open a credential-MUTATING endpoint is the same defect wearing
    a flag."""
    perm = ServiceKeyRequired()
    req = rf.post("/x")
    req.user = None
    with override_settings(ANDREA_SVC_KEYS=None, OIDC_ISSUER="", OIDC_JWKS_URL="",
                           ANDREA_AUTH_MODE="dev-open"):
        with pytest.raises(CredentialsNotProvisioned):
            perm.has_permission(req, None)


def test_auth_mode_auto_is_byte_identical_to_the_pre_0_3_decision():
    perm = ServiceKeyRequiredIfConfigured()
    req = rf.post("/x")
    req.user = None
    with override_settings(ANDREA_SVC_KEYS=None, OIDC_ISSUER="", OIDC_JWKS_URL="",
                           ANDREA_AUTH_MODE="auto"):
        assert perm.has_permission(req, None) is True           # unchanged: dev/unconfigured => open


def test_auth_mode_enforce_closes_the_data_plane_too():
    perm = ServiceKeyRequiredIfConfigured()
    req = rf.post("/x")
    req.user = None
    with override_settings(ANDREA_SVC_KEYS=None, OIDC_ISSUER="", OIDC_JWKS_URL="",
                           ANDREA_AUTH_MODE="enforce"):
        with pytest.raises(CredentialsNotProvisioned):
            perm.has_permission(req, None)


def test_an_unknown_auth_mode_falls_back_to_auto_not_to_a_500():
    perm = ServiceKeyRequiredIfConfigured()
    req = rf.post("/x")
    req.user = None
    with override_settings(ANDREA_SVC_KEYS=None, OIDC_ISSUER="", OIDC_JWKS_URL="",
                           ANDREA_AUTH_MODE="enfroce"):        # a typo in a unit file
        assert perm.has_permission(req, None) is True


# ══════════════════════════════════════════════════════════════════════════════════════════════
# NEGATIVE CONTROL 7 — no VALUE ever escapes (rm#206)
# ══════════════════════════════════════════════════════════════════════════════════════════════
def test_no_report_or_status_ever_carries_a_value(tmp_path):
    secret = "SUPER-SECRET-VALUE-DO-NOT-LEAK"
    f = _envfile(tmp_path, "svc.env", f"DB_PASSWORD={secret}\nTOK=x\n")
    s = _mk(tmp_path, env_files=[f], required={"TOK": "svc"})
    load = s.load()
    (tmp_path / "svc.env").write_text(f"DB_PASSWORD={secret}-rotated\nTOK=x\n")
    report = s.reload()

    for blob in (repr(load.as_dict()), repr(report.as_dict()), repr(s.status()),
                 json.dumps(s.status()), repr(load), repr(report)):
        assert secret not in blob
    # ...and the NAMES and provenance ARE there, which is the whole point of the trade
    assert "DB_PASSWORD" in repr(report.as_dict())
    assert s.status()["creds"]["DB_PASSWORD"]["source"] == f
    assert len(s.status()["creds"]["DB_PASSWORD"]["digest"]) == 16


def test_the_generation_stamp_holds_digests_only(tmp_path):
    secret = "STAMP-SECRET-VALUE"
    f = _envfile(tmp_path, "svc.env", f"TOK={secret}\n")
    s = _mk(tmp_path, env_files=[f])
    s.load()
    (tmp_path / "svc.env").write_text(f"TOK={secret}-2\n")
    assert s.reload().applied is True
    raw = (tmp_path / "stamp.json").read_text()
    assert secret not in raw
    assert json.loads(raw)["digests"]["TOK"] == credsmod.digest_of(secret + "-2")
    assert oct(os.stat(tmp_path / "stamp.json").st_mode)[-3:] == "600"


def test_missing_credential_exception_message_carries_no_value():
    exc = MissingCredential("TOK", "some purpose")
    assert "TOK" in str(exc) and "some purpose" in str(exc)


# ══════════════════════════════════════════════════════════════════════════════════════════════
# The MECHANISM: lazy re-resolution + multi-worker convergence
# ══════════════════════════════════════════════════════════════════════════════════════════════
def test_a_rotation_in_the_file_goes_live_on_the_next_read_with_no_reload_call(tmp_path):
    """This is the mechanism. No POST, no signal, no orchestration — the next request sees it, in
    EVERY worker. It is also why the bootstrap chicken-and-egg dissolves: a service with no key can
    be given one without authenticating the endpoint that would authorize the giving."""
    f = _envfile(tmp_path, "svc.env", "TOK=one\n")
    s = _mk(tmp_path, env_files=[f])
    s.load()
    before = s.digest("TOK")
    time.sleep(0.01)
    (tmp_path / "svc.env").write_text("TOK=two\n")
    assert s.digest("TOK") != before                 # no reload() was called
    assert s.source_of("TOK") == f


def test_a_sibling_worker_converges_through_the_generation_stamp(tmp_path):
    """gunicorn runs 2..8 worker PROCESSES; a reload POST reaches exactly one of them. Simulated
    with two ServiceSecrets sharing a state_path, which is precisely what two workers are."""
    f = _envfile(tmp_path, "svc.env", "TOK=one\n")
    stamp = str(tmp_path / "stamp.json")
    w1 = ServiceSecrets("svc", env_files=[f], env={}, state_path=stamp)
    w2 = ServiceSecrets("svc", env_files=[f], env={}, state_path=stamp)
    w1.load()
    w2.load()
    w2_before = w2.digest("TOK")

    (tmp_path / "svc.env").write_text("TOK=two\n")
    assert w1.reload().applied is True                # only worker 1 serviced the POST

    assert w2.digest("TOK") != w2_before              # worker 2 converged on its next read
    assert w2.digest("TOK") == w1.digest("TOK")
    assert w2.generation == w1.generation


def test_stamp_writes_are_fail_soft_when_the_directory_is_unwritable(tmp_path):
    """OPERATOR CHOICE 3 fallback: no RuntimeDirectory= yet means no stamp. The passive mtime path
    still carries the convergence, so a reload must NOT fail because /run is not writable."""
    f = _envfile(tmp_path, "svc.env", "TOK=one\n")
    s = ServiceSecrets("svc", env_files=[f], env={},
                       state_path="/proc/definitely-not-writable/x.json")
    s.load()
    (tmp_path / "svc.env").write_text("TOK=two\n")
    assert s.reload().applied is True


def test_stamp_can_be_disabled_entirely(tmp_path):
    f = _envfile(tmp_path, "svc.env", "TOK=one\n")
    s = _mk(tmp_path, env_files=[f], env={"ANDREA_CREDS_STAMP": "0"})
    s.load()
    (tmp_path / "svc.env").write_text("TOK=two\n")
    assert s.reload().applied is True
    assert not (tmp_path / "stamp.json").exists()


# ══════════════════════════════════════════════════════════════════════════════════════════════
# Change notification
# ══════════════════════════════════════════════════════════════════════════════════════════════
def test_subscribe_fires_with_digests_never_values(tmp_path):
    seen = []
    f = _envfile(tmp_path, "svc.env", "TOK=one\n")
    s = _mk(tmp_path, env_files=[f])
    s.load()
    s.subscribe("TOK", lambda name, old, new: seen.append((name, old, new)))
    (tmp_path / "svc.env").write_text("TOK=two\n")
    s.reload()
    assert len(seen) == 1
    name, old, new = seen[0]
    assert name == "TOK" and old != new
    assert old == credsmod.digest_of("one") and new == credsmod.digest_of("two")
    assert "one" not in repr(seen) and "two" not in repr(seen)


def test_on_change_fires_once_per_applied_generation(tmp_path):
    """memory_server clears its lru_cache here; uuid_service rebuilds its DBUtils pool."""
    seen = []
    f = _envfile(tmp_path, "svc.env", "TOK=one\n")
    s = _mk(tmp_path, env_files=[f])
    s.load()
    s.on_change(seen.append)
    (tmp_path / "svc.env").write_text("TOK=two\n")
    s.reload()
    s.reload()                                        # no-change: must NOT fire again
    assert len(seen) == 1 and seen[0].applied is True


def test_a_broken_hook_is_reported_and_never_fatal(tmp_path):
    def boom(_r):
        raise RuntimeError("hook exploded")

    f = _envfile(tmp_path, "svc.env", "TOK=one\n")
    s = _mk(tmp_path, env_files=[f])
    s.load()
    s.on_change(boom)
    (tmp_path / "svc.env").write_text("TOK=two\n")
    report = s.reload()
    assert report.applied is True                     # the SWAP already happened; the hook failed after
    assert report.hook_errors and "hook exploded" in repr(report.hook_errors)


def test_a_callback_may_read_get_without_recursing(tmp_path):
    f = _envfile(tmp_path, "svc.env", "TOK=one\n")
    s = _mk(tmp_path, env_files=[f])
    s.load()
    s.on_change(lambda _r: s.get("TOK"))
    (tmp_path / "svc.env").write_text("TOK=two\n")
    assert s.reload().applied is True


# ══════════════════════════════════════════════════════════════════════════════════════════════
# Posture / observability
# ══════════════════════════════════════════════════════════════════════════════════════════════
def test_posture_unconfigured_for_a_service_with_no_auth_material(tmp_path):
    s = _mk(tmp_path)
    with override_settings(ANDREA_SVC_KEYS=None, OIDC_ISSUER="", OIDC_JWKS_URL=""):
        assert s.posture == "unconfigured"
        assert s.status()["counters"]["creds_unconfigured"] == 1


def test_posture_enforcing_when_a_key_is_present(tmp_path):
    f = _envfile(tmp_path, "svc.env", "ANDREA_SVC_API_KEY=k\n")
    s = _mk(tmp_path, env_files=[f])
    s.load()
    assert s.posture == "enforcing" and s.auth_kind == "keys"


def test_posture_degraded_when_the_source_rejected_our_key(tmp_path, monkeypatch):
    monkeypatch.setattr(credsmod, "fetch_config_result",
                        lambda *a, **k: ConfigFetch(status="rejected", detail="HTTP 401"))
    s = _mk(tmp_path, config_service=True, env={"ANDREA_SVC_API_KEY": "k"})
    s.load()
    assert s.posture == "degraded"


def test_sources_confess_when_the_only_tier_is_the_frozen_one(tmp_path):
    """THE ONE HONEST LIMITATION, made countable: a container with no mounted env file and no
    config-service entry CANNOT be rotated by this contract. It says so, at every start."""
    s = _mk(tmp_path, env={"ANDREA_SVC_API_KEY": "k"})
    assert s.sources() == [credsmod.PROCESS_ENV]
    assert s.rotatable is False
    line = s.announce()
    assert "sources=[process-env]" in line and "rotatable=0" in line


def test_announce_names_the_service_and_the_posture(tmp_path):
    f = _envfile(tmp_path, "svc.env", "ANDREA_SVC_API_KEY=k\n")
    s = ServiceSecrets("word-detect", env_files=[f], env={}, state_path=str(tmp_path / "s.json"))
    s.load()
    line = s.announce()
    assert "svc=word-detect" in line and "posture=enforcing" in line and "rotatable=1" in line
    assert s.announce() == ""                         # idempotent: exactly one line per start


def test_rebind_service_adopts_a_late_service_name(tmp_path):
    """A service that overrides SERVICE_NAME AFTER the star-import would otherwise be stamped as
    'andrea-service' — the same mis-tag LOGGING_CONFIG exists to fix."""
    s = ServiceSecrets("andrea-service", env={})
    assert s.rebind_service("word-detect") is True
    assert s.service == "word-detect"
    assert s._state_path.endswith("/word-detect.json")
    assert s.rebind_service("word-detect") is False


def test_status_lists_only_the_credentials_this_service_manages(tmp_path):
    """Dumping every process-env NAME would publish the shape of the whole environment for no
    diagnostic gain."""
    f = _envfile(tmp_path, "svc.env", "TOK=x\n")
    s = _mk(tmp_path, env_files=[f], env={"PATH": "/usr/bin", "HOME": "/root"})
    s.load()
    assert set(s.status()["creds"]) == {"TOK"}


# ══════════════════════════════════════════════════════════════════════════════════════════════
# Parsing + misc
# ══════════════════════════════════════════════════════════════════════════════════════════════
def test_env_file_parser_handles_export_comments_and_quotes(tmp_path):
    f = _envfile(tmp_path, "svc.env",
                 '# a comment\n\nexport A=1\nB="two"\nC=\'three\'\nnot a pair\n')
    vals, present = credsmod.parse_env_file(f)
    assert present is True
    assert set(vals) == {"A", "B", "C"}
    assert vals["B"] == "two" and vals["C"] == "three"


def test_absent_file_is_reported_absent_not_empty(tmp_path):
    """An empty read is not evidence of absence, and an absent file is not an empty one."""
    missing = str(tmp_path / "nope.env")
    vals, present = credsmod.parse_env_file(missing)
    assert vals == {} and present is False
    s = _mk(tmp_path, env_files=[missing])
    assert s.load().tiers[missing] == "absent"


def test_classify_is_unchanged_from_the_proven_definition():
    assert credsmod.classify("DB_PASSWORD") == "db"     # db beats auth -> triggers a reconnect
    assert credsmod.classify("CONFIG_DB_HOST") == "db"
    assert credsmod.classify("DATABASE_URL") == "db"
    assert credsmod.classify("ANDREA_SVC_API_KEY") == "auth"
    assert credsmod.classify("POLL_INTERVAL") == "other"


def test_digest_is_stable_short_and_empty_for_absent():
    d = credsmod.digest_of("x")
    assert len(d) == 16 and d == credsmod.digest_of("x")
    assert credsmod.digest_of("") == "" and credsmod.digest_of(None) == ""


def test_declared_env_files_can_come_from_the_unit(tmp_path):
    """ANDREA_SECRETS_ENV_FILES is how waves 2-4 land with a unit edit and no code change."""
    f = _envfile(tmp_path, "svc.env", f"TOK={FRESH}\n")
    s = _mk(tmp_path, env={"ANDREA_SECRETS_ENV_FILES": f, "TOK": STALE})
    assert s.source_of("TOK") == f


def test_ttl_is_off_by_default_and_never_fetches_on_the_read_path(tmp_path, monkeypatch):
    calls = {"n": 0}

    def _spy(*a, **k):
        calls["n"] += 1
        return ConfigFetch(status="ok", payload={"TOK": "central"})

    monkeypatch.setattr(credsmod, "fetch_config_result", _spy)
    s = _mk(tmp_path, config_service=True)
    s.load()
    assert calls["n"] == 1
    for _ in range(5):
        s.get("TOK")
    assert calls["n"] == 1                             # tier 1 is NEVER on the read path by default


def test_ttl_when_enabled_refreshes_tier_one_on_the_read_path(tmp_path, monkeypatch):
    calls = {"n": 0}

    def _spy(*a, **k):
        calls["n"] += 1
        return ConfigFetch(status="ok", payload={"TOK": f"central-{calls['n']}"})

    monkeypatch.setattr(credsmod, "fetch_config_result", _spy)
    s = _mk(tmp_path, config_service=True, env={"ANDREA_CREDS_TTL": "0.01"})
    s.load()
    before = s.digest("TOK")
    time.sleep(0.02)
    assert s.digest("TOK") != before
    assert calls["n"] >= 2


def test_load_is_safe_without_django_settings_configured(tmp_path):
    """ServiceSecrets is constructed from INSIDE settings.py, while that module is still executing.
    Touching django.conf.settings there would re-enter the settings import."""
    s = _mk(tmp_path, env={"ANDREA_SVC_API_KEY": "k"})
    assert s.load().posture in ("enforcing", "degraded", "unconfigured")


# ══════════════════════════════════════════════════════════════════════════════════════════════
# VALIDATE phase (c) — the throwaway-connection DB probe
# ══════════════════════════════════════════════════════════════════════════════════════════════
#: an engine whose driver is NOT installed here, so ensure_connection() always fails. That is
#: exactly the shape of a bad DB credential from the probe's point of view, and it lets the test
#: exercise the reject path with no live database anywhere near it.
_UNREACHABLE_DB = {"ENGINE": "django.db.backends.postgresql", "NAME": "x", "USER": "u",
                   "PASSWORD": "old", "HOST": "127.0.0.1", "PORT": "5432"}


def test_a_db_credential_that_fails_its_probe_never_reaches_settings_DATABASES(tmp_path):
    """⚑ This is the DB-rotation validation rm#129 deferred as too risky to exercise unsupervised.
    A bad DB credential is caught BEFORE settings.DATABASES is touched and BEFORE connections are
    dropped — which is what makes a supervised live rotation safe to run."""
    f = _envfile(tmp_path, "svc.env", "DB_PASSWORD=old\n")
    s = _mk(tmp_path, env_files=[f])
    s.load()
    before = s.digest("DB_PASSWORD")
    dbs = {"default": dict(_UNREACHABLE_DB)}
    (tmp_path / "svc.env").write_text("DB_PASSWORD=rotated\n")
    with override_settings(DATABASES=dbs):
        report = s.reload()
    assert report.applied is False and report.reason == "rejected"
    assert "DB_PASSWORD" in report.rejected
    assert "probe failed" in report.rejected["DB_PASSWORD"]
    assert "rotated" not in repr(report.as_dict())        # the reason names a CLASS, never a value
    assert dbs["default"]["PASSWORD"] == "old"            # settings.DATABASES was NOT re-pointed
    assert s.digest("DB_PASSWORD") == before              # and the live credential is untouched


def test_the_db_probe_can_be_disabled_per_service(tmp_path):
    """Positive control for the test above: with the probe off, the SAME rotation applies. So the
    rejection really was the probe, not some unrelated refusal."""
    f = _envfile(tmp_path, "svc.env", "DB_PASSWORD=old\n")
    s = _mk(tmp_path, env_files=[f], env={"ANDREA_CREDS_DB_PROBE": "0"})
    s.load()
    before = s.digest("DB_PASSWORD")
    dbs = {"default": dict(_UNREACHABLE_DB)}
    (tmp_path / "svc.env").write_text("DB_PASSWORD=rotated\n")
    with override_settings(DATABASES=dbs):
        report = s.reload()
    assert report.applied is True
    assert report.changed["db"] == ["DB_PASSWORD"]        # db, not auth -> it triggers a reconnect
    assert report.reconnected == ["default"]
    assert s.digest("DB_PASSWORD") != before


def test_a_service_with_no_orm_is_not_probed(tmp_path):
    """DATABASES is intentionally empty for most basement services. Nothing to probe is not a
    failure — it must not reject the reload."""
    f = _envfile(tmp_path, "svc.env", "DB_PASSWORD=old\n")
    s = _mk(tmp_path, env_files=[f])
    s.load()
    (tmp_path / "svc.env").write_text("DB_PASSWORD=rotated\n")
    with override_settings(DATABASES={}):
        report = s.reload()
    assert report.applied is True and report.reconnected == []


def test_a_non_db_rotation_never_opens_a_probe_connection(tmp_path):
    """A key rotation must NOT bounce the DB — the pre-0.3 contract, preserved."""
    f = _envfile(tmp_path, "svc.env", "ANDREA_SVC_API_KEY=one\n")
    s = _mk(tmp_path, env_files=[f])
    s.load()
    (tmp_path / "svc.env").write_text("ANDREA_SVC_API_KEY=two\n")
    with override_settings(DATABASES={"default": dict(_UNREACHABLE_DB)}):
        report = s.reload()
    assert report.applied is True                          # the unreachable DB was never touched
    assert report.changed["auth"] == ["ANDREA_SVC_API_KEY"]
    assert report.reconnected == []


# ══════════════════════════════════════════════════════════════════════════════════════════════
# Read-path COST and multi-worker tier-1 convergence
# ══════════════════════════════════════════════════════════════════════════════════════════════
def test_a_tier3_only_service_pays_nothing_on_the_read_path(tmp_path, monkeypatch):
    """A container with no mounted env file and no config-service entry CANNOT be rotated, so
    watching for changes would be pure cost on every request."""
    stats = {"n": 0}
    real_stat = os.stat

    def counting_stat(*a, **k):
        stats["n"] += 1
        return real_stat(*a, **k)

    s = _mk(tmp_path, env={"ANDREA_SVC_API_KEY": "k"})
    s.load()
    monkeypatch.setattr(credsmod.os, "stat", counting_stat)
    for _ in range(20):
        s.get("ANDREA_SVC_API_KEY")
    assert stats["n"] == 0


def test_a_declared_file_costs_exactly_one_stat_pair_per_read(tmp_path, monkeypatch):
    f = _envfile(tmp_path, "svc.env", "TOK=one\n")
    s = _mk(tmp_path, env_files=[f])
    s.load()
    stats = {"n": 0}
    real_stat = os.stat

    def counting_stat(*a, **k):
        stats["n"] += 1
        return real_stat(*a, **k)

    monkeypatch.setattr(credsmod.os, "stat", counting_stat)
    s.get("TOK")
    assert stats["n"] == 2                       # one env file + the generation stamp


def test_a_sibling_worker_refetches_tier_one_when_the_generation_advances(tmp_path, monkeypatch):
    """⚑ Without the tier-1 re-fetch on a stamp advance, a config-service rotation would converge
    for tiers 2-3 in every other worker — i.e. NOT for the values that actually rotated."""
    payload = {"TOK": "central-v1"}
    monkeypatch.setattr(credsmod, "fetch_config_result",
                        lambda *a, **k: ConfigFetch(status="ok", payload=dict(payload)))
    stamp = str(tmp_path / "stamp.json")
    w1 = ServiceSecrets("svc", config_service=True, env={}, state_path=stamp)
    w2 = ServiceSecrets("svc", config_service=True, env={}, state_path=stamp)
    w1.load()
    w2.load()
    w2_before = w2.digest("TOK")

    payload["TOK"] = "central-v2"                 # the central rotation
    assert w1.reload().applied is True            # only worker 1 serviced the POST

    assert w2.digest("TOK") != w2_before          # worker 2 converged...
    assert w2.digest("TOK") == w1.digest("TOK")   # ...on the SAME value
    assert w2.source_of("TOK") == credsmod.CONFIG_SERVICE
