package api

import (
	"bytes"
	"context"
	"crypto/subtle"
	"database/sql"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
	"strings"
	"time"

	"gitea.dooplex.hu/admin/felhom-hub/internal/assets"
	"gitea.dooplex.hu/admin/felhom-hub/internal/claim"
	"gitea.dooplex.hu/admin/felhom-hub/internal/configgen"
	"gitea.dooplex.hu/admin/felhom-hub/internal/intent"
	"gitea.dooplex.hu/admin/felhom-hub/internal/mailrelay"
	"gitea.dooplex.hu/admin/felhom-hub/internal/notify"
	"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)

// ConfigTemplateProvider returns the controller.yaml template for config generation.
type ConfigTemplateProvider interface {
	Template() string
}

// LatestVersionProvider returns the latest controller image version known to the hub's registry
// checker (e.g. "0.86.0"), or "" if unknown. Satisfied by *web.VersionChecker; nil when the
// registry checker is disabled. Used to advertise latest on the controller report ACK.
type LatestVersionProvider interface {
	LatestVersion() string
}

// Poker is the agent-plane immediate-sync seam (v0.63.0): satisfied by *poke.Notifier. A system-
// initiated desired-state write here (admin-set, operator-peer bump) fires a contentless, fire-and-
// forget nudge so the box ticks in seconds. nil = poke disabled — mutations still persist; the box
// picks them up on its next ≤15-min cycle. Both methods are safe to call on a nil *poke.Notifier,
// but every call site still guards with `if h.poker != nil` (the field itself may be nil).
type Poker interface {
	PokeHost(hostID string)
	PokeAllHosts()
}

// Handler handles API endpoints for report ingest and customer queries.
type Handler struct {
	store            *store.Store
	apiKey           string
	resendAPIKey     string
	fromEmail        string
	logger           *log.Logger
	httpClient       *http.Client
	templateProvider ConfigTemplateProvider
	dispatcher       *notify.Dispatcher
	assetsMgr        *assets.Manager
	latestVersion    LatestVersionProvider

	// App-email passthrough (POST /api/v1/mail). nil sender = endpoint returns 503.
	mailSender    mailrelay.Sender
	mailLimiter   *mailRateLimiter
	mailFromAllow map[string]bool

	// applianceLimiter (v0.62.0, R-21 slice C) throttles the ONE unauthenticated endpoint,
	// POST /api/v1/appliance/register, per client IP — the unclaimed population is tiny and the
	// ingress already geo-restricts to HU, so this is a cheap anti-abuse bound, not a fleet lever.
	applianceLimiter *ipRateLimiter

	// S1 offsite connectivity: the wgsync reconciler seam (internal/api/wg.go). nil = peer-sync
	// disabled — mutations still persist, responses carry sync:"disabled".
	wgSyncer WGSyncer

	// claimEngine is the customer-claim code engine (v0.50.0). nil = claim arc disabled: no codes
	// issued, no claim field in ACKs/configs — pre-arc behavior exactly.
	claimEngine *claim.Engine

	// wgRegisteredHook (v0.51.0, DR-tier-by-default scenario A) fires after a host's FIRST WG
	// peer registration — main.go wires it to the web server's PBSDRAutoProvision so a DR-ON
	// customer's pbs_dr descriptor lands hands-free (WG registers → provision → the agent's next
	// desired-state tick). nil = no cascade hook (pre-v0.51.0 behavior). Runs in a detached
	// goroutine; must never delay or fail the registration response.
	wgRegisteredHook func(ctx context.Context, customerID string)

	// offsiteReissuer (F3, v0.57.0) re-issues the customer's offsite credentials on clean-slate
	// re-enrollment — main.go wires it to the web server's ReissueOffsiteForCustomer (same machinery
	// as the manual "Re-issue offsite credentials" button, so escrow invalidation + events ride
	// along). nil = no auto re-issue; a no-op when offsite isn't provisioned/enabled for the customer.
	offsiteReissuer func(ctx context.Context, customerID string) error

	// intentHub (v0.58.0, Direction-2 immediate-sync) is the in-memory per-customer generation
	// notifier that GET /api/v1/wait long-polls against. nil = wait endpoint returns 503 (the box
	// falls back to the 15-min cycle). Shared with the web server, whose intent handlers Bump it.
	intentHub *intent.Hub

	// poker (v0.63.0, Direction-2a agent-plane immediate-sync) fires a fire-and-forget nudge after a
	// system-initiated HOST desired-state write (admin-set desired-state, operator-peer bump) so the
	// box ticks in seconds instead of ≤15 min. Shared with the web server (same *poke.Notifier). nil
	// = poke disabled (a no-op; the report cycle still reconciles).
	poker Poker
}

// SetClaimEngine wires the customer-claim code engine (nil-safe everywhere it is used).
func (h *Handler) SetClaimEngine(e *claim.Engine) {
	h.claimEngine = e
}

// SetOffsiteReissuer wires the clean-slate re-enroll offsite re-issue seam (nil-safe).
func (h *Handler) SetOffsiteReissuer(f func(ctx context.Context, customerID string) error) {
	h.offsiteReissuer = f
}

// SetWGRegisteredHook wires the post-WG-registration cascade hook (v0.51.0; nil-safe).
func (h *Handler) SetWGRegisteredHook(f func(ctx context.Context, customerID string)) {
	h.wgRegisteredHook = f
}

// SetLatestVersionProvider wires the registry version checker so the controller report ACK can
// advertise the latest available version (Phase 2). nil-safe (no latest_version field emitted).
func (h *Handler) SetLatestVersionProvider(p LatestVersionProvider) {
	h.latestVersion = p
}

// SetIntentHub wires the operator-intent notifier for GET /api/v1/wait (v0.58.0; nil-safe — an
// unset hub makes the wait endpoint return 503).
func (h *Handler) SetIntentHub(hub *intent.Hub) {
	h.intentHub = hub
}

// SetPoker wires the agent-plane immediate-sync notifier (v0.63.0; nil-safe — an unset poker makes
// the admin desired-state writes fire no nudge, and the box picks the change up on its next cycle).
func (h *Handler) SetPoker(p Poker) {
	h.poker = p
}

// New creates a new API handler.
func New(store *store.Store, apiKey, resendAPIKey, fromEmail string, templateProvider ConfigTemplateProvider, logger *log.Logger) *Handler {
	return &Handler{
		store:            store,
		apiKey:           apiKey,
		resendAPIKey:     resendAPIKey,
		fromEmail:        fromEmail,
		logger:           logger,
		httpClient:       &http.Client{Timeout: 10 * time.Second},
		templateProvider: templateProvider,
		applianceLimiter: newIPRateLimiter(20), // 20 registrations/min/IP burst — booting boxes retry ~30s
	}
}

// SetDispatcher sets the notification dispatcher for event-triggered emails.
func (h *Handler) SetDispatcher(d *notify.Dispatcher) {
	h.dispatcher = d
}

// SetAssetManager sets the asset manager for serving app assets to controllers.
func (h *Handler) SetAssetManager(am *assets.Manager) {
	h.assetsMgr = am
}

// checkAuth verifies the Bearer token against the global API key or a per-customer API key.
// Returns true if authorized.
func (h *Handler) checkAuth(r *http.Request) bool {
	_, _, ok := h.checkAuthCustomer(r)
	return ok
}

// checkAuthCustomer verifies the Bearer token and returns the authenticated customer identity.
// For per-customer keys: returns (customerID, false, true).
// For global key: returns ("", true, true) — caller must allow any customer_id.
// On failure: returns ("", false, false).
func (h *Handler) checkAuthCustomer(r *http.Request) (customerID string, isGlobal bool, ok bool) {
	auth := r.Header.Get("Authorization")
	if !strings.HasPrefix(auth, "Bearer ") {
		return "", false, false
	}
	token := strings.TrimPrefix(auth, "Bearer ")

	// Check global key first
	if h.apiKey != "" && subtle.ConstantTimeCompare([]byte(token), []byte(h.apiKey)) == 1 {
		return "", true, true
	}

	// Check per-customer key
	cfg, err := h.store.GetCustomerConfigByAPIKey(token)
	if err != nil || cfg == nil {
		return "", false, false
	}
	return cfg.CustomerID, false, true
}

// checkAuthHost resolves a Bearer token to a HOST identity (the agent's auth
// path). It is a sibling of checkAuthCustomer — the controller path is unchanged.
//   - global key  -> ("", "", true, true)    caller trusts body.host_id
//   - per-host key -> (hostID, customerID, false, true)
//   - failure      -> ("", "", false, false)
func (h *Handler) checkAuthHost(r *http.Request) (hostID, customerID string, isGlobal, ok bool) {
	auth := r.Header.Get("Authorization")
	if !strings.HasPrefix(auth, "Bearer ") {
		return "", "", false, false
	}
	token := strings.TrimPrefix(auth, "Bearer ")

	// Global key first (same constant-time compare as checkAuthCustomer).
	if h.apiKey != "" && subtle.ConstantTimeCompare([]byte(token), []byte(h.apiKey)) == 1 {
		return "", "", true, true
	}

	host, err := h.store.GetHostByAPIKey(token)
	if err != nil || host == nil {
		return "", "", false, false
	}
	return host.HostID, host.CustomerID, false, true
}

// ServeHTTP routes API requests.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	path := strings.TrimPrefix(r.URL.Path, "/api/v1")

	switch {
	case r.Method == http.MethodPost && path == "/report":
		h.handleReport(w, r)
	// Direction-2 immediate-sync (v0.58.0): the box long-polls here; the hub completes it on any
	// operator-intent bump for the box's customer, then the box fires its ordinary report.
	case r.Method == http.MethodGet && path == "/wait":
		h.handleWait(w, r)
	// R-21 slice C — the universal ISO. register is the ONE unauthenticated endpoint (per-IP
	// rate-limited); poll is Bearer appliance-token. Both minimal, no enumeration oracle.
	case r.Method == http.MethodPost && path == "/appliance/register":
		h.handleApplianceRegister(w, r)
	case r.Method == http.MethodGet && path == "/appliance/poll":
		h.handleAppliancePoll(w, r)
	case r.Method == http.MethodPost && path == "/host-report":
		h.handleHostReport(w, r)
	case r.Method == http.MethodPost && path == "/host-enroll":
		h.handleHostEnroll(w, r)
	case r.Method == http.MethodPost && path == "/admin/hosts":
		h.handleAdminCreateHost(w, r)
	case r.Method == http.MethodPut && strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/escrow"):
		hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/escrow")
		h.handleHostEscrowPut(w, r, hostID)
	// R-199 (v0.94.0): the box-authenticated MIRROR of the PUT above — a host reads back its own
	// opaque identity blob so it can be unsealed with the customer's recovery code. Distinct from the
	// operator-driven DR path in dr.go, which stays exactly as it is (see handleHostEscrowGet).
	case r.Method == http.MethodGet && strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/escrow"):
		hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/escrow")
		h.handleHostEscrowGet(w, r, hostID)
	// R-311 (v0.103.0): the RETAINED siblings of the row above. The two cases cannot collide — one
	// ends `/escrow`, the other `/escrow/retained` — but do NOT "tidy" them into a single prefix
	// match: a prefix match would route retained reads to the CURRENT row, which is silently the
	// wrong package and is exactly the confusion this endpoint exists to end.
	case r.Method == http.MethodGet && strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/escrow/retained"):
		hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/escrow/retained")
		h.handleHostEscrowRetainedGet(w, r, hostID)
	// G1 break-glass: day-0 vaults the root@pam console credential (self-scoped host key); the
	// operator retrieves it via the /admin/ path (global key only).
	case r.Method == http.MethodPut && strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/recovery-credential"):
		hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/recovery-credential")
		h.handleHostRecoveryCredentialPut(w, r, hostID)
	case r.Method == http.MethodGet && strings.HasPrefix(path, "/admin/hosts/") && strings.HasSuffix(path, "/recovery-credential"):
		hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/admin/hosts/"), "/recovery-credential")
		h.handleAdminGetRecoveryCredential(w, r, hostID)
	// DR capstone (slice 10D). Recovery-mode toggle (global key); re-enroll + restore-directive
	// (gated on recovery mode — no old key needed, the box is lost).
	case r.Method == http.MethodPut && strings.HasPrefix(path, "/admin/hosts/") && strings.HasSuffix(path, "/recovery-mode"):
		hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/admin/hosts/"), "/recovery-mode")
		h.handleSetRecoveryMode(w, r, hostID)
	case r.Method == http.MethodDelete && strings.HasPrefix(path, "/admin/hosts/") && strings.HasSuffix(path, "/recovery-mode"):
		hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/admin/hosts/"), "/recovery-mode")
		h.handleClearRecoveryMode(w, r, hostID)
	case r.Method == http.MethodPost && strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/re-enroll"):
		hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/re-enroll")
		h.handleReEnroll(w, r, hostID)
	case r.Method == http.MethodGet && strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/restore-directive"):
		hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/restore-directive")
		h.handleGetRestoreDirective(w, r, hostID)
	// S2 offsite connectivity: box-facing WG pubkey registration (per-host key, self-scoped).
	case r.Method == http.MethodPost && strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/wg"):
		hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/wg")
		h.handleRegisterHostWG(w, r, hostID)
	// PBS DR tier (SLICE 1): the agent's consume-once fetch of its PBS token secret (api/pbsdr.go).
	case r.Method == http.MethodPost && strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/pbs/consume-token"):
		hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/pbs/consume-token")
		h.handleConsumePBSToken(w, r, hostID)
	// Desired-state serving (slice 10A) — per-host-key, self-scoped (a host reads only its own).
	case r.Method == http.MethodGet && strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/desired-state"):
		hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/desired-state")
		h.handleGetDesiredState(w, r, hostID)
	case r.Method == http.MethodGet && strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/jobs"):
		hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/jobs")
		h.handleGetJobs(w, r, hostID)
	// Job completion (slice 10B) — per-host-key, self-scoped: DELETE /hosts/{id}/jobs/{job_id}.
	case r.Method == http.MethodDelete && strings.HasPrefix(path, "/hosts/") && strings.Contains(path, "/jobs/"):
		rest := strings.TrimPrefix(path, "/hosts/")
		if i := strings.Index(rest, "/jobs/"); i > 0 {
			h.handleDeleteJob(w, r, rest[:i], rest[i+len("/jobs/"):])
		} else {
			http.NotFound(w, r)
		}
	// Admin-set (slice 10A) — global/operator key only; bumps the generation.
	case r.Method == http.MethodPut && strings.HasPrefix(path, "/admin/hosts/") && strings.HasSuffix(path, "/desired-state"):
		hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/admin/hosts/"), "/desired-state")
		h.handleAdminSetDesiredState(w, r, hostID)
	case r.Method == http.MethodPost && strings.HasPrefix(path, "/admin/hosts/") && strings.HasSuffix(path, "/jobs"):
		hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/admin/hosts/"), "/jobs")
		h.handleAdminEnqueueJob(w, r, hostID)
	// S1 offsite connectivity — WG endpoint record + peer registry (global key only, api/wg.go).
	// DELETE carries the pubkey in the body: base64 '/'+'+' keep pubkeys out of URL paths.
	case r.Method == http.MethodPut && path == "/admin/wg/endpoint":
		h.handleAdminSetWGEndpoint(w, r)
	case r.Method == http.MethodGet && path == "/admin/wg/endpoint":
		h.handleAdminGetWGEndpoint(w, r)
	case r.Method == http.MethodPost && path == "/admin/wg/peers":
		h.handleAdminAddWGPeer(w, r)
	case r.Method == http.MethodDelete && path == "/admin/wg/peers":
		h.handleAdminDeleteWGPeer(w, r)
	case r.Method == http.MethodGet && path == "/admin/wg/peers":
		h.handleAdminListWGPeers(w, r)
	// H1: the fleet operator OOB peer (register/rotate at an explicit /32) + read-back.
	case r.Method == http.MethodPut && path == "/admin/wg/operator-peer":
		h.handleAdminSetOperatorPeer(w, r)
	case r.Method == http.MethodGet && path == "/admin/wg/operator-peer":
		h.handleAdminGetOperatorPeer(w, r)
	case r.Method == http.MethodPost && path == "/claim/reset-request":
		h.handleClaimResetRequest(w, r)
	case r.Method == http.MethodPost && path == "/event":
		h.handleEvent(w, r)
	case r.Method == http.MethodPost && path == "/mail":
		h.handleMail(w, r)
	case r.Method == http.MethodPost && path == "/notify":
		h.handleNotify(w, r)
	case r.Method == http.MethodPost && path == "/preferences":
		h.handleSavePreferences(w, r)
	case r.Method == http.MethodGet && path == "/customers":
		h.handleCustomers(w, r)
	case r.Method == http.MethodGet && strings.HasPrefix(path, "/customers/"):
		parts := strings.Split(strings.TrimPrefix(path, "/customers/"), "/")
		customerID := parts[0]
		if len(parts) > 1 && parts[1] == "history" {
			h.handleCustomerHistory(w, r, customerID)
		} else {
			h.handleCustomer(w, r, customerID)
		}
	case r.Method == http.MethodGet && strings.HasPrefix(path, "/recovery/"):
		customerID := strings.TrimPrefix(path, "/recovery/")
		h.handleRecovery(w, r, customerID)
	case r.Method == http.MethodGet && strings.HasPrefix(path, "/config/"):
		customerID := strings.TrimPrefix(path, "/config/")
		h.handleConfigRetrieve(w, r, customerID)
	case r.Method == http.MethodPost && strings.HasPrefix(path, "/offsite/consume-password/"):
		customerID := strings.TrimPrefix(path, "/offsite/consume-password/")
		h.handleOffsiteConsumePassword(w, r, customerID)
	case r.Method == http.MethodGet && strings.HasPrefix(path, "/artifacts/"):
		customerID := strings.TrimPrefix(path, "/artifacts/")
		h.handleArtifactManifest(w, r, customerID)
	case r.Method == http.MethodGet && path == "/assets/manifest":
		h.handleAssetsManifest(w, r)
	case r.Method == http.MethodGet && strings.HasPrefix(path, "/assets/file/"):
		filename := strings.TrimPrefix(path, "/assets/file/")
		h.handleAssetFile(w, r, filename)
	default:
		http.NotFound(w, r)
	}
}

func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) {
	authCustomerID, isGlobal, ok := h.checkAuthCustomer(r)
	if !ok {
		http.Error(w, "Unauthorized", http.StatusUnauthorized)
		return
	}

	body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit
	if err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}

	// Extract customer_id from JSON
	var payload struct {
		CustomerID string `json:"customer_id"`
	}
	if err := json.Unmarshal(body, &payload); err != nil || payload.CustomerID == "" {
		http.Error(w, "Invalid payload: customer_id required", http.StatusBadRequest)
		return
	}

	// Validate customer_id matches authenticated customer (unless global key)
	if !isGlobal && authCustomerID != payload.CustomerID {
		http.Error(w, "Forbidden: customer_id mismatch", http.StatusForbidden)
		return
	}

	if err := h.store.SaveReport(payload.CustomerID, body); err != nil {
		h.logger.Printf("[ERROR] Failed to save report from %s: %v", payload.CustomerID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}

	// Parse and save app telemetry (backward-compatible — old controllers won't have this field)
	var telemetryPayload struct {
		AppTelemetry []store.AppTelemetryRecord `json:"app_telemetry"`
	}
	if err := json.Unmarshal(body, &telemetryPayload); err == nil && len(telemetryPayload.AppTelemetry) > 0 {
		if err := h.store.SaveAppTelemetry(payload.CustomerID, time.Now(), telemetryPayload.AppTelemetry); err != nil {
			h.logger.Printf("[WARN] Failed to save app telemetry for %s: %v", payload.CustomerID, err)
		}
	}

	// On-demand log tails (v0.43.0) — the controller ships these on the cycle after the ACK
	// requested them. Storing one clears its pending request (consume-once, in SaveAppLogTail)
	// so the next ACK stops advertising it. Backward-compatible: old controllers never send this.
	var tailPayload struct {
		LogTails []struct {
			App         string    `json:"app"`
			CollectedAt time.Time `json:"collected_at"`
			Lines       []string  `json:"lines"`
		} `json:"log_tails"`
	}
	if err := json.Unmarshal(body, &tailPayload); err == nil {
		for _, lt := range tailPayload.LogTails {
			if lt.App == "" {
				continue
			}
			if err := h.store.SaveAppLogTail(payload.CustomerID, lt.App, lt.CollectedAt, lt.Lines); err != nil {
				h.logger.Printf("[WARN] Failed to save log tail %s/%s: %v", payload.CustomerID, lt.App, err)
			} else {
				h.logger.Printf("[INFO] Log tail received for %s/%s (%d lines)", payload.CustomerID, lt.App, len(lt.Lines))
			}
		}
	}

	// Controller self-log tail (v0.46.0) — the controller's OWN debug ring, shipped on the
	// cycle after the ACK's controller_log_requested. SaveLogBundle runs the secret gate
	// (a hit stores a BLOCKED flag row, nothing else) and clears the pending request
	// (consume-once). Backward-compatible: old controllers never send this.
	var selfTailPayload struct {
		ControllerLogTail *struct {
			CollectedAt time.Time `json:"collected_at"`
			Lines       []string  `json:"lines"`
		} `json:"controller_log_tail"`
	}
	if err := json.Unmarshal(body, &selfTailPayload); err == nil && selfTailPayload.ControllerLogTail != nil {
		lt := selfTailPayload.ControllerLogTail
		blocked, berr := h.store.SaveLogBundle(payload.CustomerID, store.LogBundleComponentController, lt.CollectedAt, lt.Lines)
		switch {
		case berr != nil:
			h.logger.Printf("[WARN] Failed to save controller log bundle for %s: %v", payload.CustomerID, berr)
		case blocked:
			h.logger.Printf("[WARN] controller log bundle for %s BLOCKED: possible secret in log content — nothing stored", payload.CustomerID)
		default:
			h.logger.Printf("[INFO] controller log bundle received for %s (%d lines)", payload.CustomerID, len(lt.Lines))
		}
	}

	// DR recipe — persist the controller's secret-free customer/apps half (preserving any host half).
	// Backward-compatible (old controllers won't have this field); a failure must not drop the report.
	var drPayload struct {
		DRRecipe json.RawMessage `json:"dr_recipe"`
	}
	if err := json.Unmarshal(body, &drPayload); err == nil && len(drPayload.DRRecipe) > 0 {
		var ver drRecipeVersionOnly
		_ = json.Unmarshal(drPayload.DRRecipe, &ver)
		if err := h.store.SaveDRRecipeAppHalf(payload.CustomerID, ver.RecipeVersion, drPayload.DRRecipe); err != nil {
			h.logger.Printf("[WARN] Failed to save DR-recipe app-half for %s: %v", payload.CustomerID, err)
		} else {
			h.logger.Printf("[INFO] DR-recipe app-half stored for customer %s (v%d)", payload.CustomerID, ver.RecipeVersion)
		}
	}

	h.logger.Printf("[INFO] Received report from %s (%d bytes)", payload.CustomerID, len(body))

	// Build response with optional customer_blocked flag
	resp := map[string]interface{}{"status": "ok"}
	if custCfg, err := h.store.GetCustomerConfig(payload.CustomerID); err == nil && custCfg != nil {
		if custCfg.Status == "blocked" {
			resp["customer_blocked"] = true
		}
		// Config-refresh (v0.26.0): advertise the per-customer config_version. The controller compares
		// it against its last-applied version and, on a change, re-pulls controller.yaml + self-restarts
		// (pull-based config delivery — the hub never connects into the box). Only emitted for
		// config-managed customers (a report-only box without a config row gets no field and is unaffected).
		resp["config_version"] = custCfg.ConfigVersion

		// Customer-claim arc (v0.50.0, F-4): ensure a claim code exists for every reporting managed
		// customer (idempotent — the live-box entry point; Day-0 boxes get theirs at config retrieve),
		// ingest the controller's reported claimed flag (set-only — a wiped settings.json can never
		// un-claim), and serve the ACTIVE code hash + generation in the ACK. The hash is bcrypt (non-
		// reversible) — safe to serve on the authenticated report channel; the plaintext code exists
		// only in the customer's mailbox.
		if h.claimEngine != nil {
			cs, cerr := h.claimEngine.EnsureIssued(custCfg)
			if cerr != nil {
				h.logger.Printf("[WARN] claim issue for %s on report: %v", payload.CustomerID, cerr)
			}
			var claimedPayload struct {
				Claimed *bool `json:"claimed"`
			}
			if err := json.Unmarshal(body, &claimedPayload); err == nil &&
				claimedPayload.Claimed != nil && *claimedPayload.Claimed {
				if err := h.claimEngine.MarkClaimed(custCfg); err != nil {
					h.logger.Printf("[WARN] claim mark-claimed for %s: %v", payload.CustomerID, err)
				} else if cs != nil && cs.ClaimedAt == nil {
					cs, _ = h.store.GetClaim(payload.CustomerID) // refresh for the ACK below
				}
			}
			if cs != nil {
				resp["claim"] = map[string]interface{}{
					"code_hash":  cs.CodeHash,
					"generation": cs.Generation,
					"issued_at":  cs.IssuedAt.UTC().Format(time.RFC3339),
				}
			}
		}
	}

	// ── R-241 (v0.98.0) — THE ABANDONMENT PURGE, ACTED ON BEFORE THE ACK IS BUILT ───────────────
	//
	// The box has deleted its set-aside off-site history at the end of a 14-day countdown the customer
	// chose and could see, and now asks the hub to drop the sealed package that protected it, so the
	// two halves go together. Removing only one leaves a state that asks a question nobody can answer
	// — a package that opens nothing, or ciphertext nobody can decrypt.
	//
	// IT ACTS ON A DECLARATION, NEVER AN INFERENCE, on the same principle as `offsite.state`: the hub
	// cannot see that a remote store was deleted, and the box can. The declaration repeats on every
	// report until the ACK below stops carrying a superseded package, so a lost request retries by
	// itself instead of leaving the pair half-removed.
	//
	// PLACED IMMEDIATELY BEFORE THE ACK deliberately: `GetEscrowStatusForCustomer` is read after this
	// runs, so the SAME response that carries the request's effect is what closes the box's
	// two-phase commit. No second round-trip, and no window in which the box thinks it is still owed.
	//
	// Only RETAINED rows go — never `host_escrow`, which covers the key the box is using now and is
	// what makes its live backups recoverable.
	var abandonPayload struct {
		Offsite *struct {
			AbandonPurgeRequested bool `json:"abandon_purge_requested"`
		} `json:"offsite"`
	}
	if err := json.Unmarshal(body, &abandonPayload); err == nil &&
		abandonPayload.Offsite != nil && abandonPayload.Offsite.AbandonPurgeRequested {
		n, perr := h.store.PurgeSupersededEscrowForCustomer(payload.CustomerID)
		switch {
		case perr != nil:
			// Never swallowed: the box keeps declaring, so this retries next cycle — but an operator
			// must be able to see that the pair is half-removed right now.
			h.logger.Printf("[ERROR] offsite-abandon: %s DECLARED the set-aside history deleted, but purging the retained recovery package FAILED — the two halves are apart until this succeeds: %v", payload.CustomerID, perr)
		case n > 0:
			h.logger.Printf("[INFO] offsite-abandon: %s deleted its set-aside off-site history and the retained recovery package was purged (%d row(s)) — both halves are gone", payload.CustomerID, n)
			// Hub-internal, like the pbsdr_* / offsite_selfheal_* events — not gated by
			// allowedEventTypes, which governs the box-pushed POST /event surface.
			if _, eerr := h.store.SaveEvent(payload.CustomerID, "offsite_abandon_purged", "info",
				fmt.Sprintf("Az ügyfél korábbi távoli mentései és a hozzájuk tartozó megőrzött helyreállítási csomag is törölve (%d csomag). Az ügyfél döntése alapján, a 14 napos türelmi idő lejárta után.", n),
				"", "hub"); eerr != nil {
				h.logger.Printf("[WARN] offsite-abandon: %s purge succeeded but the audit event could not be saved: %v", payload.CustomerID, eerr)
			}
		default:
			// Idempotent: already purged, and the box simply has not seen the confirming ACK yet.
			h.logger.Printf("[DEBUG] offsite-abandon: %s declared a purge; no retained package remained (already done)", payload.CustomerID)
		}
	}

	// SLICE 3 — escrow status for the hub-verified auto-confirm: the controller flips its offbox
	// EscrowState pending→escrowed ONLY when sha256(its local repo password) matches restic_pw_sha256
	// (blob-presence alone must never confirm — a stale blob may not cover the current key). The hash is
	// non-reversible (256-bit random secret) — safe to serve; omitted entirely when no escrow row exists.
	if es, err := h.store.GetEscrowStatusForCustomer(payload.CustomerID); err == nil && es != nil {
		resp["escrow"] = es
	}

	// v0.43.0 — pending log-tail requests (same additive ACK-flag pattern as escrow): the
	// controller collects the named apps' tails and ships them on its NEXT report; the field
	// is omitted when nothing is pending. The hub never connects into the box.
	if apps, err := h.store.GetPendingLogTailRequests(payload.CustomerID); err == nil && len(apps) > 0 {
		resp["log_tail_requests"] = apps
	}

	// v0.46.0 — pending CONTROLLER self-log pull (same additive ACK-flag pattern): the
	// controller ships controller_log_tail on its NEXT report; omitted when nothing pending.
	if pending, err := h.store.PendingLogBundleRequest(payload.CustomerID, store.LogBundleComponentController); err == nil && pending {
		resp["controller_log_requested"] = true
	}

	// Phase 2 managed updates: advertise the effective controller-version FLOOR (per-customer override
	// else global default) and the latest available version. The controller compares its current
	// version against the floor and auto-updates when below it (latest stays the customer's opt-in
	// "update to latest" button — NOT the auto-target). Both fields are omitted when empty, so an old
	// controller that ignores them, or a hub with no floor configured, behaves exactly as before.
	// Part D: the per-box MinAgent conditional floor — HOLD the controller-version floor for a box
	// whose host agent is below the current golden's required MinAgent (never push a controller past
	// the agent it depends on). A held box gets NO directive (behaves as if no floor) but is flagged
	// on the dashboard, never silently stale.
	if fd := h.store.ResolveManagedFloor(payload.CustomerID); fd.Floor != "" {
		resp["min_controller_version"] = fd.Floor
	} else if fd.Held {
		// ONE sentence, from the decision itself — the two hold reasons must never drift apart
		// across this line and the dashboard (see ManagedFloorDecision.HoldReason).
		h.logger.Printf("[INFO] managed floor HELD for %s: %s (controller floor withheld)",
			payload.CustomerID, fd.HoldReason())
	}
	if h.latestVersion != nil {
		if latest := h.latestVersion.LatestVersion(); latest != "" {
			resp["latest_version"] = latest
		}
	}

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	json.NewEncoder(w).Encode(resp)
}

// defaultHostPollSeconds is the cadence the hub hands every agent this slice (no
// per-host override UI yet — that is a later slice).
const defaultHostPollSeconds = 900

// maxHostReportBytes bounds a host-report body. Larger than the controller path's
// 1 MiB because host reports carry the full guest list + (later) storage/backup
// arrays. We read one byte past it and reject explicitly (413) rather than letting
// LimitReader silently truncate — a truncated-but-valid JSON would otherwise be
// accepted as a partial report, dropping guests from the mirror.
const maxHostReportBytes = 4 << 20 // 4 MiB

// hostReportPayload is the subset of the agent host-report (slice-3 contract,
// §3 / agent spec §4) the hub needs for denorm + guest reality. The remaining fields
// (backups/restore_tests/pbs_snapshots/audit_tail) are ignored, so an empty or absent
// collection is accepted without error.
//
// storage_targets (slice 5) is now parsed: the agent populates it, and the hub accepts
// + persists it. Persistence is the full report_json row (which carries the targets
// verbatim) plus the denorm counts below — the RICH manifest schema (desired class/role/
// policy/creds) is hub-owned and lands in slice 10; this slice only mirrors what the agent
// observes.
type hostReportPayload struct {
	HostID       string `json:"host_id"`
	AgentVersion string `json:"agent_version"`
	Host         struct {
		CPUPercent    float64 `json:"cpu_percent"`
		MemoryPercent float64 `json:"memory_percent"`
		DiskPercent   float64 `json:"disk_percent"`
	} `json:"host"`
	Guests []struct {
		VMID              int    `json:"vmid"`
		Name              string `json:"name"`
		Status            string `json:"status"`
		ControllerVersion string `json:"controller_version"`
	} `json:"guests"`
	StorageTargets []hostStorageTarget `json:"storage_targets"`
	Backups        []hostBackup        `json:"backups"`       // slice 6
	RestoreTests   []hostRestoreTest   `json:"restore_tests"` // slice 6
	PBSSnapshots   []hostPBSSnapshot   `json:"pbs_snapshots"` // slice 6 Phase B
	Cloudflared    struct {
		Status string `json:"status"`
	} `json:"cloudflared"`
	// DR recipe — the agent's storage/guest/PBS half (secret-free). RawMessage = stored verbatim,
	// ignore-unknown (forward-compat). Persisted to dr_recipe, assembled with the controller half.
	DRRecipe json.RawMessage `json:"dr_recipe"`
	// LogTail (v0.46.0) — the agent's on-demand debug-ring tail, present only on the
	// heartbeat right after the envelope's log_tail_requested (agent ≥ 0.83.0).
	LogTail *struct {
		CollectedAt time.Time `json:"collected_at"`
		Lines       []string  `json:"lines"`
	} `json:"log_tail"`
}

// drRecipeVersionOnly extracts just recipe_version from a half's JSON (ignore-unknown). 0 if absent.
type drRecipeVersionOnly struct {
	RecipeVersion int `json:"recipe_version"`
}

// hostPBSSnapshot mirrors the agent's hub.PBSSnapshot wire contract (slice 6 Phase B). The
// hub persists it via report_json and surfaces a FAILED verify prominently (the loudest
// offsite-DR signal — same treatment as a failed restore-test).
type hostPBSSnapshot struct {
	Namespace   string `json:"namespace"`
	BackupType  string `json:"backup_type"`
	BackupID    string `json:"backup_id"`
	BackupTime  string `json:"backup_time"`
	SizeBytes   int64  `json:"size_bytes"`
	Owner       string `json:"owner"`
	Protected   bool   `json:"protected"`
	Encrypted   bool   `json:"encrypted"`
	VerifyState string `json:"verify_state"`
	VerifyUPID  string `json:"verify_upid,omitempty"`
}

// hostBackup / hostRestoreTest mirror the agent's hub.Backup / hub.RestoreTest wire
// contract field-for-field (slice 6, doc 03 §8). DUPLICATED contract — the golden stays
// byte-identical with felhom-agent's copy and the key-set tests guard drift. The hub
// persists these via report_json (no new columns this slice) and surfaces a FAILED
// restore-test prominently (the loudest DR signal). The rich backup policy is slice 10.
type hostBackup struct {
	TargetID         string   `json:"target_id"`
	VMID             int      `json:"vmid"`
	Archive          string   `json:"archive"`
	Mode             string   `json:"mode"`
	CrashConsistent  bool     `json:"crash_consistent"`
	SizeBytes        int64    `json:"size_bytes"`
	Success          bool     `json:"success"`
	Error            string   `json:"error,omitempty"`
	StartedAt        string   `json:"started_at"`
	DurationSeconds  float64  `json:"duration_seconds"`
	UncoveredVolumes []string `json:"uncovered_volumes"`
}

type hostRestoreTest struct {
	SourceArchive   string  `json:"source_archive"`
	SourceTier      string  `json:"source_tier"`
	ScratchVMID     int     `json:"scratch_vmid"`
	Pass            bool    `json:"pass"`
	Verified        string  `json:"verified"`
	Error           string  `json:"error,omitempty"`
	TestedAt        string  `json:"tested_at"`
	DurationSeconds float64 `json:"duration_seconds"`
	// Warnings are the guest-start task's warning line(s) on a PASS (e.g. the systemd-nesting
	// advisory). The verdict is liveness-only, so a passed restore-test can carry warnings.
	Warnings []string `json:"warnings,omitempty"`
	// WarningsRecognized is true iff every warning is the known-benign anchor. Absent ⇒ false,
	// which is the SAFE default: the hub then treats it as an unrecognized warning (the louder
	// path), so a missing flag can only over-notice, never hide a real warning.
	WarningsRecognized bool `json:"warnings_recognized,omitempty"`
}

// hostStorageTarget mirrors the agent's hub.StorageTarget wire contract field-for-field.
// It is a DUPLICATED contract (no shared types module yet); testdata/host-report.golden.json
// must stay byte-identical with felhom-agent's copy and the key-set test guards drift.
// The hub does not act on these yet beyond persisting + counting them (slice 10 adds the
// authoritative manifest), but mirroring the full shape keeps the cross-repo contract honest.
type hostStorageTarget struct {
	Name          string  `json:"name"`
	Type          string  `json:"type"`
	DurableID     string  `json:"durable_id"`
	State         string  `json:"state"`
	Reachable     bool    `json:"reachable"`
	TotalBytes    int64   `json:"total_bytes"`
	UsedBytes     int64   `json:"used_bytes"`
	AvailBytes    int64   `json:"avail_bytes"`
	UsedFraction  float64 `json:"used_fraction"`
	Content       string  `json:"content"`
	MountPath     string  `json:"mount_path"`
	BackingDevice string  `json:"backing_device"`
	ClassHint     string  `json:"class_hint"`
	Role          string  `json:"role"`
	ThinPool      *struct {
		DataUsedFraction     float64  `json:"data_used_fraction"`
		MetadataUsedFraction *float64 `json:"metadata_used_fraction"`
	} `json:"thin_pool,omitempty"`
	Smart struct {
		Health               string `json:"health"`
		TemperatureC         *int   `json:"temperature_c"`
		PowerOnHours         *int   `json:"power_on_hours"`
		ReallocatedSectors   *int   `json:"reallocated_sectors"`
		PendingSectors       *int   `json:"pending_sectors"`
		OfflineUncorrectable *int   `json:"offline_uncorrectable"`
		CriticalWarning      *int   `json:"critical_warning"`
		MediaErrors          *int   `json:"media_errors"`
		PercentageUsed       *int   `json:"percentage_used"`
	} `json:"smart"`
}

// handleHostReport ingests the agent's host-report (the heartbeat) and returns the
// control envelope (agent spec §5).
func (h *Handler) handleHostReport(w http.ResponseWriter, r *http.Request) {
	hostID, custID, isGlobal, ok := h.checkAuthHost(r)
	if !ok {
		http.Error(w, "Unauthorized", http.StatusUnauthorized)
		return
	}

	body, err := io.ReadAll(io.LimitReader(r.Body, maxHostReportBytes+1))
	if err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}
	if len(body) > maxHostReportBytes {
		http.Error(w, "Payload too large", http.StatusRequestEntityTooLarge)
		return
	}
	var rep hostReportPayload
	if err := json.Unmarshal(body, &rep); err != nil || rep.HostID == "" {
		http.Error(w, "Invalid payload: host_id required", http.StatusBadRequest)
		return
	}

	if isGlobal {
		// Global-key bootstrap: trust body.host_id but require the host to exist
		// (it must be minted first) and resolve its customer from the row.
		host, err := h.store.GetHost(rep.HostID)
		if err != nil {
			h.logger.Printf("[ERROR] host lookup failed for %s: %v", rep.HostID, err)
			http.Error(w, "Internal error", http.StatusInternalServerError)
			return
		}
		if host == nil {
			http.Error(w, "Unknown host_id (mint via /admin/hosts first)", http.StatusBadRequest)
			return
		}
		hostID, custID = rep.HostID, host.CustomerID
	} else if rep.HostID != hostID {
		http.Error(w, "Forbidden: host_id mismatch", http.StatusForbidden)
		return
	}

	running := 0
	for _, g := range rep.Guests {
		if g.Status == "running" {
			running++
		}
	}
	denorm := store.HostReportDenorm{
		AgentVersion:      rep.AgentVersion,
		CPUPercent:        rep.Host.CPUPercent,
		MemoryPercent:     rep.Host.MemoryPercent,
		DiskPercent:       rep.Host.DiskPercent,
		GuestTotal:        len(rep.Guests),
		GuestRunning:      running,
		CloudflaredStatus: rep.Cloudflared.Status,
	}
	if err := h.store.SaveHostReport(hostID, custID, body, denorm); err != nil {
		h.logger.Printf("[ERROR] Failed to save host-report from %s: %v", hostID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}

	for _, g := range rep.Guests {
		status := g.Status
		if status == "" {
			status = "unknown"
		}
		guest := &store.Guest{
			GuestID:           store.GuestID(hostID, g.VMID),
			CustomerID:        custID,
			HostID:            hostID,
			VMID:              g.VMID,
			DisplayName:       g.Name,
			Status:            status,
			ControllerVersion: g.ControllerVersion,
		}
		if err := h.store.UpsertGuestFromReport(guest); err != nil {
			// A guest upsert failure must not drop the whole report (liveness).
			h.logger.Printf("[WARN] Failed to upsert guest %s: %v", guest.GuestID, err)
		}
	}

	// storage_targets (slice 5): persisted as part of report_json above. Count + surface
	// disconnected ones in the log (the slice-10 manifest will reconcile them; for now the
	// signal is the visibility — a disconnected target is the storage analog of host-down).
	disconnected := 0
	for _, st := range rep.StorageTargets {
		if st.State == "disconnected" {
			disconnected++
		}
	}
	if disconnected > 0 {
		h.logger.Printf("[WARN] host %s reports %d disconnected storage target(s) of %d",
			hostID, disconnected, len(rep.StorageTargets))
	}

	// restore_tests (slice 6): a FAILED self-restore-test is the loudest DR signal there is
	// — surface it prominently. A PASS that carried start warnings (e.g. the systemd-nesting
	// advisory) is surfaced too: INFO when every warning is recognized-benign, escalated to
	// WARN when an UNRECOGNIZED warning stood out (as loud as a failed PBS verify is for
	// backups), so a real restore warning can't hide behind a green pass. A backup whose
	// vzdump failed is also worth a warning.
	for _, rt := range rep.RestoreTests {
		switch {
		case !rt.Pass:
			h.logger.Printf("[WARN] host %s restore-test FAILED: archive=%s tier=%s scratch=%d err=%q",
				hostID, rt.SourceArchive, rt.SourceTier, rt.ScratchVMID, rt.Error)
		case len(rt.Warnings) == 0:
			// clean pass — nothing to surface here (counted in the summary line below).
		case rt.WarningsRecognized:
			h.logger.Printf("[INFO] host %s restore-test passed WITH WARNINGS (recognized): archive=%s tier=%s warnings=%v",
				hostID, rt.SourceArchive, rt.SourceTier, rt.Warnings)
		default:
			h.logger.Printf("[WARN] host %s restore-test passed WITH UNRECOGNIZED WARNINGS: archive=%s tier=%s warnings=%v",
				hostID, rt.SourceArchive, rt.SourceTier, rt.Warnings)
		}
	}
	for _, bk := range rep.Backups {
		if !bk.Success {
			h.logger.Printf("[WARN] host %s backup FAILED: target=%s vmid=%d err=%q",
				hostID, bk.TargetID, bk.VMID, bk.Error)
		}
	}
	// pbs_snapshots (slice 6 Phase B): a FAILED PBS verify is the loudest offsite-DR signal.
	for _, ps := range rep.PBSSnapshots {
		if ps.VerifyState == "failed" {
			h.logger.Printf("[WARN] host %s PBS verify FAILED: %s/%s ns=%s owner=%s",
				hostID, ps.BackupType, ps.BackupID, ps.Namespace, ps.Owner)
		}
	}

	h.logger.Printf("[INFO] host-report from %s (%d guests, %d storage targets, %d backups, %d restore-tests, %d pbs-snapshots, %d bytes)",
		hostID, len(rep.Guests), len(rep.StorageTargets), len(rep.Backups), len(rep.RestoreTests), len(rep.PBSSnapshots), len(body))

	// Agent log tail (v0.46.0) — the debug-ring bundle a prior envelope requested.
	// SaveLogBundle runs the secret gate + clears the pending request (consume-once).
	// A failure must NOT drop the heartbeat; just warn.
	if rep.LogTail != nil {
		blocked, berr := h.store.SaveLogBundle(hostID, store.LogBundleComponentAgent, rep.LogTail.CollectedAt, rep.LogTail.Lines)
		switch {
		case berr != nil:
			h.logger.Printf("[WARN] Failed to save agent log bundle for %s: %v", hostID, berr)
		case blocked:
			h.logger.Printf("[WARN] agent log bundle for %s BLOCKED: possible secret in log content — nothing stored", hostID)
		default:
			h.logger.Printf("[INFO] agent log bundle received for %s (%d lines)", hostID, len(rep.LogTail.Lines))
		}
	}

	// DR recipe — persist the agent's secret-free storage/guest/PBS half (preserving any app half).
	// A failure here must NOT drop the heartbeat (the report already saved); just warn.
	if len(rep.DRRecipe) > 0 && custID != "" {
		var ver drRecipeVersionOnly
		_ = json.Unmarshal(rep.DRRecipe, &ver)
		if err := h.store.SaveDRRecipeHostHalf(custID, hostID, ver.RecipeVersion, rep.DRRecipe); err != nil {
			h.logger.Printf("[WARN] Failed to save DR-recipe host-half for customer %s (host %s): %v", custID, hostID, err)
		} else {
			h.logger.Printf("[INFO] DR-recipe host-half stored for customer %s (host %s, v%d)", custID, hostID, ver.RecipeVersion)
		}
	}

	blocked := false
	if cc, err := h.store.GetCustomerConfig(custID); err == nil && cc != nil && cc.Status == "blocked" {
		blocked = true
	}

	// Control envelope (slice 10A): the cheap change-notification. desired_generation is the
	// host's current generation (the agent re-fetches the full desired-state only when it
	// advances past its cached one); has_signed_ops flags a non-empty signed-jobs queue (the
	// agent fetches/executes them in 10B). Both degrade safely to their slice-4 defaults on a
	// store error — a heartbeat must never fail on the control channel.
	var desiredGen int64
	if host, err := h.store.GetHost(hostID); err == nil && host != nil {
		desiredGen = host.DesiredGeneration
	}
	hasSignedOps := false
	if n, err := h.store.CountSignedJobs(hostID); err == nil && n > 0 {
		hasSignedOps = true
	}
	resp := map[string]interface{}{
		"status":                "ok",
		"poll_interval_seconds": defaultHostPollSeconds,
		"blocked":               blocked,
		"desired_generation":    desiredGen,
		"has_signed_ops":        hasSignedOps,
	}
	// v0.46.0 — pending agent log pull: the NEXT heartbeat carries log_tail (agent ≥
	// 0.83.0; older agents ignore the flag and the request stays visibly pending).
	if pending, err := h.store.PendingLogBundleRequest(hostID, store.LogBundleComponentAgent); err == nil && pending {
		resp["log_tail_requested"] = true
	}
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	json.NewEncoder(w).Encode(resp)
}

// handleAdminCreateHost mints a host identity (host_id + per-host api_key).
//
// PROVISIONAL (slice-3 bootstrap): global-key only, so the demo agent can
// authenticate before enrollment (slices 7–8) exists. Enrollment will mint host
// identity + pin signing keys; this endpoint should be removed/locked down then
// (tracked under doc 05 §11 auth-tightening at cutover).
func (h *Handler) handleAdminCreateHost(w http.ResponseWriter, r *http.Request) {
	_, _, isGlobal, ok := h.checkAuthHost(r)
	if !ok || !isGlobal {
		http.Error(w, "Forbidden: global key required", http.StatusForbidden)
		return
	}
	body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
	if err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}
	var req struct {
		CustomerID  string `json:"customer_id"`
		HostID      string `json:"host_id"`
		DisplayName string `json:"display_name"`
	}
	if err := json.Unmarshal(body, &req); err != nil || req.CustomerID == "" {
		http.Error(w, "Invalid payload: customer_id required", http.StatusBadRequest)
		return
	}
	cc, err := h.store.GetCustomerConfig(req.CustomerID)
	if err != nil {
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	if cc == nil {
		http.Error(w, "Unknown customer_id", http.StatusBadRequest)
		return
	}

	hostID := req.HostID
	if hostID == "" {
		sfx, err := configgen.RandomHex(3) // 6 hex chars — human-legible for the demo
		if err != nil {
			http.Error(w, "Internal error", http.StatusInternalServerError)
			return
		}
		hostID = req.CustomerID + "-" + sfx
	}
	apiKey, err := configgen.RandomHex(32)
	if err != nil {
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	if err := h.store.UpsertHost(&store.Host{HostID: hostID, CustomerID: req.CustomerID, APIKey: apiKey}); err != nil {
		h.logger.Printf("[ERROR] Failed to mint host for %s: %v", req.CustomerID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	h.logger.Printf("[INFO] provisional host mint: %s (customer %s)", hostID, req.CustomerID)
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusCreated)
	json.NewEncoder(w).Encode(map[string]string{"host_id": hostID, "api_key": apiKey})
}

// handleHostEnroll is the passphrase-authed, mint-once-reuse host enrollment for Day-0
// (option C, SPIKE-day0-firstboot-handshake-2026-06-26). It is the sibling of the
// global-key handleAdminCreateHost: the operator/host-bootstrap script carries ONLY the
// customer's retrieval passphrase (no global key in the field deploy path), POSTs the
// customer_id, and gets back the host credential — minted on first call, REUSED byte-for-
// byte on every subsequent call (so re-running the bootstrap never orphans a live agent's
// key). Auth (passphrase) is checked BEFORE any mint — a bad-auth call never writes a row.
// The proven GET /config/{id} controller pull and POST /admin/hosts escape hatch are
// untouched.
func (h *Handler) handleHostEnroll(w http.ResponseWriter, r *http.Request) {
	body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
	if err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}
	var req struct {
		CustomerID string `json:"customer_id"`
	}
	if err := json.Unmarshal(body, &req); err != nil || req.CustomerID == "" {
		http.Error(w, "Invalid payload: customer_id required", http.StatusBadRequest)
		return
	}

	// Passphrase auth — mirrors handleConfigRetrieve exactly (header, 404-then-401 order,
	// constant-time compare). Happens BEFORE any mint.
	password := r.Header.Get("X-Retrieval-Password")
	if password == "" {
		http.Error(w, "Unauthorized: X-Retrieval-Password header required", http.StatusUnauthorized)
		return
	}
	cc, err := h.store.GetCustomerConfig(req.CustomerID)
	if err != nil {
		h.logger.Printf("[ERROR] host-enroll: customer lookup failed for %s: %v", req.CustomerID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	if cc == nil {
		http.Error(w, "Not found", http.StatusNotFound)
		return
	}
	if subtle.ConstantTimeCompare([]byte(password), []byte(cc.RetrievalPassword)) != 1 {
		http.Error(w, "Unauthorized: invalid password", http.StatusUnauthorized)
		return
	}

	// Mint-once-reuse: an existing host for this customer is returned as-is (idempotent).
	existing, err := h.store.GetHostByCustomer(req.CustomerID)
	if err != nil {
		h.logger.Printf("[ERROR] host-enroll: host lookup failed for %s: %v", req.CustomerID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	if existing != nil {
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusOK)
		json.NewEncoder(w).Encode(map[string]string{"host_id": existing.HostID, "api_key": existing.APIKey})
		return
	}

	// First enroll: mint (mirrors handleAdminCreateHost's mint block).
	sfx, err := configgen.RandomHex(3) // 6 hex chars — host_id suffix
	if err != nil {
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	hostID := req.CustomerID + "-" + sfx
	apiKey, err := configgen.RandomHex(32)
	if err != nil {
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	if err := h.store.UpsertHost(&store.Host{HostID: hostID, CustomerID: req.CustomerID, APIKey: apiKey}); err != nil {
		h.logger.Printf("[ERROR] host-enroll: failed to mint host for %s: %v", req.CustomerID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	h.logger.Printf("[INFO] host enrolled: %s (customer %s)", hostID, req.CustomerID)

	// F2 (v0.57.0) — clean-slate reinstall of an existing customer. This mint path fires exactly
	// once per fresh host record (the clean-slate flow deletes the stale host, so re-enroll mints),
	// so it is the natural single-shot re-enroll hook. For a CLAIMED customer the fresh box has no
	// password; auto-issue a reset code so the customer isn't stranded at the claim page hunting for
	// the manual "request a new code" button (delivery rides the report ACK). No-op for an unclaimed
	// customer (first provision). Also re-issue offsite credentials to the fresh box (F3) — the
	// one-time offsite password only ever reached the OLD controller, so the fresh one has no target.
	h.reissueOnReenroll(cc)

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusCreated)
	json.NewEncoder(w).Encode(map[string]string{"host_id": hostID, "api_key": apiKey})
}

// reissueOnReenroll runs the F2+F3 clean-slate re-enrollment side effects for a customer whose box
// was wiped and re-minted a host record: re-issue the claim code (if claimed) and re-issue offsite
// credentials (if provisioned). Every action that fires emits a visible customer event. Best-effort
// and non-fatal — a failure here never fails the enrollment (the box is already minted).
func (h *Handler) reissueOnReenroll(cc *store.CustomerConfig) {
	// F2 — claim continuity.
	if h.claimEngine != nil {
		if gen, reissued, err := h.claimEngine.ReissueForReenroll(cc); err != nil {
			h.logger.Printf("[WARN] claim re-issue on re-enroll for %s failed: %v", cc.CustomerID, err)
		} else if reissued {
			if _, serr := h.store.SaveEvent(cc.CustomerID, "claim_reissued_reenroll", "info",
				fmt.Sprintf("Új beállító kódot küldtünk a szerver újratelepítése után (%d. generáció) az ügyfél címére.", gen),
				"", "hub"); serr != nil {
				h.logger.Printf("[WARN] save claim_reissued_reenroll for %s: %v", cc.CustomerID, serr)
			}
		}
	}
	// F3 — offsite continuity: re-stage the one-time offsite password to the fresh controller (the
	// one-time password only ever reached the OLD controller). ⚠ CORRECTED 2026-08-04 (R-196): this
	// used to claim "the re-issuer resets the restic repo password, which makes the OLD escrow blob
	// stale". It does not and cannot — the re-issuer resets the PROVIDER account password; the
	// repository password is generated on the box and never leaves it except sealed under R. The
	// provisioner does mark the escrow stale, but PRECAUTIONARILY (see the reasoning at
	// offsite.ReissueCredentials), not because this call rotated anything.
	// Skips silently when offsite isn't provisioned/enabled.
	if h.offsiteReissuer != nil {
		if err := h.offsiteReissuer(context.Background(), cc.CustomerID); err != nil {
			h.logger.Printf("[WARN] offsite re-issue on re-enroll for %s failed: %v", cc.CustomerID, err)
		}
	}
	// Direction-2 (v0.63.0): wake a long-polling controller so the re-staged claim code / offsite
	// password ride the next ACK in seconds, not on the 15-min cycle. Both legs above are
	// best-effort; an over-bump costs one cheap wake. (On the clean-slate path the controller
	// usually does not exist yet — its startup fetch covers that shape; a bump landing during a
	// fresh controller's FIRST hold is recorded as baseline without firing — the known open
	// observation, fixed later by carrying intent_gen in the report ACK. Out of scope here.)
	if h.intentHub != nil {
		h.intentHub.Bump(cc.CustomerID)
	}
}

// escrowUploadRequest is the agent→hub wire shape for the OPAQUE PBS recovery-code escrow blob
// (slice 7, doc 03 §8a). It MUST stay in lockstep with the agent's emit struct
// (felhom-agent cmd/felhom-agent escrowUploadRequest). The hub stores the bytes and NEVER decrypts
// them (it has no recovery code).
type escrowUploadRequest struct {
	BlobB64        string `json:"blob_b64"`        // base64 of the opaque R-wrapped blob (ciphertext)
	KeyFingerprint string `json:"key_fingerprint"` // for operator display only
	Posture        string `json:"posture"`         // e.g. "zero_knowledge"
	CreatedAt      string `json:"created_at"`      // RFC3339
	// Slice 10D.1 — optional DR bundle, stored alongside the K-escrow (both opaque/non-secret).
	IdentityBlobB64 string          `json:"identity_blob_b64,omitempty"` // age-wrapped {tunnel_token, pbs_token}
	DirectiveJSON   json.RawMessage `json:"directive,omitempty"`         // non-secret directive (pbs repo/ns, expected fp, tunnel id)
	// SLICE 3 — sha256 hex of the restic repo password sealed in the identity blob (non-reversible hash
	// of a 256-bit random secret — safe to store/serve; present only when a staged password was folded in).
	ResticPwSHA256 string `json:"restic_pw_sha256,omitempty"`
}

// handleHostEscrowPut stores a host's opaque escrow blob (doc 03 §8a). Authed with the PER-HOST key
// (a host may only write its own escrow; the global operator key is also accepted). The hub keeps
// the ciphertext and never opens it. Last-write-wins (rotation). No serving this slice (slice 10).
func (h *Handler) handleHostEscrowPut(w http.ResponseWriter, r *http.Request, pathHostID string) {
	authHostID, _, isGlobal, ok := h.checkAuthHost(r)
	if !ok {
		http.Error(w, "Unauthorized", http.StatusUnauthorized)
		return
	}
	if pathHostID == "" {
		http.Error(w, "Missing host_id", http.StatusBadRequest)
		return
	}
	// A per-host key may only write ITS OWN escrow; the global key may write any.
	if !isGlobal && authHostID != pathHostID {
		http.Error(w, "Forbidden: host_id mismatch", http.StatusForbidden)
		return
	}
	body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1 MB cap; the blob is ~hundreds of bytes
	if err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}
	var req escrowUploadRequest
	if err := json.Unmarshal(body, &req); err != nil || req.BlobB64 == "" {
		http.Error(w, "Invalid payload: blob_b64 required", http.StatusBadRequest)
		return
	}
	blob, err := base64.StdEncoding.DecodeString(req.BlobB64)
	if err != nil || len(blob) == 0 {
		http.Error(w, "Invalid payload: blob_b64 not valid base64", http.StatusBadRequest)
		return
	}
	createdAt := req.CreatedAt
	if createdAt == "" {
		createdAt = time.Now().UTC().Format(time.RFC3339)
	}
	// Store the OPAQUE bytes. No decrypt path exists — the hub cannot open this. Part B (v0.60.0):
	// when this upload supersedes a DIFFERENT-passphrase old blob, the old one is RETAINED (not
	// overwritten) so its recovery-code-recoverable history survives (Viktor's data-first ruling).
	superseded, prevPwSHA, serr := h.store.SaveHostEscrow(pathHostID, blob, req.KeyFingerprint, req.Posture, createdAt, req.ResticPwSHA256)
	if serr != nil {
		h.logger.Printf("[ERROR] Failed to store escrow for host %s: %v", pathHostID, serr)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	if superseded {
		n, _ := h.store.CountSupersededEscrow(pathHostID)
		h.logger.Printf("[INFO] escrow for host %s superseded a different-passphrase blob — RETAINED (now %d superseded blob(s) held)", pathHostID, n)
		// Hub-internal audit event (not gated by allowedEventTypes) — tied to the owning customer.
		if host, herr := h.store.GetHost(pathHostID); herr == nil && host != nil && host.CustomerID != "" {
			details, _ := json.Marshal(map[string]any{"host_id": pathHostID, "retained_count": n})
			if _, eerr := h.store.SaveEvent(host.CustomerID, "escrow_superseded", "info",
				"A korábbi helyreállítási csomag megőrizve (új kulcs érkezett).", string(details), "hub"); eerr != nil {
				h.logger.Printf("[WARN] escrow_superseded event save failed for %s: %v", pathHostID, eerr)
			}
			// R-197: the box's offsite DATA key demonstrably changed. Both halves of that comparison
			// have been stored since SLICE 3 and nothing read them — demo-felhom's key changed on
			// 2026-08-03 and nothing said so for thirteen hours.
			h.maybeEmitRepoKeyChanged(host.CustomerID, pathHostID, prevPwSHA, req.ResticPwSHA256, n)
		}
	}
	// Slice 10D.1: optionally store the IDENTITY escrow blob + the non-secret DR directive alongside
	// the K-escrow (both opaque / non-secret — no usable secret hub-side). Additive: a slice-7
	// upload without these is unchanged.
	if req.IdentityBlobB64 != "" {
		idBlob, derr := base64.StdEncoding.DecodeString(req.IdentityBlobB64)
		if derr != nil || len(idBlob) == 0 {
			http.Error(w, "Invalid payload: identity_blob_b64 not valid base64", http.StatusBadRequest)
			return
		}
		directive := req.DirectiveJSON
		if len(directive) == 0 || !json.Valid(directive) {
			directive = json.RawMessage("{}")
		}
		if err := h.store.SaveHostDRBundle(pathHostID, idBlob, string(directive)); err != nil {
			h.logger.Printf("[ERROR] Failed to store DR bundle for host %s: %v", pathHostID, err)
			http.Error(w, "Internal error", http.StatusInternalServerError)
			return
		}
		h.logger.Printf("[INFO] stored DR bundle for host %s (identity %d bytes + directive)", pathHostID, len(idBlob))
	}
	h.logger.Printf("[INFO] stored opaque escrow blob for host %s (%d bytes, posture=%s, fp=%s)",
		pathHostID, len(blob), req.Posture, req.KeyFingerprint)
	w.WriteHeader(http.StatusOK)
	w.Write([]byte(`{"status":"ok"}`))
}

// eventRepoKeyChanged (R-197) — the box's offsite restic REPOSITORY password changed, proven by the
// hub's own stored hashes. Hub-internal (not in allowedEventTypes, like escrow_superseded) and
// registered operator-only in notify.operatorOnlyEvents.
const eventRepoKeyChanged = "offsite_repo_key_changed"

// eventEscrowBlobServed (R-199) — a host retrieved its own sealed identity blob. Hub-internal,
// operator-only. See handleHostEscrowGet for why every retrieval is loud.
const eventEscrowBlobServed = "escrow_blob_served"

// escrowSelfServiceRetrieval is THE SINGLE DECISION POINT for the §8.2/§8.3 trade (R-199).
//
//	true  (§8.2, shipped v0.94.0) — a host may read its own blob whenever it authenticates as itself.
//	false (§8.3, the fallback)    — the same read additionally requires operator-armed recovery mode.
//
// It is one condition on purpose: the operator may overrule the trade below, and switching must cost a
// boolean rather than a redesign. Everything else in the recovery chain is identical either way.
const escrowSelfServiceRetrieval = true

// handleHostEscrowGet serves a host its OWN opaque identity-escrow blob (R-199, v0.94.0).
//
// WHAT THIS GIVES OUT, WHY IT IS SAFE, AND WHAT IT CHANGES ABOUT WHO IS REQUIRED — recorded here so the
// next reader finds the trade rather than inferring it (the dr.go header convention).
//
// WHAT: the age-wrapped `IdentityBundle` — opaque ciphertext. It carries the offsite restic repository
// password, the tunnel token, the PBS token and the WG key. The hub stores these bytes and has no
// decrypt path; the recovery code R that opens them exists only in the customer's hands.
//
// WHY IT IS SAFE TO GIVE OUT: the blob is useless without R (age scrypt + ChaCha20-Poly1305; a wrong R
// fails closed at the KDF, never to a plausible-but-wrong bundle), and a 10-word EFF code carries ~129
// bits. The caller already authenticates as this host for its report, its desired state, its WG
// registration and its PBS token — this adds no new identity, only a new object, and it is the exact
// MIRROR of the PUT above, which is how the blob got here in the first place.
//
// WHAT IT CHANGES, STATED PLAINLY BECAUSE IT IS THE WHOLE OF THE TRADE: before this, obtaining the blob
// required the OPERATOR to arm recovery mode with the global key (dr.go). Now whoever controls a
// rebuilt box can obtain it with that box's own credential. That is a real reduction in the number of
// parties required. They still cannot open it. The mitigation is that the capability is AUDITED rather
// than silent: every successful retrieval raises an operator event (below), because a silent capability
// is the shape this project has spent two weeks removing.
//
// THE OPERATOR-DRIVEN DR PATH IS UNTOUCHED. `handleReEnroll` / `handleGetRestoreDirective` keep their
// recovery-mode gate and their global-key arming, and they serve the K-escrow and the directive as
// well. This endpoint serves ONE object to ONE authenticated owner. Do not merge them.
func (h *Handler) handleHostEscrowGet(w http.ResponseWriter, r *http.Request, pathHostID string) {
	authHostID, _, isGlobal, ok := h.checkAuthHost(r)
	if !ok {
		http.Error(w, "Unauthorized", http.StatusUnauthorized)
		return
	}
	if pathHostID == "" {
		http.Error(w, "Missing host_id", http.StatusBadRequest)
		return
	}
	// SELF-SCOPED: a per-host key reads only its OWN escrow. The global operator key may read any —
	// the same asymmetry the PUT has. Without this line any host key is a fleet-wide blob reader.
	if !isGlobal && authHostID != pathHostID {
		h.logger.Printf("[WARN] escrow GET REFUSED: host %s asked for %s's blob (self-scope)", authHostID, pathHostID)
		http.Error(w, "Forbidden: host_id mismatch", http.StatusForbidden)
		return
	}
	host, err := h.store.GetHost(pathHostID)
	if err != nil {
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	if host == nil {
		http.Error(w, "Unknown host_id", http.StatusNotFound)
		return
	}
	// §8.3 fallback lives here and nowhere else.
	if !escrowSelfServiceRetrieval && !host.InRecoveryMode(time.Now().UTC()) {
		h.logger.Printf("[WARN] escrow GET REFUSED for %s — self-service retrieval is disabled and recovery mode is not armed", pathHostID)
		http.Error(w, "Forbidden: host not in recovery mode (operator must arm it)", http.StatusForbidden)
		return
	}

	bundle, berr := h.store.GetHostDRBundle(pathHostID)
	if berr != nil {
		h.logger.Printf("[ERROR] escrow GET for %s: %v", pathHostID, berr)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	// A host with no sealed bundle gets a CLEAN ANSWER, not a fault: 200 with present=false. A 404
	// here would be indistinguishable from an unknown host, and an empty 200 without the flag would be
	// indistinguishable from a zero-length blob — both read as "something is broken" to a caller whose
	// situation is simply "no ceremony has run yet".
	if bundle == nil || len(bundle.IdentityBlob) == 0 {
		h.logger.Printf("[INFO] escrow GET for %s: no identity blob stored (no ceremony has run)", pathHostID)
		writeJSON(w, http.StatusOK, map[string]any{"host_id": pathHostID, "present": false, "identity_escrow_b64": ""})
		return
	}

	// THE MITIGATION (§8.2). Recorded BEFORE the bytes leave, so a retrieval cannot be served without
	// its audit row; a save failure is logged and does NOT block the response (the blob is opaque and
	// refusing it would break a recovery over an audit hiccup — but the log line always exists).
	//
	// SEVERITY = warning, i.e. it reaches the operator by e-mail. Retrieval is not routine today: it
	// happens during a recovery and nowhere else. IF a customer-facing self-service flow ever makes it
	// routine, revisit this — but revisit it deliberately, do not let it decay to info because the
	// mail became annoying.
	if host.CustomerID != "" {
		msg := fmt.Sprintf("Recovery blob served: host %s retrieved its own sealed identity escrow (%d opaque bytes). "+
			"This is the recovery path in use — the blob cannot be opened without the customer's recovery code, which the hub never holds. "+
			"If no recovery is in progress on that box, investigate.", pathHostID, len(bundle.IdentityBlob))
		details, _ := json.Marshal(map[string]any{
			"host_id":    pathHostID,
			"blob_bytes": len(bundle.IdentityBlob),
			"self_scope": !isGlobal,
		})
		if _, eerr := h.store.SaveEvent(host.CustomerID, eventEscrowBlobServed, "warning", msg, string(details), "hub"); eerr != nil {
			h.logger.Printf("[WARN] %s event save FAILED for %s (serving anyway): %v", eventEscrowBlobServed, pathHostID, eerr)
		} else if h.dispatcher != nil {
			go h.dispatcher.ProcessEvent(host.CustomerID, eventEscrowBlobServed, "warning", msg, string(details), "hub")
		}
	}
	h.logger.Printf("[WARN] escrow blob SERVED to host %s (%d opaque bytes, self_scope=%v) — recovery path in use",
		pathHostID, len(bundle.IdentityBlob), !isGlobal)
	writeJSON(w, http.StatusOK, map[string]any{
		"host_id":             pathHostID,
		"present":             true,
		"identity_escrow_b64": base64.StdEncoding.EncodeToString(bundle.IdentityBlob),
	})
}

// handleHostEscrowRetainedGet serves a host its own RETAINED (superseded) sealed identity packages.
//
// ── WHY THIS EXISTS (R-311) ─────────────────────────────────────────────────────────────────────
//
// `ListSupersededEscrow` has been the only reader of a retained identity blob since v0.93.0 and had
// ZERO production callers — every call site was a test. The consequence, measured on the fixture
// 2026-08-12: a recovery code that demonstrably opens a retained package (proven by hand: unsealed,
// and it restored planted files byte-identical from a store the box could no longer open) was
// reported to the customer as a code that opened nothing. The screen already hedged that with two
// possible causes (R-222/R-226) and said it could not tell them apart. **It could not tell them
// apart because nothing ever looked.** This endpoint is what makes looking possible.
//
// ── WHAT THIS DOES NOT CHANGE ───────────────────────────────────────────────────────────────────
//
// The hub still cannot read any of it. Sealed bytes in, sealed bytes out; there is no decrypt path
// here and the hub has never held a recovery code. What DOES widen is volume: a host key that could
// previously fetch one opaque package can now fetch N. The trade is stated rather than waved through
// — see the audit — and it is bounded three ways: the same self-scope as the current row, the same
// recovery-mode gate, and an explicit cap so a host with a long supersession history cannot turn one
// request into an unbounded read.
//
// Rows whose `identity_blob` is NULL are NOT served and are counted separately. They are the
// pre-v0.93.0 rows; they retain the PBS key and not the repository password, so they can never open
// anything the caller is asking about. Serving them would make the agent try packages that cannot
// succeed and would let the screen claim an earlier package is openable when it is not — the same
// false-explanation trap `SupersededPresent` already avoids at store.go. They are counted because
// their EXISTENCE is a true and load-bearing fact: on those boxes a correct old code opens nothing,
// and the honest reason is a defect of ours, not the customer's typing.
const retainedEscrowServeCap = 16

// RetainedEscrowPackage / RetainedEscrowResponse are the WIRE for GET /hosts/<id>/escrow/retained.
//
// They are named types rather than a `map[string]any` on purpose: the wire-contract gate resolves a
// declared ROOT by TYPE, so an untyped map is a cross-repo contract the gate cannot see. This wire is
// declared in `scripts/wire_contract_gate.py` ROOTS — hub → agent — and the agent's mirror is
// `felhom-agent/internal/hub.RetainedEscrowResponse`. Change a tag here and the gate fails there,
// which is the entire point.
type RetainedEscrowPackage struct {
	// Index is a label WITHIN ONE RESPONSE. Not durable, never persisted, never a lookup key.
	Index int `json:"index"`
	// SupersededAt is when this package stopped being the current one. It is the only field here a
	// customer ever sees — it is how they recognise which recovery code they are holding.
	SupersededAt   string `json:"superseded_at"`
	KeyFingerprint string `json:"key_fingerprint"`
	// IdentityEscrowB64 is OPAQUE ciphertext. The hub cannot open it and never could.
	IdentityEscrowB64 string `json:"identity_escrow_b64"`
}

type RetainedEscrowResponse struct {
	HostID string `json:"host_id"`
	Count  int    `json:"count"`
	// UnopenableCount is the number of retained rows withheld because they carry no key material
	// (every pre-v0.93.0 row). Underivable by the caller and load-bearing: on a box with only those,
	// a perfectly correct old code opens nothing and the reason is a defect of ours.
	UnopenableCount int                     `json:"unopenable_count"`
	TruncatedCount  int                     `json:"truncated_count"`
	Packages        []RetainedEscrowPackage `json:"packages"`
}

func (h *Handler) handleHostEscrowRetainedGet(w http.ResponseWriter, r *http.Request, pathHostID string) {
	authHostID, _, isGlobal, ok := h.checkAuthHost(r)
	if !ok {
		http.Error(w, "Unauthorized", http.StatusUnauthorized)
		return
	}
	if pathHostID == "" {
		http.Error(w, "Missing host_id", http.StatusBadRequest)
		return
	}
	// SELF-SCOPED, identically to the current-row GET. Without this line any host key is a fleet-wide
	// reader of every retained package, which is strictly worse than the same hole on one row.
	if !isGlobal && authHostID != pathHostID {
		h.logger.Printf("[WARN] retained escrow GET REFUSED: host %s asked for %s's packages (self-scope)", authHostID, pathHostID)
		http.Error(w, "Forbidden: host_id mismatch", http.StatusForbidden)
		return
	}
	host, err := h.store.GetHost(pathHostID)
	if err != nil {
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	if host == nil {
		http.Error(w, "Unknown host_id", http.StatusNotFound)
		return
	}
	// The SAME §8.3 gate as the current row. A retained package is not less sensitive than the
	// current one; if self-service retrieval is ever switched off, it must go dark with it.
	if !escrowSelfServiceRetrieval && !host.InRecoveryMode(time.Now().UTC()) {
		h.logger.Printf("[WARN] retained escrow GET REFUSED for %s — self-service retrieval is disabled and recovery mode is not armed", pathHostID)
		http.Error(w, "Forbidden: host not in recovery mode (operator must arm it)", http.StatusForbidden)
		return
	}

	retained, rerr := h.store.ListSupersededEscrow(pathHostID)
	if rerr != nil {
		h.logger.Printf("[ERROR] retained escrow GET for %s: %v", pathHostID, rerr)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	packages := make([]RetainedEscrowPackage, 0, len(retained))
	unopenable := 0
	for _, e := range retained {
		if len(e.IdentityBlob) == 0 {
			unopenable++
			continue
		}
		if len(packages) >= retainedEscrowServeCap {
			continue
		}
		packages = append(packages, RetainedEscrowPackage{
			Index:             len(packages),
			SupersededAt:      e.UpdatedAt,
			KeyFingerprint:    e.KeyFingerprint,
			IdentityEscrowB64: base64.StdEncoding.EncodeToString(e.IdentityBlob),
		})
	}
	truncated := 0
	if n := len(retained) - unopenable; n > len(packages) {
		truncated = n - len(packages)
	}

	// The audit row is written BEFORE the bytes leave, exactly as the current-row GET does, so a
	// retrieval cannot be served without its record. Severity matches that path deliberately: this is
	// a recovery in progress and nothing else, and it must reach the operator by e-mail.
	if host.CustomerID != "" && len(packages) > 0 {
		msg := fmt.Sprintf("Retained recovery packages served: host %s retrieved %d retained sealed package(s) (%d unopenable pre-v0.93.0 row(s) withheld). "+
			"This is the recovery path in use — the packages cannot be opened without the customer's recovery code, which the hub never holds. "+
			"If no recovery is in progress on that box, investigate.", pathHostID, len(packages), unopenable)
		details, _ := json.Marshal(map[string]any{
			"host_id":    pathHostID,
			"served":     len(packages),
			"unopenable": unopenable,
			"truncated":  truncated,
			"self_scope": !isGlobal,
		})
		if _, eerr := h.store.SaveEvent(host.CustomerID, eventEscrowBlobServed, "warning", msg, string(details), "hub"); eerr != nil {
			h.logger.Printf("[WARN] %s event save FAILED for %s (serving anyway): %v", eventEscrowBlobServed, pathHostID, eerr)
		} else if h.dispatcher != nil {
			go h.dispatcher.ProcessEvent(host.CustomerID, eventEscrowBlobServed, "warning", msg, string(details), "hub")
		}
	}
	h.logger.Printf("[WARN] retained escrow SERVED to host %s (%d package(s), %d unopenable withheld, %d over cap, self_scope=%v)",
		pathHostID, len(packages), unopenable, truncated, !isGlobal)
	writeJSON(w, http.StatusOK, RetainedEscrowResponse{
		HostID:          pathHostID,
		Count:           len(packages),
		UnopenableCount: unopenable,
		TruncatedCount:  truncated,
		Packages:        packages,
	})
}

// maybeEmitRepoKeyChanged raises ONE operator signal per supersession when the sealed offsite repo
// password demonstrably changed. Both hashes have been stored since SLICE 3 (host_escrow and, since
// v0.60.0, host_escrow_superseded) and NOTHING compared them: demo-felhom's repository password
// changed on 2026-08-03, orphaning 36 snapshots / 1.14 GB, and no event, e-mail, card or log line
// said so for thirteen hours — the comparison that eventually found it is this one
// (audits/RECON-offsite-dr-chain-2026-08-04.md, R-197).
//
// THE PREDICATE IS DELIBERATELY NARROW: both hashes known AND different. A first-ever hash (prev "")
// is onboarding, not a change; a hash-less supersession (now "") cannot show a change happened; an
// identical hash is a re-ceremony of the SAME password, which is a normal healthy act and must stay
// silent or a customer is punished for re-running a ceremony. The in-between shapes are LOGGED rather
// than dropped, so "we chose not to alarm" and "the check did not run" never look identical.
//
// SEVERITY = warning, chosen for the world v0.93.0 creates rather than the one it inherits. Before
// R-198 a changed key meant the previous history was unopenable by anyone, ever — that would have
// argued for error. From v0.93.0 the superseding ceremony RETAINS the old identity blob, so the
// previous history stays recoverable with the recovery code that sealed it: the situation is "this
// customer's off-site history now depends on an older recovery code", which is operator-actionable
// (check the orphan card, expect a fresh repository) and is not a loss. warning also routes: the
// dispatcher notifies on warning/error/critical and treats info as an intentional non-notify, and
// the whole point of this row is that the operator learns on the day.
//
// EDGE-TRIGGERED: called only from the superseded branch of the escrow PUT, i.e. once per
// supersession, never per report. No timer lives here — the dispatcher owns cooldown.
//
// NO HASH VALUE TRAVELS. The message and the details name the host, the customer and the retained
// count only. The hashes are non-reversible, but a hash is still a fingerprint of a live secret and
// this project's rule is that values do not leave the store.
func (h *Handler) maybeEmitRepoKeyChanged(customerID, hostID, prevSHA, newSHA string, retained int) {
	switch {
	case prevSHA == "":
		h.logger.Printf("[INFO] escrow for host %s: no previous repo-password hash recorded (first hash, or a legacy hash-less blob) — repo-key-change check not applicable", hostID)
		return
	case newSHA == "":
		h.logger.Printf("[WARN] escrow for host %s: the NEW blob carries no repo-password hash (hash-less supersession) — whether the repository key changed CANNOT be determined from the hub's data", hostID)
		return
	case prevSHA == newSHA:
		return // same password re-sealed: a healthy re-ceremony (Scenario E — silence is correct)
	}
	msg := fmt.Sprintf("Offsite repository key CHANGED for host %s: the new escrow seals a different repository password than the one it replaced. "+
		"The previous off-site history is no longer opened by this box's current key. The superseding blob was retained (%d held), so that history stays recoverable with the recovery code that sealed it — verify the box's off-site tier reports a repository rather than an orphan card, and expect the next backup to start a fresh history.",
		hostID, retained)
	details, _ := json.Marshal(map[string]any{
		"host_id":        hostID,
		"retained_count": retained,
		"repo_key":       "changed", // never the hash values
	})
	if _, err := h.store.SaveEvent(customerID, eventRepoKeyChanged, "warning", msg, string(details), "hub"); err != nil {
		h.logger.Printf("[WARN] %s event save failed for %s: %v", eventRepoKeyChanged, hostID, err)
		return // audit row first: an e-mail without its event row lies (the OffsiteChecker convention)
	}
	h.logger.Printf("[WARN] offsite repository key CHANGED for host %s (customer %s) — previous history now depends on the superseded recovery code; %d retained blob(s)",
		hostID, customerID, retained)
	if h.dispatcher != nil {
		go h.dispatcher.ProcessEvent(customerID, eventRepoKeyChanged, "warning", msg, string(details), "hub")
	}
}

// handleHostRecoveryCredentialPut vaults a host's break-glass root@pam console credential (TASK G1).
// SELF-SCOPED (a host key writes only its own; global may write any) — day-0 posts it with the
// host api_key. The secret is stored at rest and NEVER logged (only the username + a length are
// logged). This is the human fallback for when both the sshd path AND the agent-independent
// auto-heal have failed: the operator retrieves it to reach the PVE web console (pveproxy — a
// failure domain distinct from sshd).
func (h *Handler) handleHostRecoveryCredentialPut(w http.ResponseWriter, r *http.Request, pathHostID string) {
	authHostID, _, isGlobal, ok := h.checkAuthHost(r)
	if !ok {
		http.Error(w, "Unauthorized", http.StatusUnauthorized)
		return
	}
	if pathHostID == "" {
		http.Error(w, "Missing host_id", http.StatusBadRequest)
		return
	}
	if !isGlobal && authHostID != pathHostID {
		http.Error(w, "Forbidden: host_id mismatch", http.StatusForbidden)
		return
	}
	body, err := io.ReadAll(io.LimitReader(r.Body, 1<<16)) // 64 KiB cap; a username+password is tiny
	if err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}
	var req struct {
		Username string `json:"username"`
		Password string `json:"password"`
	}
	if err := json.Unmarshal(body, &req); err != nil || req.Username == "" || req.Password == "" {
		http.Error(w, "Invalid payload: username + password required", http.StatusBadRequest)
		return
	}
	// The host must exist (mint-first) — a per-host key already proves it; the global path re-checks.
	if isGlobal {
		host, herr := h.store.GetHost(pathHostID)
		if herr != nil || host == nil {
			http.Error(w, "Unknown host_id", http.StatusBadRequest)
			return
		}
	}
	if err := h.store.SaveHostRecoveryCredential(pathHostID, req.Username, req.Password); err != nil {
		h.logger.Printf("[ERROR] Failed to vault recovery credential for host %s: %v", pathHostID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	// SECRET DISCIPLINE: log the username + a length only — NEVER the password.
	h.logger.Printf("[INFO] vaulted break-glass recovery credential for host %s (user=%s, secret %d chars)",
		pathHostID, req.Username, len(req.Password))
	w.WriteHeader(http.StatusOK)
	w.Write([]byte(`{"status":"ok"}`))
}

// handleAdminGetRecoveryCredential returns a host's vaulted break-glass credential to the OPERATOR
// (global key only — a per-host key must NOT read its own console password back out). This is the
// authenticated retrieval path the break-glass runbook uses. The response body carries the secret by
// necessity; it is never written to the hub log.
func (h *Handler) handleAdminGetRecoveryCredential(w http.ResponseWriter, r *http.Request, pathHostID string) {
	_, _, isGlobal, ok := h.checkAuthHost(r)
	if !ok || !isGlobal {
		http.Error(w, "Unauthorized", http.StatusUnauthorized) // operator/global key ONLY
		return
	}
	if pathHostID == "" {
		http.Error(w, "Missing host_id", http.StatusBadRequest)
		return
	}
	cred, err := h.store.GetHostRecoveryCredential(pathHostID)
	if err != nil {
		h.logger.Printf("[ERROR] Failed to read recovery credential for host %s: %v", pathHostID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	if cred == nil {
		http.Error(w, "No recovery credential vaulted for this host", http.StatusNotFound)
		return
	}
	h.logger.Printf("[INFO] operator retrieved break-glass recovery credential for host %s (user=%s)", pathHostID, cred.Username)
	resp, _ := json.Marshal(map[string]string{
		"host_id":  cred.HostID,
		"username": cred.Username,
		"password": cred.Secret,
		"set_at":   cred.SetAt.UTC().Format(time.RFC3339),
	})
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	w.Write(resp)
}

// handleGetDesiredState serves a host its authoritative desired-state (slice 10A). Per-host key,
// SELF-SCOPED: a host reads ONLY its own (the global operator key may read any). The agent fetches
// this when the heartbeat envelope's desired_generation has advanced past its cached one. The
// response carries the generation the state corresponds to, so the agent caches it atomically.
func (h *Handler) handleGetDesiredState(w http.ResponseWriter, r *http.Request, pathHostID string) {
	authHostID, _, isGlobal, ok := h.checkAuthHost(r)
	if !ok {
		http.Error(w, "Unauthorized", http.StatusUnauthorized)
		return
	}
	if pathHostID == "" {
		http.Error(w, "Missing host_id", http.StatusBadRequest)
		return
	}
	if !isGlobal && authHostID != pathHostID {
		http.Error(w, "Forbidden: host_id mismatch", http.StatusForbidden)
		return
	}
	host, err := h.store.GetHost(pathHostID)
	if err != nil {
		h.logger.Printf("[ERROR] desired-state lookup for %s: %v", pathHostID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	if host == nil {
		http.Error(w, "Unknown host_id", http.StatusNotFound)
		return
	}
	desired := host.DesiredJSON
	if strings.TrimSpace(desired) == "" {
		desired = "{}"
	}
	// S2: merge the hub-OWNED wireguard block at read time (no peer → pass-through unchanged;
	// the stored operator blob is never modified). See api/wg.go mergeWireguard.
	desired = h.mergeWireguard(pathHostID, desired)
	resp := map[string]interface{}{
		"generation":    host.DesiredGeneration,
		"desired_state": json.RawMessage(desired), // opaque to the hub — agent owns the schema
	}
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	json.NewEncoder(w).Encode(resp)
}

// handleGetJobs serves a host its pending signed-op blobs (slice 10A). Per-host key, SELF-SCOPED.
// The blobs are OPAQUE (the hub never forged or opened them); the agent verifies + executes them
// in 10B. 10A only serves the queue.
func (h *Handler) handleGetJobs(w http.ResponseWriter, r *http.Request, pathHostID string) {
	authHostID, _, isGlobal, ok := h.checkAuthHost(r)
	if !ok {
		http.Error(w, "Unauthorized", http.StatusUnauthorized)
		return
	}
	if pathHostID == "" {
		http.Error(w, "Missing host_id", http.StatusBadRequest)
		return
	}
	if !isGlobal && authHostID != pathHostID {
		http.Error(w, "Forbidden: host_id mismatch", http.StatusForbidden)
		return
	}
	jobs, err := h.store.GetSignedJobs(pathHostID)
	if err != nil {
		h.logger.Printf("[ERROR] jobs lookup for %s: %v", pathHostID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	out := make([]map[string]string, 0, len(jobs))
	for _, j := range jobs {
		out = append(out, map[string]string{
			"job_id":     j.JobID,
			"blob_b64":   base64.StdEncoding.EncodeToString(j.Blob),
			"created_at": j.CreatedAt,
		})
	}
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	json.NewEncoder(w).Encode(map[string]interface{}{"jobs": out})
}

// handleDeleteJob clears a processed job from a host's queue (slice 10B). Per-host key,
// SELF-SCOPED (a host clears only its own jobs; the global key may clear any). Idempotent.
func (h *Handler) handleDeleteJob(w http.ResponseWriter, r *http.Request, pathHostID, jobID string) {
	authHostID, _, isGlobal, ok := h.checkAuthHost(r)
	if !ok {
		http.Error(w, "Unauthorized", http.StatusUnauthorized)
		return
	}
	if pathHostID == "" || jobID == "" {
		http.Error(w, "Missing host_id or job_id", http.StatusBadRequest)
		return
	}
	if !isGlobal && authHostID != pathHostID {
		http.Error(w, "Forbidden: host_id mismatch", http.StatusForbidden)
		return
	}
	if err := h.store.DeleteSignedJob(pathHostID, jobID); err != nil {
		h.logger.Printf("[ERROR] delete job %s for %s: %v", jobID, pathHostID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	h.logger.Printf("[INFO] host %s cleared signed-op job %s (executed or rejected)", pathHostID, jobID)
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	w.Write([]byte(`{"status":"ok"}`))
}

// handleAdminSetDesiredState sets a host's desired-state (slice 10A). GLOBAL/operator key ONLY —
// a per-host key cannot author its own intent. The body is the desired-state JSON (opaque to the
// hub: it stores + serves bytes, never validates/interprets the schema — the agent/CLI owns it).
// Writing BUMPS desired_generation so the next heartbeat signals the agent to re-fetch.
func (h *Handler) handleAdminSetDesiredState(w http.ResponseWriter, r *http.Request, pathHostID string) {
	_, _, isGlobal, ok := h.checkAuthHost(r)
	if !ok || !isGlobal {
		http.Error(w, "Forbidden: global key required", http.StatusForbidden)
		return
	}
	if pathHostID == "" {
		http.Error(w, "Missing host_id", http.StatusBadRequest)
		return
	}
	body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
	if err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}
	// Validate it is well-formed JSON (the hub does not interpret the schema, but a malformed
	// blob would break the agent's parse — reject it at the door).
	if !json.Valid(body) {
		http.Error(w, "Invalid payload: body must be JSON", http.StatusBadRequest)
		return
	}
	// S2: the wireguard block is HUB-owned, merged at read time — an operator copy-paste of a
	// served desired-state must never write it back into the stored blob (it would go stale and
	// shadow the live assignment). Reject at the door.
	var topKeys map[string]json.RawMessage
	if err := json.Unmarshal(body, &topKeys); err == nil {
		if _, has := topKeys["wireguard"]; has {
			http.Error(w, "wireguard is hub-owned; register via POST /hosts/{id}/wg", http.StatusBadRequest)
			return
		}
	}
	gen, err := h.store.SetHostDesired(pathHostID, body)
	if err == sql.ErrNoRows {
		http.Error(w, "Unknown host_id", http.StatusNotFound)
		return
	}
	if err != nil {
		h.logger.Printf("[ERROR] set desired-state for %s: %v", pathHostID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	h.logger.Printf("[INFO] admin-set desired-state for host %s (generation now %d, %d bytes)", pathHostID, gen, len(body))
	if h.poker != nil {
		h.poker.PokeHost(pathHostID) // agent-plane immediate-sync (Direction-2a): generation bumped → nudge the box now
	}
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	json.NewEncoder(w).Encode(map[string]interface{}{"status": "ok", "generation": gen})
}

// handleAdminEnqueueJob appends an opaque signed-op blob to a host's queue (slice 10A). GLOBAL key
// ONLY. The blob is pre-signed off-hub (the hub holds no signing key); the hub stores it verbatim.
// This is the minimal operator path to seed the queue so HasSignedOps/serving are exercisable; the
// rich operator/signing UX is later. Execution is 10B.
func (h *Handler) handleAdminEnqueueJob(w http.ResponseWriter, r *http.Request, pathHostID string) {
	_, _, isGlobal, ok := h.checkAuthHost(r)
	if !ok || !isGlobal {
		http.Error(w, "Forbidden: global key required", http.StatusForbidden)
		return
	}
	if pathHostID == "" {
		http.Error(w, "Missing host_id", http.StatusBadRequest)
		return
	}
	host, err := h.store.GetHost(pathHostID)
	if err != nil || host == nil {
		http.Error(w, "Unknown host_id", http.StatusNotFound)
		return
	}
	body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
	if err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}
	var req struct {
		JobID   string `json:"job_id"`
		BlobB64 string `json:"blob_b64"`
	}
	if err := json.Unmarshal(body, &req); err != nil || req.BlobB64 == "" {
		http.Error(w, "Invalid payload: blob_b64 required", http.StatusBadRequest)
		return
	}
	blob, err := base64.StdEncoding.DecodeString(req.BlobB64)
	if err != nil || len(blob) == 0 {
		http.Error(w, "Invalid payload: blob_b64 not valid base64", http.StatusBadRequest)
		return
	}
	if req.JobID == "" {
		req.JobID, _ = configgen.RandomHex(8)
	}
	if err := h.store.EnqueueSignedJob(pathHostID, req.JobID, blob); err != nil {
		h.logger.Printf("[ERROR] enqueue job for %s: %v", pathHostID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	h.logger.Printf("[INFO] enqueued signed-op job %s for host %s (%d bytes)", req.JobID, pathHostID, len(blob))
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusCreated)
	json.NewEncoder(w).Encode(map[string]interface{}{"status": "ok", "job_id": req.JobID})
}

// handleClaimResetRequest is the controller-forwarded "Elfelejtett jelszó" (v0.50.0): the box
// asks the hub to email a fresh reset code to the REGISTERED customer address — the requester
// never chooses the destination. Auth: the customer's own report Bearer key (self-scoped).
// The response is deliberately neutral 200 on every authorized outcome (cap reached, email
// failure) — the customer-facing message is always "ha az e-mail cím regisztrálva van…"; the
// real outcome goes to the operator log + notification_log.
func (h *Handler) handleClaimResetRequest(w http.ResponseWriter, r *http.Request) {
	authCustomerID, isGlobal, ok := h.checkAuthCustomer(r)
	if !ok {
		http.Error(w, "Unauthorized", http.StatusUnauthorized)
		return
	}
	body, err := io.ReadAll(io.LimitReader(r.Body, 4096))
	if err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}
	var payload struct {
		CustomerID string `json:"customer_id"`
	}
	if err := json.Unmarshal(body, &payload); err != nil || payload.CustomerID == "" {
		http.Error(w, "Invalid payload: customer_id required", http.StatusBadRequest)
		return
	}
	if !isGlobal && authCustomerID != payload.CustomerID {
		http.Error(w, "Forbidden: customer_id mismatch", http.StatusForbidden)
		return
	}
	if h.claimEngine == nil {
		http.Error(w, "Claim engine not available", http.StatusServiceUnavailable)
		return
	}
	cfg, err := h.store.GetCustomerConfig(payload.CustomerID)
	if err != nil || cfg == nil {
		http.Error(w, "Not found", http.StatusNotFound)
		return
	}
	if err := h.claimEngine.RequestReset(cfg); err != nil {
		// Neutral to the box; loud to the operator (cap reached / send failure / no email).
		h.logger.Printf("[WARN] claim reset-request for %s: %v", payload.CustomerID, err)
	} else {
		h.logger.Printf("[INFO] claim reset-request for %s: reset code emailed to the registered address", payload.CustomerID)
	}
	// v0.52.0 (take-two F-15): serve the ACTIVE code state in the response — same shape and same
	// bcrypt-only guarantee as the report ACK — so the box accepts the emailed code the moment it
	// lands instead of waiting for the next ACK (~15 min). Served on every authorized outcome: on
	// a cap-reached refusal it is the unrotated row (a controller-side no-op by generation).
	resp := map[string]interface{}{"status": "ok"}
	if cs, err := h.store.GetClaim(payload.CustomerID); err == nil && cs != nil {
		resp["claim"] = map[string]interface{}{
			"code_hash":  cs.CodeHash,
			"generation": cs.Generation,
			"issued_at":  cs.IssuedAt.UTC().Format(time.RFC3339),
		}
	}
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	json.NewEncoder(w).Encode(resp)
}

// allowedEventTypes lists all valid event_type values the Hub accepts.
var allowedEventTypes = map[string]bool{
	// R-85: restore-test signals. Hub-GENERATED (source "hub"), but listed here on purpose —
	// allowedEventTypes is the project's single register of legitimate event types, and R-77's
	// lesson was that an event type missing from it ships as an inert seam. Neither has a
	// customerMessages entry, so both stay operator-tier.
	"restore_test_failed": true,
	"restore_test_stale":  true,

	// R-97a: the WHOLE-GUEST (vzdump) backup tier's outcome. Controller-pushed from
	// `internal/quiesce`, which until now had no route to the hub at all — on 2026-07-27 three failed
	// whole-guest backups and twelve app-stack stop/starts produced ZERO events.
	//
	// DELIBERATELY NOT `backup_failed`/`backup_completed`. Those two carry customerMessages entries
	// AND sit in demo-felhom's live enabled_events, so reusing them would email the CUSTOMER, in
	// Hungarian, that their backup failed — while it is still retrying behind the R-88 breaker. A
	// customer can take no action on a failed whole-guest backup.
	//
	// OPERATOR-ONLY IS ENFORCED BY `notify.operatorOnlyEvents`, NOT by the absence of a
	// customerMessages entry. v0.78.0 claimed the latter and was WRONG (corrected in v0.79.0/R-97c):
	// `FormatCustomerEmail` treats a missing entry as a fallback to the raw message, and the only
	// customer gate is `prefs.EnabledEvents` — configuration, which a customer or a future code path
	// can change. The register is checked before customer dispatch and logs a `skipped/operator_only`
	// row. Adding a type here does NOT make it operator-only; add it to that register too.
	"whole_guest_backup_failed":    true,
	"whole_guest_backup_recovered": true,

	// R-158 / R-167 (controller v0.191.0, decision D-c): a per-app Tier-1 recovery-unit capture
	// failed. Until then a `[WARN]` line in the controller reached no hub channel at all — the fifth
	// instance in this project of a mechanism built and left disconnected.
	//
	// DELIBERATELY NOT `backup_failed`, for exactly the reason recorded above for the whole-guest
	// pair: that type carries a customerMessages entry AND sits in the controller's
	// DefaultEnabledEvents, so reusing it emails the CUSTOMER, in Hungarian, about a failure they
	// cannot act on. R-158's original proposal named `backup_failed`; D-c routes this to the
	// operator, and where the two disagree D-c wins.
	//
	// OPERATOR-ONLY IS ENFORCED BY `notify.operatorOnlyEvents` — see the paragraph above. This entry
	// alone does NOT make it operator-only.
	"recovery_unit_capture_failed": true,
	// R-182. The per-run backup digest: one event at the end of a run, listing every app whose
	// backup failed or was refused. Allowlisting it is NOT what keeps it away from customers —
	// `notify.operatorOnlyEvents` is (see the comment there); both entries ship together and
	// `backup_run_digest_event_test.go` pins the pair.
	"backup_run_failures": true,

	// Controller-pushed events
	"controller_started":      true,
	"claim_lockout":           true, // v0.50.0 — claim/reset code brute-force lockout tripped
	"controller_updated":      true,
	"backup_completed":        true,
	"backup_failed":           true,
	"db_dump_completed":       true,
	"db_dump_failed":          true,
	"backup_integrity_ok":     true,
	"backup_integrity_failed": true,
	"crossdrive_completed":    true,
	"crossdrive_failed":       true,
	// controller v0.134.1 — enlarged offsite push refused by the quota gate (warning; the controller's
	// dynamic Hungarian message is customer-grade — deliberately NO customerMessages entry, which would
	// discard the numbers (templates.go:129 priority)).
	"offbox_enlarge_blocked": true,
	// controller v0.142.0 — offsite-repo continuity: the remote repo is orphaned (reinstall shape) /
	// was reset (move-aside + re-init). Customer-grade messages below.
	"offbox_repo_orphaned": true,
	"offbox_repo_reset":    true,
	"storage_disconnected": true,
	"storage_reconnected":  true,
	// controller v0.184.0 (E-2) — the assigned whole-guest backup TARGET drive is absent. Distinct
	// from storage_disconnected on purpose: that one says "a drive went away and some apps may have
	// stopped"; this one says "the thing that makes a backup survive a disk failure is gone", which
	// is a different action for the customer and a different urgency for the operator. Before this
	// the only signal was the tier's own failure at its next due cycle — up to ~24 h on the daily
	// local tier — i.e. the R-100 shape: a real fault visible only after a deadline elapsed.
	"backup_target_absent":   true,
	"backup_target_restored": true,
	"disk_warning":           true,
	"disk_critical":          true,
	// controller v0.169.0 — per-disk SMART degradation (Rendben→Figyelmeztetés/Hiba). The controller
	// sends a dynamic Hungarian message (disk label + the triggering attribute names), so — like
	// offbox_enlarge_blocked — there is deliberately NO customerMessages entry (which would discard the
	// specifics via the templates.go fallback priority).
	"disk_health_degraded":        true,
	"health_degraded":             true,
	"health_critical":             true,
	"health_recovered":            true,
	"app_deployed":                true,
	"app_removed":                 true,
	"app_start_failed":            true, // controller fix-3 (CAMPAIGN-3): a deployed app is not running
	"disaster_recovery_started":   true,
	"disaster_recovery_completed": true,
	// Controller→agent channel health (controller v0.90.0) — operator-only (not customer toggles)
	"agent_channel_pin_mismatch":       true,
	"agent_channel_unauthorized":       true,
	"agent_channel_unreachable":        true,
	"agent_channel_timeout":            true,
	"agent_channel_misconfigured":      true,
	"agent_channel_construction_error": true,
	"agent_channel_unknown":            true,
	"agent_channel_recovered":          true,
	// controller v0.173.0 (R-77): controller.yaml and bootstrap.json disagree on local_api.endpoint.
	// Operator-only, deliberately NOT an agent_channel_* type — during the 2026-07-25 island-migration
	// outage the generic "unreachable" alert was the only signal and it hid a specific config fault.
	"local_api_endpoint_drift": true,
	// Hub-generated events
	"node_stale":     true,
	"node_down":      true,
	"node_recovered": true,
	// v0.57.0 reinstall arc (F2/F3/2.3) — hub-emitted on clean-slate re-enrollment / offsite re-issue
	"claim_reissued_reenroll": true, // reset code auto-issued to a reinstalled claimed customer
	"offsite_reissued":        true, // offsite one-time password re-staged (manual button or re-enroll)
	"escrow_stale":            true, // key-escrow blob invalidated by an offsite password re-issue
	// Hub-generated host-domain events (v0.7.0, slice 3)
	"host_stale":     true,
	"host_down":      true,
	"host_recovered": true,
	// Hub-generated host root-fs disk-pressure (v0.23.0) — distinct from the controller's GUEST disk_*
	"host_disk_warning":      true,
	"host_disk_critical":     true,
	"storage_fill_warning":   true,
	"storage_fill_critical":  true,
	"expected_backup_missed": true,
	"expected_dbdump_missed": true,
	// Special
	"test": true,
}

// handleEvent processes structured events from controllers (new endpoint, replaces /notify for updated controllers).
func (h *Handler) handleEvent(w http.ResponseWriter, r *http.Request) {
	authCustomerID, isGlobal, ok := h.checkAuthCustomer(r)
	if !ok {
		http.Error(w, "Unauthorized", http.StatusUnauthorized)
		return
	}

	body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
	if err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}

	var payload struct {
		CustomerID string          `json:"customer_id"`
		EventType  string          `json:"event_type"`
		Severity   string          `json:"severity"`
		Message    string          `json:"message"`
		Details    json.RawMessage `json:"details"`
	}
	if err := json.Unmarshal(body, &payload); err != nil {
		http.Error(w, "Invalid JSON", http.StatusBadRequest)
		return
	}
	if payload.CustomerID == "" || payload.EventType == "" {
		http.Error(w, "customer_id and event_type are required", http.StatusBadRequest)
		return
	}

	// Validate customer_id matches authenticated customer (unless global key)
	if !isGlobal && authCustomerID != payload.CustomerID {
		http.Error(w, "Forbidden: customer_id mismatch", http.StatusForbidden)
		return
	}

	// Validate event_type
	if !allowedEventTypes[payload.EventType] {
		http.Error(w, fmt.Sprintf("Invalid event_type: %s", payload.EventType), http.StatusBadRequest)
		return
	}

	// Validate/default severity (exact-match lowercase; unknown values coerce to info)
	switch payload.Severity {
	case "info", "warning", "error", "critical":
	default:
		payload.Severity = "info"
	}

	// Store details as JSON string
	detailsStr := "{}"
	if len(payload.Details) > 0 && string(payload.Details) != "null" {
		detailsStr = string(payload.Details)
	}

	_, err = h.store.SaveEvent(payload.CustomerID, payload.EventType, payload.Severity, payload.Message, detailsStr, "controller")
	if err != nil {
		h.logger.Printf("[ERROR] Failed to save event from %s: %v", payload.CustomerID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}

	h.logger.Printf("[INFO] Event from %s: %s (%s) — %s", payload.CustomerID, payload.EventType, payload.Severity, payload.Message)

	// Dispatch notifications (non-blocking)
	if h.dispatcher != nil {
		go h.dispatcher.ProcessEvent(payload.CustomerID, payload.EventType, payload.Severity, payload.Message, detailsStr, "controller")
	}

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	w.Write([]byte(`{"ok":true}`))
}

func (h *Handler) handleCustomers(w http.ResponseWriter, r *http.Request) {
	customers, err := h.store.GetCustomers()
	if err != nil {
		h.logger.Printf("[ERROR] Failed to get customers: %v", err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}

	type customerJSON struct {
		ID                 string     `json:"id"`
		Name               string     `json:"name"`
		ControllerVersion  string     `json:"controller_version"`
		ControllerURL      string     `json:"controller_url,omitempty"`
		HealthStatus       string     `json:"health_status"`
		LastSeen           time.Time  `json:"last_seen"`
		CPUPercent         float64    `json:"cpu_percent"`
		MemoryPercent      float64    `json:"memory_percent"`
		ContainerTotal     int        `json:"container_total"`
		ContainerRunning   int        `json:"container_running"`
		BackupLastSnapshot *time.Time `json:"backup_last_snapshot"`
	}

	result := make([]customerJSON, 0, len(customers))
	for _, c := range customers {
		result = append(result, customerJSON{
			ID:                 c.CustomerID,
			Name:               c.CustomerName,
			ControllerVersion:  c.ControllerVersion,
			ControllerURL:      c.ControllerURL,
			HealthStatus:       c.HealthStatus,
			LastSeen:           c.ReceivedAt,
			CPUPercent:         c.CPUPercent,
			MemoryPercent:      c.MemoryPercent,
			ContainerTotal:     c.ContainerTotal,
			ContainerRunning:   c.ContainerRunning,
			BackupLastSnapshot: c.BackupLastSnapshot,
		})
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(result)
}

func (h *Handler) handleCustomer(w http.ResponseWriter, r *http.Request, customerID string) {
	customer, err := h.store.GetCustomer(customerID)
	if err != nil {
		h.logger.Printf("[ERROR] Failed to get customer %s: %v", customerID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	if customer == nil {
		http.NotFound(w, r)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	// Return the full report JSON directly
	w.Write([]byte(customer.ReportJSON))
}

func (h *Handler) handleCustomerHistory(w http.ResponseWriter, r *http.Request, customerID string) {
	period := r.URL.Query().Get("period")
	var since time.Duration
	switch period {
	case "7d":
		since = 7 * 24 * time.Hour
	case "30d":
		since = 30 * 24 * time.Hour
	default:
		since = 24 * time.Hour
	}

	history, err := h.store.GetCustomerHistory(customerID, since)
	if err != nil {
		h.logger.Printf("[ERROR] Failed to get history for %s: %v", customerID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}

	type historyEntry struct {
		ReceivedAt    time.Time `json:"received_at"`
		HealthStatus  string    `json:"health_status"`
		CPUPercent    float64   `json:"cpu_percent"`
		MemoryPercent float64   `json:"memory_percent"`
	}

	result := make([]historyEntry, 0, len(history))
	for _, h := range history {
		result = append(result, historyEntry{
			ReceivedAt:    h.ReceivedAt,
			HealthStatus:  h.HealthStatus,
			CPUPercent:    h.CPUPercent,
			MemoryPercent: h.MemoryPercent,
		})
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(result)
}

// handleNotify processes notification events from customer controllers.
func (h *Handler) handleNotify(w http.ResponseWriter, r *http.Request) {
	if !h.checkAuth(r) {
		http.Error(w, "Unauthorized", http.StatusUnauthorized)
		return
	}

	body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
	if err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}

	var payload struct {
		CustomerID string `json:"customer_id"`
		EventType  string `json:"event_type"`
		Severity   string `json:"severity"`
		Message    string `json:"message"`
		Details    string `json:"details"`
	}
	if err := json.Unmarshal(body, &payload); err != nil || payload.CustomerID == "" || payload.EventType == "" {
		http.Error(w, "Invalid payload: customer_id and event_type required", http.StatusBadRequest)
		return
	}

	h.logger.Printf("[INFO] Notification from %s: %s (%s) — %s", payload.CustomerID, payload.EventType, payload.Severity, payload.Message)

	// Check if customer is blocked
	if h.store.IsCustomerBlocked(payload.CustomerID) {
		h.logger.Printf("[INFO] Notification suppressed for blocked customer %s", payload.CustomerID)
		h.store.LogNotification(payload.CustomerID, payload.EventType, payload.Severity, payload.Message, "skipped", "customer blocked", "customer")
		w.WriteHeader(http.StatusOK)
		w.Write([]byte(`{"status":"ok","sent":false,"reason":"blocked"}`))
		return
	}

	// Look up customer notification preferences
	prefs, err := h.store.GetNotificationPrefs(payload.CustomerID)
	if err != nil {
		h.logger.Printf("[ERROR] Failed to get notification prefs for %s: %v", payload.CustomerID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}

	// Check if customer has email configured and event type is enabled
	if prefs == nil || prefs.Email == "" {
		h.logger.Printf("[INFO] No email configured for %s, skipping notification", payload.CustomerID)
		h.store.LogNotification(payload.CustomerID, payload.EventType, payload.Severity, payload.Message, "skipped", "no email configured", "customer")
		w.WriteHeader(http.StatusOK)
		w.Write([]byte(`{"status":"ok","sent":false,"reason":"no_email"}`))
		return
	}

	// Check if event type is in the enabled list (test events always pass)
	eventEnabled := payload.EventType == "test"
	for _, e := range prefs.EnabledEvents {
		if e == payload.EventType {
			eventEnabled = true
			break
		}
	}
	if !eventEnabled {
		h.logger.Printf("[INFO] Event %s not enabled for %s, skipping", payload.EventType, payload.CustomerID)
		h.store.LogNotification(payload.CustomerID, payload.EventType, payload.Severity, payload.Message, "skipped", "event not enabled", "customer")
		w.WriteHeader(http.StatusOK)
		w.Write([]byte(`{"status":"ok","sent":false,"reason":"event_disabled"}`))
		return
	}

	// Send email via Resend API
	if h.resendAPIKey == "" {
		h.logger.Printf("[WARN] Resend API key not configured, cannot send notification email")
		h.store.LogNotification(payload.CustomerID, payload.EventType, payload.Severity, payload.Message, "skipped", "resend api key not configured", "customer")
		w.WriteHeader(http.StatusOK)
		w.Write([]byte(`{"status":"ok","sent":false,"reason":"no_api_key"}`))
		return
	}

	subject, emailBody := formatNotificationEmail(payload.CustomerID, payload.EventType, payload.Severity, payload.Message, payload.Details)
	sendErr := h.sendResendEmail(prefs.Email, subject, emailBody)
	if sendErr != nil {
		h.logger.Printf("[ERROR] Failed to send notification email to %s: %v", prefs.Email, sendErr)
		h.store.LogNotification(payload.CustomerID, payload.EventType, payload.Severity, payload.Message, "failed", sendErr.Error(), "customer")
		http.Error(w, "Failed to send email", http.StatusInternalServerError)
		return
	}

	h.logger.Printf("[INFO] Notification email sent to %s for %s/%s", prefs.Email, payload.CustomerID, payload.EventType)
	h.store.LogNotification(payload.CustomerID, payload.EventType, payload.Severity, payload.Message, "sent", "", "customer")

	w.WriteHeader(http.StatusOK)
	w.Write([]byte(`{"status":"ok","sent":true}`))
}

// handleSavePreferences stores notification preferences pushed from a customer controller.
func (h *Handler) handleSavePreferences(w http.ResponseWriter, r *http.Request) {
	if !h.checkAuth(r) {
		http.Error(w, "Unauthorized", http.StatusUnauthorized)
		return
	}

	body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
	if err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}

	var payload struct {
		CustomerID    string   `json:"customer_id"`
		Email         string   `json:"email"`
		EnabledEvents []string `json:"enabled_events"`
		CooldownHours int      `json:"cooldown_hours"`
	}
	if err := json.Unmarshal(body, &payload); err != nil || payload.CustomerID == "" {
		http.Error(w, "Invalid payload: customer_id required", http.StatusBadRequest)
		return
	}

	// Empty-email no-clobber guard (v0.71.0, audit F12): a controller push with an empty email
	// (e.g. an unconfigured box) must never wipe a stored non-empty address — the seeded/edited
	// email is the customer's alert lifeline. Events + cooldown from the push still apply; a push
	// with a non-empty email updates everything (customer edits keep working).
	saveEmail := payload.Email
	if saveEmail == "" {
		if existing, err := h.store.GetNotificationPrefs(payload.CustomerID); err == nil && existing != nil && existing.Email != "" {
			saveEmail = existing.Email
			h.logger.Printf("[INFO] Notification prefs push for %s had empty email — preserving stored address", payload.CustomerID)
		}
	}

	if err := h.store.SaveNotificationPrefs(payload.CustomerID, saveEmail, payload.EnabledEvents, payload.CooldownHours); err != nil {
		h.logger.Printf("[ERROR] Failed to save notification prefs for %s: %v", payload.CustomerID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}

	h.logger.Printf("[INFO] Notification preferences updated for %s: email=%s, events=%v", payload.CustomerID, saveEmail, payload.EnabledEvents)
	w.WriteHeader(http.StatusOK)
	w.Write([]byte(`{"status":"ok"}`))
}

// handleRecovery returns the generated controller.yaml for disaster recovery.
// Auth: X-Retrieval-Password header (same as config retrieval).
//
// The infra-backup payload was retired (Phase-1, 2026-06-16): it pushed plaintext
// customer secrets to the hub (a zero-knowledge violation) and had been dead since
// slice 8C. DR config now comes from the generated controller.yaml here; the data
// bytes come from the agent's PBS whole-CT snapshot. A secret-free DR recipe is the
// later DR slice's job.
func (h *Handler) handleRecovery(w http.ResponseWriter, r *http.Request, customerID string) {
	if customerID == "" {
		http.Error(w, "Missing customer_id", http.StatusBadRequest)
		return
	}

	password := r.Header.Get("X-Retrieval-Password")
	if password == "" {
		http.Error(w, "Unauthorized: X-Retrieval-Password header required", http.StatusUnauthorized)
		return
	}

	cfg, err := h.store.GetCustomerConfig(customerID)
	if err != nil {
		h.logger.Printf("[ERROR] Recovery: failed to get customer config for %s: %v", customerID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	if cfg == nil {
		http.Error(w, "Not found", http.StatusNotFound)
		return
	}

	if subtle.ConstantTimeCompare([]byte(password), []byte(cfg.RetrievalPassword)) != 1 {
		http.Error(w, "Unauthorized: invalid password", http.StatusUnauthorized)
		return
	}

	// Generate controller.yaml. The claim state is baked read-only (no issue on the DR path — a
	// recovered box whose settings.json is gone re-gates on the EXISTING hash; the reset flow
	// covers a customer who lost the password with the box).
	var configYAML string
	if h.templateProvider != nil {
		var claimState *store.ClaimState
		if h.claimEngine != nil {
			claimState, _ = h.store.GetClaim(customerID)
		}
		yamlOutput, err := configgen.Generate(h.templateProvider.Template(), cfg, claimState)
		if err != nil {
			h.logger.Printf("[ERROR] Recovery: failed to generate config for %s: %v", customerID, err)
			http.Error(w, "Internal error", http.StatusInternalServerError)
			return
		}
		configYAML = yamlOutput
	}

	// infra_backup retired: the response keeps has_infra_backup=false so any old client
	// degrades gracefully to the config_yaml-only path.
	resp := struct {
		CustomerID     string `json:"customer_id"`
		ConfigYAML     string `json:"config_yaml"`
		HasInfraBackup bool   `json:"has_infra_backup"`
	}{
		CustomerID:     customerID,
		ConfigYAML:     configYAML,
		HasInfraBackup: false,
	}

	h.logger.Printf("[INFO] Recovery data downloaded for customer %s (config only; infra-backup retired)", customerID)
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(resp)
}

// handleConfigRetrieve returns a generated controller.yaml for a customer.
// Auth: X-Retrieval-Password header (not Bearer token).
func (h *Handler) handleConfigRetrieve(w http.ResponseWriter, r *http.Request, customerID string) {
	if customerID == "" {
		http.Error(w, "Missing customer_id", http.StatusBadRequest)
		return
	}

	password := r.Header.Get("X-Retrieval-Password")
	if password == "" {
		http.Error(w, "Unauthorized: X-Retrieval-Password header required", http.StatusUnauthorized)
		return
	}

	cfg, err := h.store.GetCustomerConfig(customerID)
	if err != nil {
		h.logger.Printf("[ERROR] Failed to get customer config for %s: %v", customerID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	if cfg == nil {
		http.Error(w, "Not found", http.StatusNotFound)
		return
	}

	// Constant-time comparison to prevent timing attacks
	if subtle.ConstantTimeCompare([]byte(password), []byte(cfg.RetrievalPassword)) != 1 {
		http.Error(w, "Unauthorized: invalid password", http.StatusUnauthorized)
		return
	}

	if h.templateProvider == nil {
		http.Error(w, "Config generation not available", http.StatusServiceUnavailable)
		return
	}

	// Customer-claim arc (v0.50.0): the REAL config pull (Day-0 installer / controller refresh) is
	// the Day-0 claim entry point — issue + email the first code here (idempotent), and bake the
	// active hash into the generated web.claim_code_hash so the box is gated from FIRST boot.
	// (The operator-UI preview deliberately does NOT issue — it only bakes an existing hash.)
	var claimState *store.ClaimState
	if h.claimEngine != nil {
		var cerr error
		claimState, cerr = h.claimEngine.EnsureIssued(cfg)
		if cerr != nil {
			// Loud but non-fatal: the config is still served; if a hash was stored the gate is armed
			// and the operator resends the email from the customer page.
			h.logger.Printf("[WARN] claim issue for %s on config retrieve: %v", customerID, cerr)
		}
	}

	yamlOutput, err := configgen.Generate(h.templateProvider.Template(), cfg, claimState)
	if err != nil {
		h.logger.Printf("[ERROR] Failed to generate config for %s: %v", customerID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}

	h.logger.Printf("[INFO] Config downloaded for customer %s", customerID)
	w.Header().Set("Content-Type", "text/yaml; charset=utf-8")
	w.Write([]byte(yamlOutput))
}

// artifactManifestResponse is the wire shape the host-bootstrap script consumes: the operator-vouched
// current agent binary + golden archive (version + sha256 each). The script fetches each artifact from
// Gitea with the config-retrieve git token and verifies its sha256 against THESE values before install
// — so the hub is the checksum trust root, a different root than Gitea (which only stores the bytes).
type artifactManifestResponse struct {
	Agent  artifactEntry `json:"agent"`
	Golden artifactEntry `json:"golden"`
}

type artifactEntry struct {
	Version string `json:"version"`
	SHA256  string `json:"sha256"`
}

// handleArtifactManifest serves the current artifact set for a customer. Auth mirrors
// handleConfigRetrieve EXACTLY (X-Retrieval-Password header, 404-then-401 order, constant-time
// compare) so a script that can pull the controller.yaml can pull the manifest with the same secret.
// v0.16.0 returns the GLOBAL current set for every customer (per-customer pinning is a future hook).
// An unset manifest returns empty fields (not an error) — the script falls back to the local golden
// and fails clearly on a missing binary.
func (h *Handler) handleArtifactManifest(w http.ResponseWriter, r *http.Request, customerID string) {
	if customerID == "" {
		http.Error(w, "Missing customer_id", http.StatusBadRequest)
		return
	}

	password := r.Header.Get("X-Retrieval-Password")
	if password == "" {
		http.Error(w, "Unauthorized: X-Retrieval-Password header required", http.StatusUnauthorized)
		return
	}

	cfg, err := h.store.GetCustomerConfig(customerID)
	if err != nil {
		h.logger.Printf("[ERROR] artifacts: customer lookup failed for %s: %v", customerID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	if cfg == nil {
		http.Error(w, "Not found", http.StatusNotFound)
		return
	}
	if subtle.ConstantTimeCompare([]byte(password), []byte(cfg.RetrievalPassword)) != 1 {
		http.Error(w, "Unauthorized: invalid password", http.StatusUnauthorized)
		return
	}

	m := h.store.GetArtifactManifest()
	resp := artifactManifestResponse{
		Agent:  artifactEntry{Version: m.AgentVersion, SHA256: m.AgentSHA256},
		Golden: artifactEntry{Version: m.GoldenVersion, SHA256: m.GoldenSHA256},
	}
	h.logger.Printf("[INFO] Artifact manifest served for customer %s (agent=%s golden=%s)", customerID, m.AgentVersion, m.GoldenVersion)
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	json.NewEncoder(w).Encode(resp)
}

// sendResendEmail sends an email via the Resend HTTP API.
func (h *Handler) sendResendEmail(to, subject, textBody string) error {
	payload := map[string]interface{}{
		"from":    h.fromEmail,
		"to":      []string{to},
		"subject": subject,
		"text":    textBody,
	}

	jsonData, err := json.Marshal(payload)
	if err != nil {
		return fmt.Errorf("marshaling email payload: %w", err)
	}

	req, err := http.NewRequest("POST", "https://api.resend.com/emails", bytes.NewReader(jsonData))
	if err != nil {
		return fmt.Errorf("creating request: %w", err)
	}
	req.Header.Set("Authorization", "Bearer "+h.resendAPIKey)
	req.Header.Set("Content-Type", "application/json")

	resp, err := h.httpClient.Do(req)
	if err != nil {
		return fmt.Errorf("sending request: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode >= 400 {
		respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
		return fmt.Errorf("resend API returned %d: %s", resp.StatusCode, string(respBody))
	}

	return nil
}

// formatNotificationEmail creates a Hungarian email subject and body.
func formatNotificationEmail(customerID, eventType, severity, message, details string) (string, string) {
	severityLabel := map[string]string{
		"info":     "Információ",
		"warning":  "Figyelmeztetés",
		"error":    "Hiba",
		"critical": "Kritikus",
	}
	label := severityLabel[severity]
	if label == "" {
		label = severity
	}

	subject := fmt.Sprintf("[Felhom] %s: %s", label, message)

	now := time.Now().Format("2006-01-02 15:04")
	emailText := fmt.Sprintf(`Kedves Ügyfél!

A Felhom rendszered a következő figyelmeztetést jelezte:

%s

Részletek:
- Szerver: %s
- Időpont: %s
- Szint: %s
- Típus: %s`, message, customerID, now, label, eventType)

	if details != "" {
		emailText += fmt.Sprintf("\n- Megjegyzés: %s", details)
	}

	emailText += `

Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel.

Üdvözlettel,
Felhom.eu monitoring`

	return subject, emailText
}

// --- Asset endpoints ---

func (h *Handler) handleAssetsManifest(w http.ResponseWriter, r *http.Request) {
	if !h.checkAuth(r) {
		http.Error(w, "Unauthorized", http.StatusUnauthorized)
		return
	}

	if h.assetsMgr == nil {
		http.Error(w, "Assets not configured", http.StatusServiceUnavailable)
		return
	}

	data, err := h.assetsMgr.MarshalManifestJSON()
	if err != nil {
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	w.Write(data)
}

func (h *Handler) handleAssetFile(w http.ResponseWriter, r *http.Request, filename string) {
	if !h.checkAuth(r) {
		http.Error(w, "Unauthorized", http.StatusUnauthorized)
		return
	}

	if h.assetsMgr == nil {
		http.Error(w, "Assets not configured", http.StatusServiceUnavailable)
		return
	}

	h.assetsMgr.ServeFile(w, r, filename)
}
