package wgtunnel

// Group B — the manager state machine (Scenarios A-D). Non-hollow: every test asserts EXEC
// COUNTS on the recording runner (the no-exec negatives are the load-bearing ones), single
// registration, marker semantics, and the absence of key material in the captured log buffer.

import (
	"bytes"
	"context"
	"errors"
	"io"
	"log/slog"
	"net/netip"
	"os"
	"strings"
	"sync"
	"testing"
	"time"

	"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
)

// fakeResolver drives endpoint resolution deterministically + counts lookups (the "no DNS while
// healthy" negative asserts count stays flat).
type fakeResolver struct {
	mu    sync.Mutex
	addrs []netip.Addr
	err   error
	calls int
}

func (f *fakeResolver) LookupIPv4(_ context.Context, _ string) ([]netip.Addr, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.calls++
	if f.err != nil {
		return nil, f.err
	}
	return append([]netip.Addr(nil), f.addrs...), nil
}

func (f *fakeResolver) count() int { f.mu.Lock(); defer f.mu.Unlock(); return f.calls }
func (f *fakeResolver) set(a ...netip.Addr) {
	f.mu.Lock()
	f.addrs, f.err = a, nil
	f.mu.Unlock()
}
func (f *fakeResolver) fail(e error) { f.mu.Lock(); f.err = e; f.mu.Unlock() }

// recordingRunner records every privileged exec; scripted errors per command name.
type recordingRunner struct {
	mu    sync.Mutex
	calls [][]string
	fail  map[string]error // command name → error
}

func (r *recordingRunner) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) {
	r.mu.Lock()
	r.calls = append(r.calls, append([]string{name}, args...))
	err := r.fail[name]
	r.mu.Unlock()
	if err != nil {
		return nil, []byte("scripted failure"), err
	}
	if name == "wg" {
		return []byte("SERVERPUB\t1783107115\n"), nil, nil
	}
	return nil, nil, nil
}

func (r *recordingRunner) RunStdin(ctx context.Context, _ io.Reader, name string, args ...string) ([]byte, []byte, error) {
	return r.Run(ctx, name, args...)
}

func (r *recordingRunner) count(name string) int {
	r.mu.Lock()
	defer r.mu.Unlock()
	n := 0
	for _, c := range r.calls {
		if c[0] == name {
			n++
		}
	}
	return n
}

func (r *recordingRunner) total() int {
	r.mu.Lock()
	defer r.mu.Unlock()
	return len(r.calls)
}

func (r *recordingRunner) lastSystemctl() []string {
	r.mu.Lock()
	defer r.mu.Unlock()
	for i := len(r.calls) - 1; i >= 0; i-- {
		if r.calls[i][0] == "systemctl" {
			return r.calls[i]
		}
	}
	return nil
}

// fakeHub scripts RegisterWG.
type fakeHub struct {
	mu    sync.Mutex
	calls int
	resp  *hub.WGRegisterResponse
	err   error
}

func (f *fakeHub) RegisterWG(_ context.Context, pubkey string) (*hub.WGRegisterResponse, error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.calls++
	if f.err != nil {
		return nil, f.err
	}
	if f.resp != nil {
		return f.resp, nil
	}
	return &hub.WGRegisterResponse{Pubkey: pubkey, AssignedIP: "10.77.0.2/32", Generation: 3, Sync: "ok"}, nil
}

func (f *fakeHub) count() int { f.mu.Lock(); defer f.mu.Unlock(); return f.calls }

// testManager builds a Manager over temp state with all external probes faked.
// active is a pointer so tests flip service state mid-scenario.
func testManager(t *testing.T, fh *fakeHub) (*Manager, *recordingRunner, *bool, *bytes.Buffer) {
	t.Helper()
	rr := &recordingRunner{fail: map[string]error{}}
	active := false
	logBuf := &bytes.Buffer{}
	logger := slog.New(slog.NewTextHandler(logBuf, &slog.HandlerOptions{Level: slog.LevelDebug}))
	m := NewManager(rr, fh, t.TempDir(), logger)
	m.isActive = func(ctx context.Context) bool { return active }
	m.wgPresent = func() bool { return true }
	// Default resolver: one A record, no real DNS. Tests exercising the watchdog swap in their own.
	m.resolver = &fakeResolver{addrs: []netip.Addr{netip.MustParseAddr("167.233.158.164")}}
	return m, rr, &active, logBuf
}

func testBlock(pub string) *hub.WireWireguard {
	return &hub.WireWireguard{
		Endpoint: hub.WireWireguardEndpoint{
			DNSName: "ep0.felhom.eu", WGPort: 443,
			ServerPubkey: "CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=", PBSTunnelIP: "10.77.0.1",
		},
		Pubkey:     pub,
		AssignedIP: "10.77.0.2/32",
	}
}

func localPub(t *testing.T, m *Manager) string {
	t.Helper()
	pub, _, err := EnsureKey(m.stateDir)
	if err != nil {
		t.Fatal(err)
	}
	return pub
}

// TestRenderConf_Golden pins the EXACT conf bytes, including "MTU = 1280" (the IPv6-minimum
// floor, doc 06 §4.3) and the v4-pinned "Endpoint = 167.233.158.164:443" (doc 06 §4.2 — the A
// LITERAL, never the DNS name, never an AAAA). Red-proofed: flipping clientMTU back to 1420, or
// rendering the dns_name instead of the resolved literal, fails this on the mismatch — non-vacuous.
func TestRenderConf_Golden(t *testing.T) {
	block := testBlock("ignored")
	conf, err := renderConf(block, vectorPrivB64, "167.233.158.164")
	if err != nil {
		t.Fatal(err)
	}
	want := `# felhom offsite tunnel — agent-managed (S3); DO NOT EDIT
[Interface]
PrivateKey = ` + vectorPrivB64 + `
Address = 10.77.0.2/32
MTU = 1280

[Peer]
PublicKey = CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=
Endpoint = 167.233.158.164:443
AllowedIPs = 10.77.0.1/32
PersistentKeepalive = 25
`
	if conf != want {
		t.Errorf("conf mismatch:\n--- got ---\n%s\n--- want ---\n%s", conf, want)
	}
	// Refusals: bad server key, bad ip, hostile dns_name, non-v4 endpoint literal.
	bad := *block
	bad.Endpoint.ServerPubkey = "short"
	if _, err := renderConf(&bad, vectorPrivB64, "167.233.158.164"); err == nil {
		t.Error("bad server pubkey accepted")
	}
	bad = *block
	bad.AssignedIP = "10.77.0.2" // no /32
	if _, err := renderConf(&bad, vectorPrivB64, "167.233.158.164"); err == nil {
		t.Error("assigned_ip without /32 accepted")
	}
	bad = *block
	bad.Endpoint.DNSName = "evil host\ninjected"
	if _, err := renderConf(&bad, vectorPrivB64, "167.233.158.164"); err == nil {
		t.Error("hostile dns_name accepted")
	}
	// The endpoint literal must be IPv4 — a v6 (or the DNS name) is refused.
	if _, err := renderConf(block, vectorPrivB64, "2a01:4f8:1c16:7aa1::1"); err == nil {
		t.Error("IPv6 endpoint literal accepted (v4-pin violated)")
	}
	if _, err := renderConf(block, vectorPrivB64, "ep0.felhom.eu"); err == nil {
		t.Error("DNS name accepted as endpoint literal")
	}
}

// TestRenderConf_OOBPeerAppendedSortedAndStable is the [OF-1] core: when oob_peer_ip is set the
// operator /32 joins AllowedIPs, DETERMINISTICALLY SORTED, and two renders are BYTE-IDENTICAL (the
// conf-hash stability the reconcile loop depends on — trap 1). Absent → byte-identical to pre-H1.
func TestRenderConf_OOBPeerAppendedSortedAndStable(t *testing.T) {
	// operator IP that sorts AFTER the pbs IP (10.77.0.1 < 10.77.0.250).
	block := testBlock("ignored")
	block.OOBPeerIP = "10.77.0.250"
	c1, err := renderConf(block, vectorPrivB64, "167.233.158.164")
	if err != nil {
		t.Fatal(err)
	}
	if !strings.Contains(c1, "AllowedIPs = 10.77.0.1/32, 10.77.0.250/32\n") {
		t.Fatalf("OOB /32 not appended sorted:\n%s", c1)
	}
	// byte-stable across renders (no per-tick flapping).
	c2, _ := renderConf(block, vectorPrivB64, "167.233.158.164")
	if c1 != c2 {
		t.Fatal("two renders of the same block differ — conf-hash would flap every tick")
	}

	// operator IP that sorts BEFORE the pbs IP → it must come FIRST (proves the sort, not append order).
	lower := testBlock("ignored")
	lower.OOBPeerIP = "10.76.0.9"
	cl, err := renderConf(lower, vectorPrivB64, "167.233.158.164")
	if err != nil {
		t.Fatal(err)
	}
	if !strings.Contains(cl, "AllowedIPs = 10.76.0.9/32, 10.77.0.1/32\n") {
		t.Fatalf("lower OOB IP not sorted first:\n%s", cl)
	}

	// absent oob_peer_ip → byte-identical to the pre-H1 single-/32 line.
	none := testBlock("ignored")
	cn, _ := renderConf(none, vectorPrivB64, "167.233.158.164")
	if !strings.Contains(cn, "AllowedIPs = 10.77.0.1/32\n") {
		t.Fatalf("absent oob must render the single pbs /32:\n%s", cn)
	}

	// invalid oob_peer_ip is a HARD error (never a silently-dropped widening).
	bad := testBlock("ignored")
	bad.OOBPeerIP = "not-an-ip"
	if _, err := renderConf(bad, vectorPrivB64, "167.233.158.164"); err == nil {
		t.Error("invalid oob_peer_ip accepted")
	}
	bad.OOBPeerIP = "2a01:4f8::1" // v6 → refused (arc is v4-pinned)
	if _, err := renderConf(bad, vectorPrivB64, "167.233.158.164"); err == nil {
		t.Error("IPv6 oob_peer_ip accepted")
	}
}

func TestScenarioA_RegisterThenApplyOrdering(t *testing.T) {
	fh := &fakeHub{}
	m, rr, active, _ := testManager(t, fh)
	ctx := context.Background()

	// Tick 1: no desired data yet → keygen + ONE registration, NO conf/systemctl.
	m.Apply(ctx, false, nil)
	if fh.count() != 1 {
		t.Fatalf("register calls = %d, want 1", fh.count())
	}
	if rr.total() != 0 {
		t.Fatalf("privileged execs before the block arrived: %v", rr.calls)
	}
	if m.loadMarker() == nil {
		t.Fatal("marker not written after successful registration")
	}

	// Tick 2 (still nothing fetched): NO second registration (marker gate).
	m.Apply(ctx, false, nil)
	if fh.count() != 1 {
		t.Fatalf("register calls after marker = %d, want still 1 (marker gate)", fh.count())
	}

	// Block arrives → conf staged + installed + enabled.
	pub := localPub(t, m)
	m.Apply(ctx, true, testBlock(pub))
	if rr.count("install") != 1 || rr.count("systemctl") != 1 {
		t.Fatalf("apply execs = %v", rr.calls)
	}
	if got := rr.lastSystemctl(); strings.Join(got, " ") != "systemctl enable --now wg-quick@wg-felhom" {
		t.Errorf("systemctl argv = %v", got)
	}
	if _, err := os.Stat(m.stagedConfPath()); err != nil {
		t.Error("staged conf missing")
	}
	// Steady state with service now active: NO further execs.
	*active = true
	before := rr.total()
	m.Apply(ctx, true, testBlock(pub))
	if rr.total() != before {
		t.Errorf("steady-state tick ran execs: %v", rr.calls[before:])
	}
}

func TestScenarioB_ConfChangeRestartsAndSelfHeal(t *testing.T) {
	fh := &fakeHub{}
	m, rr, active, _ := testManager(t, fh)
	ctx := context.Background()
	m.Apply(ctx, false, nil) // register
	pub := localPub(t, m)
	m.Apply(ctx, true, testBlock(pub)) // first apply (enable)
	*active = true

	// Endpoint re-key: server_pubkey changes → re-render + install + RESTART (not reload/enable).
	changed := testBlock(pub)
	changed.Endpoint.ServerPubkey = "AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI="
	m.Apply(ctx, true, changed)
	if got := rr.lastSystemctl(); strings.Join(got, " ") != "systemctl restart wg-quick@wg-felhom" {
		t.Errorf("conf change systemctl = %v, want restart", got)
	}

	// Crash/manual stop with unchanged conf → self-heal enable --now.
	*active = false
	m.Apply(ctx, true, changed)
	if got := rr.lastSystemctl(); strings.Join(got, " ") != "systemctl enable --now wg-quick@wg-felhom" {
		t.Errorf("self-heal systemctl = %v, want enable --now", got)
	}
}

func TestScenarioC_RevocationDisablesAndNeverReregisters(t *testing.T) {
	fh := &fakeHub{}
	m, rr, active, _ := testManager(t, fh)
	ctx := context.Background()
	m.Apply(ctx, false, nil)
	pub := localPub(t, m)
	m.Apply(ctx, true, testBlock(pub))
	*active = true

	// The hub removes the peer: PRESENT desired-state, absent block.
	m.Apply(ctx, true, nil)
	if got := rr.lastSystemctl(); strings.Join(got, " ") != "systemctl disable --now wg-quick@wg-felhom" {
		t.Fatalf("revocation systemctl = %v, want disable --now", got)
	}
	if m.loadMarker() == nil {
		t.Fatal("marker removed on revocation — it must be KEPT (revoked stays revoked)")
	}
	*active = false

	// Multiple later ticks: NO re-register, NO install, NO enable (the load-bearing negatives).
	regBefore, execBefore := fh.count(), rr.total()
	for i := 0; i < 5; i++ {
		m.Apply(ctx, true, nil)
	}
	if fh.count() != regBefore {
		t.Errorf("re-registration after revocation: %d calls", fh.count()-regBefore)
	}
	if rr.total() != execBefore {
		t.Errorf("execs after teardown: %v", rr.calls[execBefore:])
	}

	// Operator re-adds the peer → block returns → tunnel back WITHOUT registration.
	m.Apply(ctx, true, testBlock(pub))
	if fh.count() != regBefore {
		t.Errorf("re-add triggered a registration: %d", fh.count()-regBefore)
	}
	if got := rr.lastSystemctl(); strings.Join(got, " ") != "systemctl enable --now wg-quick@wg-felhom" {
		t.Errorf("re-add systemctl = %v, want enable --now", got)
	}
}

func TestScenarioD_MismatchReregisterWithBackoff(t *testing.T) {
	fh := &fakeHub{}
	m, _, _, _ := testManager(t, fh)
	ctx := context.Background()
	now := time.Unix(1_800_000_000, 0)
	m.now = func() time.Time { return now }

	m.Apply(ctx, false, nil) // initial registration
	pub := localPub(t, m)

	// The hub serves a block for ANOTHER pubkey → exactly one re-register per backoff window.
	other := testBlock("hHwNLDdSNPNl5mCVUYejc1oPdhPRYJ06ak2MU66qWiI=")
	fh.err = errors.New("hub 500")
	m.Apply(ctx, true, other)
	if fh.count() != 2 {
		t.Fatalf("mismatch register calls = %d, want 2 (initial + one retry)", fh.count())
	}
	// Same instant: backoff gates the hot loop.
	m.Apply(ctx, true, other)
	m.Apply(ctx, true, other)
	if fh.count() != 2 {
		t.Fatalf("backoff not applied: %d calls", fh.count())
	}
	// After the backoff window: one more attempt, this time succeeding → marker updated.
	fh.err = nil
	now = now.Add(2 * time.Minute)
	m.Apply(ctx, true, other)
	if fh.count() != 3 {
		t.Fatalf("post-backoff register calls = %d, want 3", fh.count())
	}
	if mk := m.loadMarker(); mk == nil || mk.Pubkey != pub {
		t.Errorf("marker after re-key = %+v, want local pubkey %s", m.loadMarker(), pub)
	}
}

func TestAdoptLostMarkerWithKnownKey(t *testing.T) {
	fh := &fakeHub{}
	m, rr, _, _ := testManager(t, fh)
	ctx := context.Background()
	pub := localPub(t, m) // key exists, NO marker (lost state), hub already has the block

	m.Apply(ctx, true, testBlock(pub))
	if fh.count() != 0 {
		t.Fatalf("adopt path re-registered: %d calls", fh.count())
	}
	mk := m.loadMarker()
	if mk == nil || mk.Pubkey != pub || mk.AssignedIP != "10.77.0.2/32" {
		t.Fatalf("adoption marker = %+v", mk)
	}
	if rr.count("install") != 1 {
		t.Errorf("adopt did not proceed to conf apply: %v", rr.calls)
	}
}

func TestNoTeardownOnAbsentData(t *testing.T) {
	fh := &fakeHub{}
	m, rr, active, _ := testManager(t, fh)
	ctx := context.Background()
	m.Apply(ctx, false, nil)
	pub := localPub(t, m)
	m.Apply(ctx, true, testBlock(pub))
	*active = true
	before := rr.total()

	// Desired fetch stale/unavailable (fetched=false): keep last-applied state — NO teardown,
	// NO execs (red-proof (d) anchor).
	for i := 0; i < 3; i++ {
		m.Apply(ctx, false, nil)
	}
	if rr.total() != before {
		t.Errorf("absent-data tick ran execs (teardown-on-absent-data bug): %v", rr.calls[before:])
	}
}

func TestPartialStateAndCorruptKeyIdle(t *testing.T) {
	fh := &fakeHub{}
	m, rr, _, _ := testManager(t, fh)
	ctx := context.Background()

	// Marker without key file → idle, no registration, no execs, key NOT created.
	if err := m.writeMarker(marker{Pubkey: "X", AssignedIP: "10.77.0.9/32"}); err != nil {
		t.Fatal(err)
	}
	m.Apply(ctx, false, nil)
	if fh.count() != 0 || rr.total() != 0 {
		t.Fatalf("partial state acted: reg=%d execs=%d", fh.count(), rr.total())
	}
	if _, err := os.Stat(KeyFilePath(m.stateDir)); !os.IsNotExist(err) {
		t.Fatal("partial state minted a fresh key — never-guess violated")
	}

	// Corrupt key file → idle likewise.
	os.Remove(m.markerPath())
	os.MkdirAll(m.wgDir(), 0o700)
	os.WriteFile(KeyFilePath(m.stateDir), []byte("garbage"), 0o600)
	m.Apply(ctx, false, nil)
	if fh.count() != 0 || rr.total() != 0 {
		t.Fatalf("corrupt key acted: reg=%d execs=%d", fh.count(), rr.total())
	}
}

func TestStatusStanzaAndHandshakeParse(t *testing.T) {
	fh := &fakeHub{}
	m, rr, active, _ := testManager(t, fh)
	ctx := context.Background()
	m.now = func() time.Time { return time.Unix(1783107215, 0) } // 100s after the scripted handshake

	m.Apply(ctx, false, nil) // keygen + register
	*active = true
	st := m.Status(ctx)
	if st.Pubkey == "" || !st.Registered || !st.Active || st.AssignedIP != "10.77.0.2/32" {
		t.Fatalf("status = %+v", st)
	}
	if st.LastHandshakeAgeS == nil || *st.LastHandshakeAgeS != 100 {
		t.Fatalf("handshake age = %v, want 100", st.LastHandshakeAgeS)
	}
	// The ONLY wg invocation is latest-handshakes — never `dump`.
	rr.mu.Lock()
	for _, c := range rr.calls {
		if c[0] == "wg" && strings.Join(c, " ") != "wg show wg-felhom latest-handshakes" {
			t.Errorf("unexpected wg invocation: %v", c)
		}
	}
	rr.mu.Unlock()

	// Inactive service → no wg exec, nil age.
	*active = false
	wgBefore := rr.count("wg")
	st = m.Status(ctx)
	if st.Active || st.LastHandshakeAgeS != nil {
		t.Errorf("inactive status = %+v", st)
	}
	if rr.count("wg") != wgBefore {
		t.Error("handshake read ran against an inactive service")
	}
}

func TestNoKeyMaterialInLogs(t *testing.T) {
	fh := &fakeHub{}
	m, _, active, logBuf := testManager(t, fh)
	ctx := context.Background()
	m.Apply(ctx, false, nil)
	pub := localPub(t, m)
	m.Apply(ctx, true, testBlock(pub))
	*active = true
	m.Apply(ctx, true, nil) // revocation path too
	m.Status(ctx)

	raw, err := os.ReadFile(KeyFilePath(m.stateDir))
	if err != nil {
		t.Fatal(err)
	}
	privB64 := strings.TrimSpace(string(raw))
	if privB64 == "" || len(privB64) != 44 {
		t.Fatalf("unexpected key file %q", privB64)
	}
	logs := logBuf.String()
	if strings.Contains(logs, privB64) {
		t.Fatal("PRIVATE KEY found in the log buffer")
	}
	// base64 of the raw key without padding variants too (defense against partial logging)
	if trimmed := strings.TrimRight(privB64, "="); len(trimmed) > 20 && strings.Contains(logs, trimmed) {
		t.Fatal("private key material (unpadded) found in the log buffer")
	}
	if !strings.Contains(logs, pub) {
		t.Error("pubkey absent from logs — expected (pubkeys are fine to log)")
	}
}

// --- S4: v4-pin + re-resolve watchdog (doc 06 §4.2) ---

// TestV4Pin_LowestARecordInConf: Apply renders the DETERMINISTIC-lowest A record as the Endpoint
// literal (never the DNS name), resolves exactly once, and a steady-state re-Apply hits the cache
// (no second DNS call — the "no DNS while healthy" negative at the Apply level).
func TestV4Pin_LowestARecordInConf(t *testing.T) {
	fh := &fakeHub{}
	m, _, active, _ := testManager(t, fh)
	fr := &fakeResolver{}
	fr.set(netip.MustParseAddr("167.233.158.164"), netip.MustParseAddr("10.0.0.5")) // lowest = 10.0.0.5
	m.resolver = fr
	ctx := context.Background()

	m.Apply(ctx, false, nil)
	pub := localPub(t, m)
	m.Apply(ctx, true, testBlock(pub))
	*active = true

	staged, err := os.ReadFile(m.stagedConfPath())
	if err != nil {
		t.Fatal(err)
	}
	if !strings.Contains(string(staged), "Endpoint = 10.0.0.5:443") {
		t.Errorf("conf did not pin the lowest A record:\n%s", staged)
	}
	if strings.Contains(string(staged), "ep0.felhom.eu") {
		t.Error("DNS name leaked into the conf (v4-pin violated)")
	}
	m.Apply(ctx, true, testBlock(pub)) // steady state
	if fr.count() != 1 {
		t.Errorf("resolver calls = %d, want exactly 1 (cached after first apply — no DNS while healthy)", fr.count())
	}
}

// establishWG brings a manager to applied+active over the given resolver; returns the local pubkey.
func establishWG(t *testing.T, m *Manager, active *bool) string {
	t.Helper()
	ctx := context.Background()
	m.Apply(ctx, false, nil)
	pub := localPub(t, m)
	m.Apply(ctx, true, testBlock(pub))
	*active = true
	return pub
}

func TestWatchdog_HealthyNoReResolve(t *testing.T) {
	fh := &fakeHub{}
	m, rr, active, _ := testManager(t, fh)
	fr := &fakeResolver{}
	fr.set(netip.MustParseAddr("167.233.158.164"))
	m.resolver = fr
	pub := establishWG(t, m, active)
	ctx := context.Background()

	m.now = func() time.Time { return time.Unix(1783107115+30, 0) } // handshake age 30s < 180 → fresh
	dnsBefore, scBefore := fr.count(), rr.count("systemctl")
	m.Watchdog(ctx, true, testBlock(pub))
	if fr.count() != dnsBefore {
		t.Errorf("watchdog re-resolved a HEALTHY tunnel: %d extra DNS calls", fr.count()-dnsBefore)
	}
	if rr.count("systemctl") != scBefore {
		t.Errorf("watchdog took mutating action on a healthy tunnel: %v", rr.calls)
	}
}

func TestWatchdog_StaleReIPRestarts(t *testing.T) {
	fh := &fakeHub{}
	m, rr, active, _ := testManager(t, fh)
	fr := &fakeResolver{}
	fr.set(netip.MustParseAddr("167.233.158.164"))
	m.resolver = fr
	pub := establishWG(t, m, active)
	ctx := context.Background()

	m.now = func() time.Time { return time.Unix(1783107115+300, 0) } // age 300s > 180 → stale
	fr.set(netip.MustParseAddr("192.0.2.9"))                         // endpoint re-IP
	m.Watchdog(ctx, true, testBlock(pub))

	if got := rr.lastSystemctl(); strings.Join(got, " ") != "systemctl restart wg-quick@wg-felhom" {
		t.Errorf("re-IP watchdog systemctl = %v, want restart", got)
	}
	staged, err := os.ReadFile(m.stagedConfPath())
	if err != nil {
		t.Fatal(err)
	}
	if !strings.Contains(string(staged), "Endpoint = 192.0.2.9:443") {
		t.Errorf("conf not re-pinned to the new endpoint IP:\n%s", staged)
	}
}

func TestWatchdog_StaleSameIPNoChurn(t *testing.T) {
	fh := &fakeHub{}
	m, rr, active, logBuf := testManager(t, fh)
	fr := &fakeResolver{}
	fr.set(netip.MustParseAddr("167.233.158.164"))
	m.resolver = fr
	pub := establishWG(t, m, active)
	ctx := context.Background()

	m.now = func() time.Time { return time.Unix(1783107115+300, 0) } // stale, but resolver returns SAME IP
	scBefore := rr.count("systemctl")
	m.Watchdog(ctx, true, testBlock(pub))
	m.Watchdog(ctx, true, testBlock(pub)) // second stale tick
	if rr.count("systemctl") != scBefore {
		t.Errorf("watchdog restarted on stale-but-unchanged IP (churn): %v", rr.calls)
	}
	if n := strings.Count(logBuf.String(), "endpoint IP unchanged"); n != 1 {
		t.Errorf("stale-same-IP warning logged %d times, want throttled to 1", n)
	}
}

func TestWatchdog_ResolverFailureKeepsConf(t *testing.T) {
	fh := &fakeHub{}
	m, rr, active, _ := testManager(t, fh)
	fr := &fakeResolver{}
	fr.set(netip.MustParseAddr("167.233.158.164"))
	m.resolver = fr
	pub := establishWG(t, m, active)
	ctx := context.Background()
	orig, _ := os.ReadFile(m.stagedConfPath())

	m.now = func() time.Time { return time.Unix(1783107115+300, 0) } // stale
	fr.fail(errors.New("resolver down"))
	scBefore := rr.count("systemctl")
	m.Watchdog(ctx, true, testBlock(pub))
	if rr.count("systemctl") != scBefore {
		t.Errorf("watchdog acted despite a resolver failure: %v", rr.calls)
	}
	after, _ := os.ReadFile(m.stagedConfPath())
	if !bytes.Equal(orig, after) {
		t.Error("conf changed on resolver failure — must keep the last-applied conf")
	}
}

// TestInitialResolveFailureNoTeardown: first apply with a failing resolver cannot render — so it
// applies NOTHING and NEVER tears down (DNS failure ≠ revocation); it recovers when DNS returns.
func TestInitialResolveFailureNoTeardown(t *testing.T) {
	fh := &fakeHub{}
	m, rr, _, _ := testManager(t, fh)
	fr := &fakeResolver{}
	fr.fail(errors.New("resolver down"))
	m.resolver = fr
	ctx := context.Background()

	m.Apply(ctx, false, nil) // register (no DNS)
	pub := localPub(t, m)
	m.Apply(ctx, true, testBlock(pub)) // resolve fails → cannot apply
	if rr.count("install") != 0 || rr.count("systemctl") != 0 {
		t.Errorf("applied/tore-down despite a resolve failure: %v", rr.calls)
	}
	// DNS returns → the tunnel comes up on the next tick.
	fr.set(netip.MustParseAddr("167.233.158.164"))
	m.Apply(ctx, true, testBlock(pub))
	if rr.count("install") != 1 {
		t.Errorf("did not recover after DNS returned: %v", rr.calls)
	}
}
