package wgtunnel

import (
	"context"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"log/slog"
	"net"
	"net/netip"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"sort"
	"strconv"
	"strings"
	"sync"
	"time"

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

const (
	// Iface is the managed interface/unit name: wg-quick@wg-felhom.
	Iface = "wg-felhom"
	// confDest is the installed conf path — must match the FELHOM_WG sudoers entry EXACTLY.
	confDest = "/etc/wireguard/wg-felhom.conf"
	// unit is the systemd unit the sudoers allowlists.
	unit = "wg-quick@wg-felhom"

	markerName      = "registered.json"
	lastAppliedName = "last-applied.sha"
	stagedConfName  = "wg-felhom.conf"

	// registration backoff bounds (Scenario D: no hot loop).
	regBackoffMin = time.Minute
	regBackoffMax = 15 * time.Minute
)

// Registrar is the hub seam (satisfied by *hub.Client; tests inject a fake).
type Registrar interface {
	RegisterWG(ctx context.Context, pubkey string) (*hub.WGRegisterResponse, error)
}

// Resolver resolves the endpoint's DNS name to IPv4 addresses (doc 06 §4.2 v4-pin). The seam lets
// tests drive resolution deterministically; the production impl asks ONLY for A records ("ip4"),
// so the AAAA is never returned — the tunnel can never silently ride un-NATed IPv6.
type Resolver interface {
	LookupIPv4(ctx context.Context, host string) ([]netip.Addr, error)
}

// netResolver is the production Resolver — the system resolver, A records only.
type netResolver struct{}

func (netResolver) LookupIPv4(ctx context.Context, host string) ([]netip.Addr, error) {
	return net.DefaultResolver.LookupNetIP(ctx, "ip4", host)
}

// lowestAddr picks the numerically lowest address — a deterministic, fleet-wide-stable choice when
// the endpoint publishes multiple A records (every box picks the same one; no per-box drift).
func lowestAddr(addrs []netip.Addr) netip.Addr {
	best := addrs[0]
	for _, a := range addrs[1:] {
		if a.Compare(best) < 0 {
			best = a
		}
	}
	return best
}

// marker is the local registration record (<StateDir>/wg/registered.json). Its EXISTENCE is the
// registration gate: present → never register again except the pubkey-mismatch re-key path.
// KEPT on revocation (revoked stays revoked — doc 06 §3.5 completion).
type marker struct {
	Pubkey     string `json:"pubkey"`
	AssignedIP string `json:"assigned_ip"`
	Generation int64  `json:"generation"`
}

// Manager renders + applies the wg-felhom conf and drives wg-quick@wg-felhom through the
// narrow-sudoers runner (the lanresolver shape). All mutations go through `runner`; the only
// unprivileged execs are `systemctl is-active` (world-readable state) via isActive.
type Manager struct {
	runner   proxmox.Runner
	hub      Registrar
	stateDir string
	logger   *slog.Logger

	// injectable for tests
	isActive  func(ctx context.Context) bool
	wgPresent func() bool
	now       func() time.Time
	resolver  Resolver

	staleAfter time.Duration // handshake-age threshold for the re-resolve watchdog (doc 06 §4.2)

	mu              sync.Mutex
	nextRegAt       time.Time
	regBackoff      time.Duration
	keyBroken       bool // corrupt key / partial state — loop idles until operator resolves
	brokenAnnounced bool

	// v4-pin cache + watchdog throttles (all guarded by mu).
	lastResolvedIP    netip.Addr // last A-record we rendered into the conf; invalid → resolve on next apply
	resolveFailLogged bool       // DNS-failure log throttle (reset on the next success)
	staleSameIPLogged bool       // "stale but IP unchanged" log throttle (endpoint-down, not re-IP)

	// conv is the CACHED tunnel-convergence snapshot (v0.90.0, R-28 fast-tick source), refreshed at
	// the end of every Apply (the tunnel's own cadence) so the fast-tick reads it WITHOUT execing
	// `wg`/`systemctl` per tick. wgBlockDesired = a wireguard block for THIS key is desired;
	// operational = registered (marker) AND the unit is active. desired && !operational is exactly
	// the poke-undeliverable window R-28 exists to close.
	conv convSnapshot
}

// convSnapshot is the cheap, cached read the fast-tick consumes.
type convSnapshot struct {
	wgBlockDesired bool
	operational    bool
}

// TunnelConvergence returns the cached (desired, operational) snapshot without any exec (fast-tick
// source). Zero value (false, false) before the first Apply = "nothing desired yet" = converged for
// this source (the never-fetched case is covered by the desired-generation source instead).
func (m *Manager) TunnelConvergence() (desired, operational bool) {
	m.mu.Lock()
	defer m.mu.Unlock()
	return m.conv.wgBlockDesired, m.conv.operational
}

// NewManager builds a Manager. stateDir is the agent state dir (default /var/lib/felhom-agent —
// note the FELHOM_WG sudoers install entry hard-codes the staged path under it).
func NewManager(runner proxmox.Runner, registrar Registrar, stateDir string, logger *slog.Logger) *Manager {
	if logger == nil {
		logger = slog.Default()
	}
	return &Manager{
		runner:   runner,
		hub:      registrar,
		stateDir: stateDir,
		logger:   logger,
		isActive: func(ctx context.Context) bool {
			out, _ := exec.CommandContext(ctx, "systemctl", "is-active", unit).Output()
			return strings.TrimSpace(string(out)) == "active"
		},
		wgPresent: func() bool {
			_, err := os.Stat("/usr/bin/wg")
			return err == nil
		},
		now:        time.Now,
		resolver:   netResolver{},
		staleAfter: 180 * time.Second,
	}
}

// SetStaleAfter overrides the re-resolve staleness threshold (from wg_tunnel.stale_after_seconds);
// non-positive values keep the 180s default.
func (m *Manager) SetStaleAfter(d time.Duration) {
	if d > 0 {
		m.mu.Lock()
		m.staleAfter = d
		m.mu.Unlock()
	}
}

func (m *Manager) wgDir() string           { return filepath.Join(m.stateDir, "wg") }
func (m *Manager) markerPath() string      { return filepath.Join(m.wgDir(), markerName) }
func (m *Manager) lastAppliedPath() string { return filepath.Join(m.wgDir(), lastAppliedName) }
func (m *Manager) stagedConfPath() string  { return filepath.Join(m.wgDir(), stagedConfName) }

func (m *Manager) loadMarker() *marker {
	raw, err := os.ReadFile(m.markerPath())
	if err != nil {
		return nil
	}
	var mk marker
	if json.Unmarshal(raw, &mk) != nil || mk.Pubkey == "" {
		return nil
	}
	return &mk
}

// LoadAssignedAddr reads the box's assigned WG address from the registration marker
// (<stateDir>/wg/registered.json) WITHOUT constructing a Manager — the poke listener (v0.89.0)
// binds EXCLUSIVELY to this /32, so it needs the bare address, not the /32 prefix. Returns
// ok=false until the box has registered (marker absent / unparsable / empty). The address is
// stable across the box's life (preserved on re-key / reinstall — the WG IP is host-scoped).
func LoadAssignedAddr(stateDir string) (netip.Addr, bool) {
	raw, err := os.ReadFile(filepath.Join(stateDir, "wg", markerName))
	if err != nil {
		return netip.Addr{}, false
	}
	var mk marker
	if json.Unmarshal(raw, &mk) != nil || mk.AssignedIP == "" {
		return netip.Addr{}, false
	}
	if pfx, err := netip.ParsePrefix(mk.AssignedIP); err == nil {
		return pfx.Addr(), true
	}
	if a, err := netip.ParseAddr(mk.AssignedIP); err == nil { // tolerate a bare addr
		return a, true
	}
	return netip.Addr{}, false
}

func (m *Manager) writeMarker(mk marker) error {
	if err := os.MkdirAll(m.wgDir(), 0o700); err != nil {
		return err
	}
	raw, _ := json.Marshal(mk)
	return os.WriteFile(m.markerPath(), raw, 0o600)
}

// --- conf rendering (pure; every value strictly validated before it reaches the file) ---

var dnsNameRe = regexp.MustCompile(`^[A-Za-z0-9.-]+$`)

func validKeyB64(s string) error {
	if len(s) != 44 {
		return fmt.Errorf("key must be 44 base64 chars, got %d", len(s))
	}
	raw, err := base64.StdEncoding.DecodeString(s)
	if err != nil {
		return fmt.Errorf("key is not valid base64")
	}
	if len(raw) != 32 {
		return fmt.Errorf("key decodes to %d bytes, want 32", len(raw))
	}
	return nil
}

// clientMTU is the offsite tunnel's interface MTU — the IPv6-minimum floor (RFC 8200 guarantees
// every path carries ≥1280). Chosen fleet-wide + family-agnostic so bulk TCP never black-holes on
// a constrained path: outer = 1280+60 (v4) / 1280+80 (v6), both fit the mobile ~1400, DS-Lite
// ~1452, PPPoE 1492 and clean 1500 cases. Client-only bounds both directions (interface MTU caps
// box→PBS; advertised MSS = MTU−40 caps PBS→box), so the endpoint's wg0 stays untouched. Was 1420
// (silently black-holed sub-~1480 paths — CGNAT smoke test 2026-07-04). See doc 06 §4.3.
const clientMTU = 1280

// renderConf builds the wg-felhom.conf content from the hub block + the local private key + the
// PRE-RESOLVED endpoint IPv4 (the caller resolves the DNS name; renderConf stays pure — no DNS).
// The Endpoint line carries the v4 LITERAL (doc 06 §4.2 v4-pin: deterministic family, never the
// AAAA). Client-side constants per doc 06 §4: MTU 1280, AllowedIPs = pbs_tunnel_ip/32 (the tunnel
// carries ONLY box→PBS traffic), PersistentKeepalive 25. All inputs validated — nothing
// user-controlled is interpolatable (strict charsets, netip parses).
func renderConf(block *hub.WireWireguard, privB64, endpointIPv4 string) (string, error) {
	if err := validKeyB64(privB64); err != nil {
		return "", fmt.Errorf("wgtunnel: private key: %w", err)
	}
	if err := validKeyB64(block.Endpoint.ServerPubkey); err != nil {
		return "", fmt.Errorf("wgtunnel: server_pubkey: %w", err)
	}
	epIP, err := netip.ParseAddr(endpointIPv4)
	if err != nil || !epIP.Is4() {
		return "", fmt.Errorf("wgtunnel: endpoint ip %q is not an IPv4 literal", endpointIPv4)
	}
	addr, err := netip.ParsePrefix(block.AssignedIP)
	if err != nil || addr.Bits() != 32 {
		return "", fmt.Errorf("wgtunnel: assigned_ip %q is not an ip/32", block.AssignedIP)
	}
	pbsIP, err := netip.ParseAddr(block.Endpoint.PBSTunnelIP)
	if err != nil {
		return "", fmt.Errorf("wgtunnel: pbs_tunnel_ip %q is not an address", block.Endpoint.PBSTunnelIP)
	}
	if !dnsNameRe.MatchString(block.Endpoint.DNSName) {
		return "", fmt.Errorf("wgtunnel: endpoint dns_name has invalid characters")
	}
	if block.Endpoint.WGPort < 1 || block.Endpoint.WGPort > 65535 {
		return "", fmt.Errorf("wgtunnel: wg_port %d out of range", block.Endpoint.WGPort)
	}
	// AllowedIPs: the PBS tunnel IP always; the operator OOB peer /32 too when present (H1 [OF-1]).
	// The tunnel carries box→PBS AND (when OOB is on) operator→box SSH — both terminate on the box, so
	// the low interface MTU still bounds both directions. RENDERED here (not a runtime `wg set`) so it
	// survives the agent's own self-heal. allowedIPs returns a DETERMINISTICALLY SORTED list so the
	// conf-hash is stable across ticks (trap 1: nondeterministic order → a restart every 60s).
	allowed, err := allowedIPsLine(pbsIP, block.OOBPeerIP)
	if err != nil {
		return "", err
	}

	var b strings.Builder
	b.WriteString("# felhom offsite tunnel — agent-managed (S3); DO NOT EDIT\n")
	b.WriteString("[Interface]\n")
	fmt.Fprintf(&b, "PrivateKey = %s\n", privB64)
	fmt.Fprintf(&b, "Address = %s\n", block.AssignedIP)
	fmt.Fprintf(&b, "MTU = %d\n\n", clientMTU)
	b.WriteString("[Peer]\n")
	fmt.Fprintf(&b, "PublicKey = %s\n", block.Endpoint.ServerPubkey)
	fmt.Fprintf(&b, "Endpoint = %s:%d\n", epIP, block.Endpoint.WGPort)
	fmt.Fprintf(&b, "AllowedIPs = %s\n", allowed)
	b.WriteString("PersistentKeepalive = 25\n")
	return b.String(), nil
}

// allowedIPsLine builds the peer AllowedIPs value: pbsIP/32 always, plus the operator OOB /32 when
// oobPeerIP is a non-empty valid IPv4. The result is SORTED by address (netip.Addr.Compare) so two
// renders of the same inputs are byte-identical — the conf-hash stability the reconcile loop relies
// on. oobPeerIP is validated (bare IPv4, like pbs_tunnel_ip); a bad value is a hard error, never a
// silently-dropped widening.
func allowedIPsLine(pbsIP netip.Addr, oobPeerIP string) (string, error) {
	addrs := []netip.Addr{pbsIP}
	if oobPeerIP != "" {
		op, err := netip.ParseAddr(oobPeerIP)
		if err != nil || !op.Is4() {
			return "", fmt.Errorf("wgtunnel: oob_peer_ip %q is not an IPv4 address", oobPeerIP)
		}
		if op != pbsIP { // never duplicate if (misconfigured) equal to the PBS IP
			addrs = append(addrs, op)
		}
	}
	sort.Slice(addrs, func(i, j int) bool { return addrs[i].Compare(addrs[j]) < 0 })
	parts := make([]string, len(addrs))
	for i, a := range addrs {
		parts[i] = a.String() + "/32"
	}
	return strings.Join(parts, ", "), nil
}

// --- the state machine (doc 06 §3.3/§3.5 + spec §7/§8) ---

// Apply reconciles local reality toward (fetched, block). fetched=false means "no desired-state
// data seen yet this process" — NEVER a teardown signal (teardown only on a PRESENT desired-state
// without the block).
func (m *Manager) Apply(ctx context.Context, fetched bool, block *hub.WireWireguard) {
	m.mu.Lock()
	defer m.mu.Unlock()

	// Refresh the cached fast-tick convergence snapshot at the end of every Apply (this cadence),
	// so the fast-tick never execs. Runs under the lock, before the Unlock defer (LIFO), covering
	// every return path. desired = a wg block for this key is wanted; operational = registered + active.
	defer func() {
		desired := fetched && block != nil
		operational := false
		if desired {
			if mk := m.loadMarker(); mk != nil {
				operational = m.isActive(ctx)
			}
		}
		m.conv = convSnapshot{wgBlockDesired: desired, operational: operational}
	}()

	keyExists := false
	if _, err := os.Stat(KeyFilePath(m.stateDir)); err == nil {
		keyExists = true
	}
	mk := m.loadMarker()

	// Partial state: marker without key. Never guess — a fresh keygen here would silently orphan
	// the hub-registered identity. Operator repairs (delete marker → fresh keygen+register).
	if mk != nil && !keyExists {
		m.announceBroken("wgtunnel: registration marker exists but the key file is missing — idling until the operator resolves (delete the marker for a fresh keygen+register)")
		return
	}

	pub, created, err := EnsureKey(m.stateDir)
	if err != nil {
		m.announceBroken("wgtunnel: " + err.Error())
		return
	}
	m.keyBroken, m.brokenAnnounced = false, false
	if created {
		m.logger.Info("wgtunnel: generated new WG keypair", "pubkey", pub)
	}

	if mk == nil {
		// Not registered (by local knowledge). Adopt if the hub already knows this exact key —
		// the lost-marker-kept-key case; no re-register.
		if fetched && block != nil && block.Pubkey == pub {
			if err := m.writeMarker(marker{Pubkey: pub, AssignedIP: block.AssignedIP}); err != nil {
				m.logger.Error("wgtunnel: writing adoption marker", "err", err)
				return
			}
			m.logger.Info("wgtunnel: adopted existing hub registration (marker restored)", "pubkey", pub)
			mk = m.loadMarker()
		} else {
			// One-shot registration (backoff-gated). Registration is gated ONLY on marker
			// absence: a revoked box (marker present, block gone) never lands here.
			m.registerLocked(ctx, pub)
			return
		}
	}

	if !fetched {
		return // no desired data yet — keep last-applied state untouched
	}

	if block == nil {
		// PRESENT desired-state without the block = the hub revoked us. Stop + disable, KEEP the
		// marker (revoked stays revoked; the operator re-adds the peer using the reported pubkey).
		m.teardownLocked(ctx)
		return
	}

	if block.Pubkey != pub {
		// The hub's block is for another key (DR from escrowed key on a re-provisioned box, or
		// stale hub state) — the S2 re-key-in-place path. Bounded by the same backoff.
		m.logger.Warn("wgtunnel: desired block pubkey differs from local key — re-registering (re-key-in-place)",
			"local", pub, "desired", block.Pubkey)
		m.registerLocked(ctx, pub)
		return // the bumped generation brings a corrected block on the next fetch
	}

	m.ensureTunnelLocked(ctx, block)
}

// announceBroken logs a broken-state error ONCE (then idles quietly until state changes).
func (m *Manager) announceBroken(msg string) {
	if !m.brokenAnnounced {
		m.logger.Error(msg)
		m.brokenAnnounced = true
	}
	m.keyBroken = true
}

// registerLocked runs one backoff-gated RegisterWG and writes the marker on success.
func (m *Manager) registerLocked(ctx context.Context, pub string) {
	if m.now().Before(m.nextRegAt) {
		return
	}
	resp, err := m.hub.RegisterWG(ctx, pub)
	if err != nil {
		if m.regBackoff == 0 {
			m.regBackoff = regBackoffMin
		} else if m.regBackoff *= 2; m.regBackoff > regBackoffMax {
			m.regBackoff = regBackoffMax
		}
		m.nextRegAt = m.now().Add(m.regBackoff)
		m.logger.Warn("wgtunnel: registration failed — will retry", "err", err, "backoff", m.regBackoff)
		return
	}
	m.regBackoff, m.nextRegAt = 0, time.Time{}
	if err := m.writeMarker(marker{Pubkey: pub, AssignedIP: resp.AssignedIP, Generation: resp.Generation}); err != nil {
		m.logger.Error("wgtunnel: registration succeeded but marker write failed", "err", err)
		return
	}
	m.logger.Info("wgtunnel: registered with hub", "pubkey", pub,
		"assigned_ip", resp.AssignedIP, "existed", resp.Existed, "generation", resp.Generation, "sync", resp.Sync)
}

// teardownLocked disables the tunnel once (idempotent via the last-applied hash file + a live
// service check). NO re-registration follows — the marker stays.
func (m *Manager) teardownLocked(ctx context.Context) {
	applied := false
	if _, err := os.Stat(m.lastAppliedPath()); err == nil {
		applied = true
	}
	if !applied && !m.isActive(ctx) {
		return // already torn down — nothing to exec (Scenario C's quiet ticks)
	}
	m.logger.Warn("wgtunnel: desired-state no longer carries the wireguard block — hub revoked this peer; disabling the tunnel (marker kept; NO re-registration)")
	if _, errOut, err := m.runner.Run(ctx, "systemctl", "disable", "--now", unit); err != nil {
		m.logger.Error("wgtunnel: disable failed", "err", err, "stderr", strings.TrimSpace(string(errOut)))
		return
	}
	os.Remove(m.lastAppliedPath())
}

// ensureTunnelLocked renders + applies the conf and keeps the service alive (Scenario B).
func (m *Manager) ensureTunnelLocked(ctx context.Context, block *hub.WireWireguard) {
	priv, err := readPrivateKeyB64(m.stateDir)
	if err != nil {
		m.announceBroken("wgtunnel: " + err.Error())
		return
	}
	epIP, ok := m.resolveEndpointLocked(ctx, block)
	if !ok {
		return // resolveEndpointLocked logged; conf UNTOUCHED — DNS failure is never a teardown
	}
	conf, err := renderConf(block, priv, epIP)
	if err != nil {
		m.logger.Error("wgtunnel: refusing to apply invalid desired block", "err", err)
		return
	}
	sum := sha256.Sum256([]byte(conf))
	hash := hex.EncodeToString(sum[:])
	last, _ := os.ReadFile(m.lastAppliedPath())
	active := m.isActive(ctx)

	if string(last) == hash {
		if active {
			return // steady state: zero execs
		}
		// self-heal: conf is current but the service is down (crash/manual stop)
		m.logger.Info("wgtunnel: service inactive with current conf — re-enabling (self-heal)")
		if err := m.ensureToolsLocked(ctx); err != nil {
			return
		}
		if _, errOut, err := m.runner.Run(ctx, "systemctl", "enable", "--now", unit); err != nil {
			m.logger.Error("wgtunnel: enable failed", "err", err, "stderr", strings.TrimSpace(string(errOut)))
		}
		return
	}

	// Conf changed (first apply, endpoint re-key, ip change): stage → install → enable/restart.
	if err := m.ensureToolsLocked(ctx); err != nil {
		return
	}
	if err := os.MkdirAll(m.wgDir(), 0o700); err != nil {
		m.logger.Error("wgtunnel: state dir", "err", err)
		return
	}
	if err := os.WriteFile(m.stagedConfPath(), []byte(conf), 0o600); err != nil {
		m.logger.Error("wgtunnel: staging conf", "err", err)
		return
	}
	// argv matches the FELHOM_WG sudoers entry exactly (fixed source + dest).
	if _, errOut, err := m.runner.Run(ctx, "install", "-o", "root", "-g", "root", "-m", "0600", "--", m.stagedConfPath(), confDest); err != nil {
		m.logger.Error("wgtunnel: conf install failed", "err", err, "stderr", strings.TrimSpace(string(errOut)))
		return
	}
	verb := []string{"enable", "--now", unit}
	if active {
		verb = []string{"restart", unit} // conf change on a running tunnel → restart (never reload)
	}
	if _, errOut, err := m.runner.Run(ctx, "systemctl", verb...); err != nil {
		m.logger.Error("wgtunnel: systemctl "+verb[0]+" failed", "err", err, "stderr", strings.TrimSpace(string(errOut)))
		return
	}
	if err := os.WriteFile(m.lastAppliedPath(), []byte(hash), 0o600); err != nil {
		m.logger.Error("wgtunnel: recording applied hash", "err", err)
		return
	}
	m.logger.Info("wgtunnel: tunnel conf applied", "endpoint",
		fmt.Sprintf("%s:%d", epIP, block.Endpoint.WGPort), "dns_name", block.Endpoint.DNSName,
		"assigned_ip", block.AssignedIP, "action", verb[0])
}

// resolveEndpointLocked returns the endpoint IPv4 to render. Steady state uses the CACHED IP — no
// DNS call while the tunnel is healthy (the watchdog owns re-resolution, doc 06 §4.2). The cache is
// empty on first apply and after a process restart → one resolve then. On resolver failure with a
// cache present the caller keeps the last conf (never a teardown); with no cache yet it simply
// can't apply and retries next tick.
func (m *Manager) resolveEndpointLocked(ctx context.Context, block *hub.WireWireguard) (string, bool) {
	if m.lastResolvedIP.IsValid() {
		return m.lastResolvedIP.String(), true
	}
	pick, ok := m.resolveNowLocked(ctx, block)
	if !ok {
		return "", false
	}
	m.lastResolvedIP = pick
	return pick.String(), true
}

// resolveNowLocked forces a fresh A-record lookup, picks the lowest, and throttles failure logging.
// Never returns an AAAA (the resolver asks for "ip4"). Returns ok=false on a bad name or lookup
// failure — the caller decides whether that means "keep last conf" or "can't apply yet".
func (m *Manager) resolveNowLocked(ctx context.Context, block *hub.WireWireguard) (netip.Addr, bool) {
	name := block.Endpoint.DNSName
	if !dnsNameRe.MatchString(name) {
		m.logger.Error("wgtunnel: endpoint dns_name has invalid characters — not resolving", "dns_name", name)
		return netip.Addr{}, false
	}
	addrs, err := m.resolver.LookupIPv4(ctx, name)
	if err != nil || len(addrs) == 0 {
		if !m.resolveFailLogged {
			m.logger.Error("wgtunnel: endpoint A-record resolution failed — keeping last-applied conf (DNS failure is NEVER a teardown)",
				"err", err, "dns_name", name)
			m.resolveFailLogged = true
		}
		return netip.Addr{}, false
	}
	m.resolveFailLogged = false
	return lowestAddr(addrs), true
}

// handshakeStaleLocked reports whether the live tunnel's last handshake is older than staleAfter —
// the watchdog's re-resolve trigger. Inactive tunnel or no-handshake-yet → NOT stale (the ensure
// path handles those). This is the ONLY place a per-tick `wg show` runs outside Status; it lives in
// Watchdog (loop-only) so Apply's steady state stays zero-exec.
func (m *Manager) handshakeStaleLocked(ctx context.Context) bool {
	if !m.isActive(ctx) {
		return false
	}
	out, _, err := m.runner.Run(ctx, "wg", "show", Iface, "latest-handshakes")
	if err != nil {
		return false
	}
	age, ok := parseHandshakeAge(string(out), m.now())
	if !ok {
		return false
	}
	return age > int64(m.staleAfter.Seconds())
}

// Watchdog re-resolves the endpoint when the tunnel's handshake has gone stale and re-applies on an
// IP change — recovery from an endpoint re-IP or a family flap (doc 06 §4.2). Loop-driven only:
// Apply never calls it, so Apply's steady state stays DNS-free and exec-free. A stale handshake with
// an UNCHANGED IP (endpoint merely down) causes no churn — one throttled log, no restart.
func (m *Manager) Watchdog(ctx context.Context, fetched bool, block *hub.WireWireguard) {
	m.mu.Lock()
	defer m.mu.Unlock()
	if !fetched || block == nil || !m.lastResolvedIP.IsValid() {
		return // nothing applied to watch, or no desired block
	}
	if !m.handshakeStaleLocked(ctx) {
		return // healthy → no DNS, no action (the load-bearing negative)
	}
	pick, ok := m.resolveNowLocked(ctx, block)
	if !ok {
		return // resolver failed while stale → keep last conf, retry next tick
	}
	if pick == m.lastResolvedIP {
		if !m.staleSameIPLogged {
			m.logger.Warn("wgtunnel: tunnel handshake stale but endpoint IP unchanged — endpoint may be down; not restarting",
				"endpoint_ip", pick.String())
			m.staleSameIPLogged = true
		}
		return
	}
	m.logger.Info("wgtunnel: endpoint re-IP detected — re-resolving and re-applying",
		"old", m.lastResolvedIP.String(), "new", pick.String(), "dns_name", block.Endpoint.DNSName)
	m.lastResolvedIP = pick
	m.staleSameIPLogged = false
	m.ensureTunnelLocked(ctx, block) // re-renders with the new cached IP → restart
}

// ensureToolsLocked installs wireguard-tools once when absent (dnsmasq-install precedent).
func (m *Manager) ensureToolsLocked(ctx context.Context) error {
	if m.wgPresent() {
		return nil
	}
	m.logger.Info("wgtunnel: wireguard-tools absent — installing")
	if out, errOut, err := m.runner.Run(ctx, "apt-get", "install", "-y", "-q", "wireguard-tools"); err != nil {
		m.logger.Error("wgtunnel: apt-get install wireguard-tools failed",
			"err", err, "out", strings.TrimSpace(string(errOut))+strings.TrimSpace(string(out)))
		return err
	}
	return nil
}

// Status builds the heartbeat report stanza (doc 06 §4.6). Read-only: it NEVER creates keys or
// registers. The handshake read is the package's single wg invocation — `latest-handshakes`
// only (NEVER `dump`, whose interface line carries the private key).
func (m *Manager) Status(ctx context.Context) *hub.WireguardStatus {
	m.mu.Lock()
	defer m.mu.Unlock()

	st := &hub.WireguardStatus{}
	if raw, err := os.ReadFile(KeyFilePath(m.stateDir)); err == nil {
		if priv, derr := decodeKey(raw); derr == nil {
			if pub, perr := derivePublic(priv); perr == nil {
				st.Pubkey = pub
			}
		}
	}
	if mk := m.loadMarker(); mk != nil {
		st.Registered = true
		st.AssignedIP = mk.AssignedIP
	}
	st.Active = m.isActive(ctx)
	if st.Active {
		if out, _, err := m.runner.Run(ctx, "wg", "show", Iface, "latest-handshakes"); err == nil {
			if age, ok := parseHandshakeAge(string(out), m.now()); ok {
				st.LastHandshakeAgeS = &age
			}
		}
	}
	if st.Pubkey == "" && !st.Registered && !st.Active {
		return st // still a valid (empty-ish) stanza; caller decides whether to attach
	}
	return st
}

// parseHandshakeAge parses `wg show <if> latest-handshakes` output ("<peer-pub>\t<epoch>").
// epoch 0 = no handshake yet → not ok (nil in the report; nil ≠ 0).
func parseHandshakeAge(out string, now time.Time) (int64, bool) {
	for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
		f := strings.Fields(line)
		if len(f) != 2 {
			continue
		}
		epoch, err := strconv.ParseInt(f[1], 10, 64)
		if err != nil || epoch <= 0 {
			continue
		}
		age := now.Unix() - epoch
		if age < 0 {
			age = 0
		}
		return age, true
	}
	return 0, false
}
