package web

import (
	"context"
	"encoding/json"
	"fmt"
	"html/template"
	"net/http"
	"regexp"
	"sort"
	"strconv"
	"strings"
	"time"

	cfClient "gitea.dooplex.hu/admin/felhom-hub/internal/cloudflare"
	"gitea.dooplex.hu/admin/felhom-hub/internal/configgen"
	"gitea.dooplex.hu/admin/felhom-hub/internal/offsite"
	"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)

var validCustomerID = regexp.MustCompile(`^[a-zA-Z0-9.\-]+$`)

// NOTE (R-94, 2026-08-02): there is deliberately NO host-install version constant here, and the
// Setup tab renders no version number. The hub cannot know which version a box will run: the
// Option-1 command downloads felhom-host-install.sh from the website at run time, and the website
// git-syncs `main` every 30s (R-110). Any build-time literal here is a guess wearing a version
// number's authority — the previous const said 1.19.0 while the served script was 1.22.0, and had
// been wrong since 2026-07-14. The single version source is scripts/felhom-host-install.sh's
// SCRIPT_VERSION; scripts/hostinstall_gates.py gate 1 now asserts this file's ABSENCE of any
// host-install version literal.

// validSemver matches a bare X.Y.Z controller version (the floor format). Empty is also accepted by
// the floor handlers (clears the override).
var validSemver = regexp.MustCompile(`^\d+\.\d+\.\d+$`)

// normalizeFloorInput trims, strips a leading "v", and validates a floor version submitted from the
// operator UI. Returns (value, true) on a valid bare semver or empty string; (_, false) otherwise.
func normalizeFloorInput(raw string) (string, bool) {
	v := strings.TrimSpace(raw)
	v = strings.TrimPrefix(v, "v")
	if v == "" {
		return "", true
	}
	if !validSemver.MatchString(v) {
		return "", false
	}
	return v, true
}

// customerListEntry is a merged view of a customer from both configs and reports.
type customerListEntry struct {
	CustomerID        string
	CustomerName      string
	Domain            string
	HasConfig         bool
	IsBlocked         bool
	OverallStatus     string // ok, warn, down, disabled, pending, "" if no reports
	HostCause         string // v0.53.0 roll-up: "" or "host down|stale|pending: <id>"
	ControllerVersion string
	TimeSinceReport   time.Duration
	ConfigCreatedAt   time.Time

	// Phase 2 managed-update floor
	FloorOverride  string // per-customer override ("" = none)
	EffectiveFloor string // override else global ("" = no floor)
	BelowFloor     bool   // current < effective floor (would auto-update)
}

// handleConfigList shows all customers (merged from configs + reports).
func (s *Server) handleConfigList(w http.ResponseWriter, r *http.Request) {
	configs, err := s.store.ListCustomerConfigs()
	if err != nil {
		s.logger.Printf("[ERROR] Failed to list configs: %v", err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}

	customers, err := s.store.GetCustomers()
	if err != nil {
		s.logger.Printf("[ERROR] Failed to list customers: %v", err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}

	// Build merged map keyed by customer_id
	merged := make(map[string]*customerListEntry)

	for _, cfg := range configs {
		merged[cfg.CustomerID] = &customerListEntry{
			CustomerID:      cfg.CustomerID,
			CustomerName:    cfg.CustomerName,
			Domain:          cfg.Domain,
			HasConfig:       true,
			IsBlocked:       cfg.Status == "blocked",
			ConfigCreatedAt: cfg.CreatedAt,
			FloorOverride:   cfg.MinControllerVersion,
		}
	}

	for _, c := range customers {
		// Controller-derived status + the v0.53.0 dead-host roll-up (rollup.go).
		status, hostCause := s.foldHostStatus(c.CustomerID, controllerStatus(&c), true)

		if entry, ok := merged[c.CustomerID]; ok {
			// Config exists — enrich with report data
			entry.OverallStatus = status
			entry.HostCause = hostCause
			entry.ControllerVersion = c.ControllerVersion
			entry.TimeSinceReport = c.TimeSinceReport
			if entry.CustomerName == "" {
				entry.CustomerName = c.CustomerName
			}
		} else {
			// Report-only customer (no config)
			merged[c.CustomerID] = &customerListEntry{
				CustomerID:        c.CustomerID,
				CustomerName:      c.CustomerName,
				OverallStatus:     status,
				HostCause:         hostCause,
				ControllerVersion: c.ControllerVersion,
				TimeSinceReport:   c.TimeSinceReport,
			}
		}
	}

	// Config-only customers (no reports yet): the roll-up still applies — a down/stale host
	// must not hide behind the muted no-reports dash; only never-reported hosts are excluded.
	for _, e := range merged {
		if e.OverallStatus == "" {
			e.OverallStatus, e.HostCause = s.foldHostStatus(e.CustomerID, "", false)
		}
	}

	// Phase 2 floor: resolve each customer's effective floor (override else global) + below-floor flag.
	globalFloor := s.store.GetGlobalMinControllerVersion()
	for _, e := range merged {
		e.EffectiveFloor = e.FloorOverride
		if e.EffectiveFloor == "" {
			e.EffectiveFloor = globalFloor
		}
		if e.EffectiveFloor != "" && e.ControllerVersion != "" {
			e.BelowFloor = compareVersions(e.EffectiveFloor, e.ControllerVersion) > 0
		}
	}

	// Sort by customer_id
	entries := make([]customerListEntry, 0, len(merged))
	for _, e := range merged {
		entries = append(entries, *e)
	}
	sort.Slice(entries, func(i, j int) bool {
		return entries[i].CustomerID < entries[j].CustomerID
	})

	// GlobalFloor + the artifact manifest are global settings — they render + save on the
	// Configuration tab now (handleConfiguration), not here. globalFloor above is still used
	// for per-customer effective-floor resolution.
	data := struct {
		Customers []customerListEntry
		ActiveNav string
		Flash     string
	}{
		Customers: entries,
		ActiveNav: "configs",
		Flash:     r.URL.Query().Get("flash"),
	}
	s.templates.ExecuteTemplate(w, "configs.html", data)
}

// handleCustomerUnified shows the unified customer detail page (config + reports).
func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, customerID string) {
	cfg, _ := s.store.GetCustomerConfig(customerID)
	customer, _ := s.store.GetCustomer(customerID)

	// 404 if neither config nor reports exist
	if cfg == nil && customer == nil {
		http.NotFound(w, r)
		return
	}

	// Determine identity fields from best source
	name := ""
	domain := ""
	email := ""
	if cfg != nil {
		name = cfg.CustomerName
		domain = cfg.Domain
		email = cfg.Email
	}
	if name == "" && customer != nil {
		name = customer.CustomerName
	}

	// Parse report JSON
	var report map[string]interface{}
	if customer != nil {
		json.Unmarshal([]byte(customer.ReportJSON), &report)
	}

	// Parse config overrides
	var overrides map[string]interface{}
	if cfg != nil {
		json.Unmarshal([]byte(cfg.ConfigJSON), &overrides)
	}

	// Overall status: controller-derived + the v0.53.0 dead-host roll-up (rollup.go). The
	// blocked override stays LAST (administrative state wins the token); the host cause chip
	// renders regardless so the header says WHICH host is the problem.
	overallStatus := "pending"
	if customer != nil {
		overallStatus = controllerStatus(customer)
	}
	var hostCause string
	overallStatus, hostCause = s.foldHostStatus(customerID, overallStatus, customer != nil)
	if cfg != nil && cfg.Status == "blocked" {
		overallStatus = "blocked"
	}

	// Controller URL
	controllerURL := ""
	if customer != nil {
		controllerURL = customer.ControllerURL
		if controllerURL == "" {
			var rpt struct {
				ControllerURL string `json:"controller_url"`
			}
			json.Unmarshal([]byte(customer.ReportJSON), &rpt)
			controllerURL = rpt.ControllerURL
		}
	}

	// Version check
	var latestVersion string
	var updateAvailable bool
	if s.versionChecker != nil && customer != nil {
		latestVersion = s.versionChecker.LatestVersion()
		if latestVersion != "" && customer.ControllerVersion != "" {
			updateAvailable = latestVersion != customer.ControllerVersion && compareVersions(latestVersion, customer.ControllerVersion) > 0
		}
	}

	// Phase 2 managed-update floor: per-customer override, the global default, the effective floor, and
	// whether the box is currently below it (i.e. would auto-update on its next report).
	floorOverride := ""
	if cfg != nil {
		floorOverride = cfg.MinControllerVersion
	}
	globalFloor := s.store.GetGlobalMinControllerVersion()
	effectiveFloor := s.store.EffectiveMinControllerVersion(customerID)
	belowFloor := false
	if effectiveFloor != "" && customer != nil && customer.ControllerVersion != "" {
		belowFloor = compareVersions(effectiveFloor, customer.ControllerVersion) > 0
	}

	// History, notifications, events
	var history []store.CustomerSummary
	var notifPrefs *store.NotificationPrefs
	var recentNotifs []store.NotificationLogEntry
	var events []store.Event
	var eventCounts map[string]int

	var appTelemetry []store.CustomerAppSummary
	var logTails []store.AppLogTail
	var pendingTails []string

	if customer != nil {
		history, _ = s.store.GetCustomerHistory(customerID, 24*time.Hour)
		notifPrefs, _ = s.store.GetNotificationPrefs(customerID)
		recentNotifs, _ = s.store.GetRecentNotifications(customerID, 10)
		events, _ = s.store.GetRecentEvents(customerID, 50)
		eventCounts, _ = s.store.CountEventsBySeverity(customerID, time.Now().Add(-24*time.Hour))
		appTelemetry, _ = s.store.GetCustomerAppSummary(customerID, time.Now().Add(-7*24*time.Hour))
		logTails, _ = s.store.GetCustomerLogTails(customerID)
		pendingTails, _ = s.store.GetPendingLogTailRequests(customerID)
	}

	type pageData struct {
		CustomerID   string
		CustomerName string
		Domain       string
		Email        string

		HasConfig bool
		Config    *store.CustomerConfig
		Overrides map[string]interface{}
		IsBlocked bool

		// Deletable (v0.70.1) gates the Danger-zone card. It is the exact negation of the delete
		// preview's 404 predicate (customer_delete.go: cfg == nil && no hosts && residue empty) —
		// one truth, not a lookalike. Before v0.70.1 the card sat inside {{if .HasConfig}}, so the
		// entire v0.70.0 ghost-delete path was implemented but unreachable (dead UI).
		Deletable bool

		HasReports    bool
		Customer      *store.CustomerSummary
		Report        map[string]interface{}
		OverallStatus string
		HostCause     string // v0.53.0 roll-up: "" or "host down|stale|pending: <id>"

		LatestVersion   string
		UpdateAvailable bool
		ControllerURL   string

		// Phase 2 managed-update floor controls
		FloorOverride  string // per-customer override ("" = none)
		GlobalFloor    string // global default (hub_settings → config/env)
		EffectiveFloor string // override else global ("" = no floor)
		BelowFloor     bool   // current < effective floor (would auto-update)

		NotifPrefs          *store.NotificationPrefs
		RecentNotifications []store.NotificationLogEntry
		History             []store.CustomerSummary

		Events      []store.Event
		EventCounts map[string]int // severity → count (last 24h)

		AppTelemetry    []store.CustomerAppSummary
		HasAppTelemetry bool

		// On-demand log tails (v0.43.0): received tails (last 2 per app) + apps with a
		// still-pending request (badge on the telemetry row).
		LogTails     []store.AppLogTail
		HasLogTails  bool
		PendingTails map[string]bool

		HasDRRecipe       bool
		DRRecipeUpdatedAt string
		DRRecipeHasHost   bool
		DRRecipeHasApps   bool

		Flash     string
		ActiveNav string
		CSRFField template.HTML
		CSRFToken string

		// Hosts (v0.47.0): the customer's enrolled hosts for the Host tab — a LIST by design
		// (1 today, N for a later HA cluster). Each entry is the hostDetailData view-model map
		// the shared host_detail_body sub-template renders.
		Hosts []map[string]interface{}

		// ConfigForm (v0.48.0 edit-a): the embedded config form's view model for the Edit tab —
		// the same configFormData the standalone chrome renders. Zero-valued (and never rendered)
		// when the customer has no config.
		ConfigForm configFormView

		// Claim (v0.50.0, customer-claim arc): the dashboard claim state for the Setup-tab card —
		// nil when no code has been issued yet (pre-arc / never-pulled customer).
		Claim *store.ClaimState

		// StaleSinceReset (v0.67.0, R-37): a RESET completed AFTER the newest report, so every
		// health number on this page describes a lifecycle that no longer exists. Without this the
		// page keeps showing pre-RESET warnings as if they were current.
		StaleSinceReset bool
		ResetAt         string

		// OffsiteUnprovisioned (v0.67.0, R-36 interim): the customer's config says offsite is
		// ENABLED, but no descriptor was ever provisioned (type == ""). Provisioning is
		// Save-triggered (applyOffsite), and the re-enroll auto-re-issue deliberately skips an
		// unprovisioned target — so this state is stable and silent until someone presses Save.
		OffsiteUnprovisioned bool
	}

	pendingSet := make(map[string]bool, len(pendingTails))
	for _, app := range pendingTails {
		pendingSet[app] = true
	}

	// Host tab (v0.47.0): per-host view models via the shared hostDetailData builder.
	var hostViews []map[string]interface{}
	if hosts, err := s.store.ListHostsByCustomer(customerID); err != nil {
		s.logger.Printf("[ERROR] ListHostsByCustomer %s: %v", customerID, err)
	} else {
		for i := range hosts {
			hostViews = append(hostViews, s.hostDetailData(&hosts[i], r))
		}
	}

	// DR recipe presence — show the secret-free reconstruction recipe panel + download link when
	// either half has landed (host-report and/or controller report).
	var hasDR, drHost, drApps bool
	var drUpdated string
	if rec, err := s.store.GetDRRecipe(customerID); err == nil && rec != nil {
		hasDR = rec.HostHalfJSON != "" || rec.AppHalfJSON != ""
		drHost = rec.HostHalfJSON != ""
		drApps = rec.AppHalfJSON != ""
		drUpdated = rec.UpdatedAt
	}

	// R-37 (v0.67.0): is every health figure on this page pre-RESET? True only when a reset actually
	// COMPLETED and no report has arrived since. A reset still running, or one older than the newest
	// report, leaves the page exactly as it was.
	var staleSinceReset bool
	var resetAt string
	if cr, err := s.store.LatestCustomerReset(customerID); err != nil {
		s.logger.Printf("[WARN] LatestCustomerReset %s: %v", customerID, err)
	} else if cr != nil && cr.CompletedAt != nil {
		resetAt = cr.CompletedAt.Format("2006-01-02 15:04 MST")
		// Stale unless a report is STRICTLY newer than the reset. SQLite timestamps are
		// second-resolution, so a report and a reset can tie; a tie resolves to STALE because a
		// same-second report almost certainly arrived just before the reset destroyed the state it
		// describes. Erring the other way would hide the banner exactly when it matters most.
		staleSinceReset = customer == nil || !customer.ReceivedAt.After(*cr.CompletedAt)
	}

	// v0.70.1: the Danger-zone render gate. Hosts are already fetched above for the Host tab —
	// only the residue count is an extra read, and it runs ONLY on the ghost shape (config-less,
	// hostless), never on the hot normal path. A lookup error logs and leaves Deletable=false:
	// fail toward HIDING a destructive control, never toward showing one on unknown state.
	deletable := cfg != nil || len(hostViews) > 0
	if !deletable {
		if residue, err := s.store.CustomerResidue(customerID); err != nil {
			s.logger.Printf("[ERROR] CustomerResidue %s: %v", customerID, err)
		} else {
			deletable = residue.Total() > 0
		}
	}

	// R-36 interim (v0.67.0): enabled-but-unprovisioned is a real, stable state — the same predicate
	// the offsite re-issue handler already uses to refuse ("No provisioned offsite tier").
	var offsiteView struct {
		Offsite struct {
			Enabled bool   `json:"enabled"`
			Type    string `json:"type"`
		} `json:"offsite"`
	}
	if cfg != nil {
		_ = json.Unmarshal([]byte(cfg.ConfigJSON), &offsiteView)
	}
	offsiteUnprovisioned := offsiteView.Offsite.Enabled && offsiteView.Offsite.Type == ""

	data := pageData{
		CustomerID:   customerID,
		CustomerName: name,
		Domain:       domain,
		Email:        email,

		HasConfig: cfg != nil,
		Config:    cfg,
		Overrides: overrides,
		IsBlocked: cfg != nil && cfg.Status == "blocked",
		Deletable: deletable,

		HasReports:    customer != nil,
		Customer:      customer,
		Report:        report,
		OverallStatus: overallStatus,
		HostCause:     hostCause,

		StaleSinceReset:      staleSinceReset,
		ResetAt:              resetAt,
		OffsiteUnprovisioned: offsiteUnprovisioned,

		LatestVersion:   latestVersion,
		UpdateAvailable: updateAvailable,
		ControllerURL:   controllerURL,

		FloorOverride:  floorOverride,
		GlobalFloor:    globalFloor,
		EffectiveFloor: effectiveFloor,
		BelowFloor:     belowFloor,

		NotifPrefs:          notifPrefs,
		RecentNotifications: recentNotifs,
		History:             history,

		Events:      events,
		EventCounts: eventCounts,

		AppTelemetry:    appTelemetry,
		HasAppTelemetry: len(appTelemetry) > 0,

		LogTails:     logTails,
		HasLogTails:  len(logTails) > 0,
		PendingTails: pendingSet,

		HasDRRecipe:       hasDR,
		DRRecipeUpdatedAt: drUpdated,
		DRRecipeHasHost:   drHost,
		DRRecipeHasApps:   drApps,

		Flash:     r.URL.Query().Get("flash"),
		ActiveNav: "configs",
		CSRFField: s.csrfField(r),
		CSRFToken: s.csrfToken(r),

		Hosts: hostViews,
	}

	// Edit tab (v0.48.0 edit-a): embed the config form. nil overrides → the builder parses the
	// STORED ConfigJSON (the read path; submitted-value preservation is the standalone error
	// re-render's job).
	if cfg != nil {
		data.ConfigForm = s.configFormData(r, false, cfg, nil, "")
	}

	// Claim state (v0.50.0) for the Setup-tab access card (nil-safe: no row → no card content).
	if cs, err := s.store.GetClaim(customerID); err == nil {
		data.Claim = cs
	}

	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	if err := s.templates.ExecuteTemplate(w, "customer_unified.html", data); err != nil {
		s.logger.Printf("[ERROR] Template render: %v", err)
	}
}

// configFormView is the render model of the customer config form — consumed by the standalone
// config_form.html chrome AND embedded in the customer page's Edit tab as .ConfigForm
// (v0.48.0 edit-a; the hostDetailData/host_detail_body pattern).
type configFormView struct {
	IsNew     bool
	Config    *store.CustomerConfig
	Overrides map[string]interface{}
	ActiveNav string
	Error     string
	CSRFField template.HTML
	PBSDR     pbsDRView
	Delivery  *deliveryView
}

// deliveryView is the R-70 customer-card rendering of offsite.DeliveryStateFor — the hub's real
// delivery knowledge replacing the static "delivered to the controller once" copy that let a
// burned credential hide for 2 days (DIAG-f10-demo-hp-offsite-2026-07-23). All strings are
// precomputed operator-tier English (this page's existing language).
type deliveryView struct {
	State      string // offsite.DeliveryState (template branch key + test anchor)
	Badge      string // short badge text
	BadgeClass string // n-ok | n-warn | n-neutral
	Line       string // the state sentence, with age ("mióta" — every state carries its timestamp)
	StaleLine  string // non-empty ONLY for applied+stale-staged (the demo-felhom shape)
}

// agoHuman renders a duration as a coarse operator-friendly age.
func agoHuman(d time.Duration) string {
	switch {
	case d < time.Minute:
		return "under a minute"
	case d < time.Hour:
		return fmt.Sprintf("%d min", int(d.Minutes()))
	case d < 48*time.Hour:
		return fmt.Sprintf("%.1f h", d.Hours())
	default:
		return fmt.Sprintf("%d days", int(d.Hours()/24))
	}
}

// deliveryViewFor derives the card view; nil for brand-new configs (nothing staged yet) or on a
// detector error (the card then simply omits the state line — never a fabricated one).
func (s *Server) deliveryViewFor(customerID string) *deliveryView {
	if customerID == "" {
		return nil
	}
	now := time.Now()
	status, err := offsite.DeliveryStateFor(s.store, customerID)
	if err != nil {
		s.logger.Printf("[WARN] delivery view %s: %v", customerID, err)
		return nil
	}
	v := &deliveryView{State: string(status.State)}
	age := agoHuman(now.Sub(status.Since))
	switch status.State {
	case offsite.DeliveryApplied:
		v.Badge, v.BadgeClass = "applied", "n-ok"
		v.Line = "offsite active on the box (last report " + age + " ago)"
		if !status.StaleStagedSince.IsZero() {
			v.StaleLine = fmt.Sprintf("Note: an unconsumed one-time secret has been staged since %s (%s ago) — superseded by the working install (key-auth-first never consumes); harmless, replaced by the next re-issue.",
				status.StaleStagedSince.UTC().Format("2006-01-02 15:04 UTC"), agoHuman(now.Sub(status.StaleStagedSince)))
		}
	case offsite.DeliveryConsumedAwaitingApply:
		// Amber past 30 min: consume→apply is a seconds-scale hop; half an hour of it is the
		// burned-credential shape taking form (the monitor turns it into an event at 1 h).
		v.Badge = "consumed"
		if now.Sub(status.Since) > 30*time.Minute {
			v.BadgeClass = "n-warn"
			v.Line = fmt.Sprintf("password consumed %s ago and the box still reports no offsite target — likely burned mid-apply; Re-issue delivers a fresh one", age)
		} else {
			v.BadgeClass = "n-neutral"
			v.Line = "password consumed — apply in progress (" + age + ")"
		}
	case offsite.DeliveryStagedAwaitingConsume:
		v.Badge, v.BadgeClass = "staged", "n-neutral"
		v.Line = "one-time password staged " + age + " ago — the box consumes it on its next config refresh"
		if now.Sub(status.Since) > 30*time.Minute {
			v.BadgeClass = "n-warn"
			v.Line = "one-time password staged " + age + " ago and NOT yet consumed — the box has not re-pulled its config (is it reporting?)"
		}
	default: // DeliveryNoSecret
		v.Badge, v.BadgeClass = "missing", "n-warn"
		v.Line = "offsite is enabled but no credential is staged — needs attention (Re-issue stages a fresh one)"
	}
	return v
}

// configFormData assembles the config form's view model. overrides carries SUBMITTED form values
// for the validation-error re-render (typed values must survive — B3); pass nil to fall back to
// the STORED cfg.ConfigJSON (the normal read path).
func (s *Server) configFormData(r *http.Request, isNew bool, cfg *store.CustomerConfig, overrides map[string]interface{}, errMsg string) configFormView {
	if overrides == nil {
		json.Unmarshal([]byte(cfg.ConfigJSON), &overrides)
	}
	if overrides == nil {
		overrides = make(map[string]interface{})
	}
	return configFormView{
		IsNew:     isNew,
		Config:    cfg,
		Overrides: overrides,
		ActiveNav: "configs",
		Error:     errMsg,
		CSRFField: s.csrfField(r),
		PBSDR:     s.pbsDRViewFor(cfg.CustomerID, cfg.DRTier),
		Delivery:  s.deliveryViewFor(cfg.CustomerID),
	}
}

// handleConfigNewForm shows the form to create a new customer config. DRTier starts ON —
// DR-tier-by-default is the new-customer default (operator decision 2026-07-12 #2); opting out
// is the per-customer exception.
func (s *Server) handleConfigNewForm(w http.ResponseWriter, r *http.Request) {
	s.renderConfigForm(w, r, true, &store.CustomerConfig{DRTier: true}, nil, "")
}

// handleConfigCreate processes the form submission to create a new config.
func (s *Server) handleConfigCreate(w http.ResponseWriter, r *http.Request) {
	if err := r.ParseForm(); err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}

	customerID := strings.TrimSpace(r.FormValue("customer_id"))
	if customerID == "" || !validCustomerID.MatchString(customerID) {
		s.renderConfigForm(w, r, true, &store.CustomerConfig{
			CustomerName: r.FormValue("customer_name"),
			Domain:       r.FormValue("domain"),
			Email:        r.FormValue("email"),
		}, nil, "Invalid Customer ID. Use only letters, numbers, dots, and hyphens.")
		return
	}

	// Check for duplicates
	existing, _ := s.store.GetCustomerConfig(customerID)
	if existing != nil {
		s.renderConfigForm(w, r, true, &store.CustomerConfig{
			CustomerID:   customerID,
			CustomerName: r.FormValue("customer_name"),
			Domain:       r.FormValue("domain"),
			Email:        r.FormValue("email"),
		}, nil, fmt.Sprintf("Customer ID %q already exists.", customerID))
		return
	}

	// Generate credentials
	retrievalPassword, err := configgen.RandomPassphrase(5)
	if err != nil {
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	apiKey, err := configgen.RandomHex(32)
	if err != nil {
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}

	// Build config_json from optional form fields
	configJSON := buildConfigJSON(r)

	cfg := &store.CustomerConfig{
		CustomerID:        customerID,
		CustomerName:      strings.TrimSpace(r.FormValue("customer_name")),
		Domain:            strings.TrimSpace(r.FormValue("domain")),
		Email:             strings.TrimSpace(r.FormValue("email")),
		RetrievalPassword: retrievalPassword,
		APIKey:            apiKey,
		ConfigJSON:        configJSON,
		// v0.51.0: the DR-tier flag (the form checkbox defaults ON for new customers). Set
		// BEFORE applyOffsite — offsite provisioning is refused without the DR tier (F-6).
		DRTier: formBool(r, "dr_tier"),
	}

	// Offsite provisioning (fail-closed): a provisioning error must NOT save a half-enabled config.
	if err := s.applyOffsite(r.Context(), r, cfg); err != nil {
		s.logger.Printf("[ERROR] offsite provision for %s: %v", customerID, err)
		http.Error(w, "Offsite provisioning failed: "+err.Error(), http.StatusBadGateway)
		return
	}

	// PBS DR tier (fail-closed, same discipline; the descriptor lives in the HOST desired-state).
	if err := s.applyPBSDR(r.Context(), r, cfg); err != nil {
		s.logger.Printf("[ERROR] pbsdr provision for %s: %v", customerID, err)
		http.Error(w, "PBS DR provisioning failed: "+err.Error(), http.StatusBadGateway)
		return
	}

	if err := s.store.SaveCustomerConfig(cfg); err != nil {
		s.logger.Printf("[ERROR] Failed to save config for %s: %v", customerID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}

	s.logger.Printf("[INFO] Customer config created: %s", customerID)
	// v0.67.0 (R-36 sub-item): mint the self-bind link NOW, not when the operator remembers. The
	// box's console banner tells the customer to open „az e-mailben kapott link" — that email should
	// already exist by the time anyone reads the banner. Never fails the create (see autoMint…).
	s.autoMintSelfBindLink(customerID, cfg.Email, "customer creation")
	s.bumpIntent(customerID) // Direction-2: wake a long-polling box in seconds
	http.Redirect(w, r, "/customers/"+customerID+"?flash=created", http.StatusSeeOther)
}

// handleConfigEditForm — the standalone edit page merged into the customer page's Edit tab
// (v0.48.0 edit-a); old links and bookmarks land on the tab. POST /configs/{id}/edit stays the
// real mutation endpoint (the embedded form posts to it).
func (s *Server) handleConfigEditForm(w http.ResponseWriter, r *http.Request, customerID string) {
	cfg, err := s.store.GetCustomerConfig(customerID)
	if err != nil || cfg == nil {
		http.NotFound(w, r)
		return
	}
	http.Redirect(w, r, "/customers/"+customerID+"#tab=edit", http.StatusFound)
}

// handleConfigUpdate processes the edit form submission.
func (s *Server) handleConfigUpdate(w http.ResponseWriter, r *http.Request, customerID string) {
	if err := r.ParseForm(); err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}

	cfg, err := s.store.GetCustomerConfig(customerID)
	if err != nil || cfg == nil {
		http.NotFound(w, r)
		return
	}

	cfg.CustomerName = strings.TrimSpace(r.FormValue("customer_name"))
	cfg.Domain = strings.TrimSpace(r.FormValue("domain"))
	cfg.Email = strings.TrimSpace(r.FormValue("email"))
	// v0.51.0: the DR-tier flag — set BEFORE applyOffsite (offsite requires the tier) and
	// applyPBSDR (which converges the descriptor toward it).
	cfg.DRTier = formBool(r, "dr_tier")

	// Server-side twin of the form's required attributes (v0.48.0 — B3). The error re-render is
	// the STANDALONE page and carries the SUBMITTED overrides, so nothing the operator typed is
	// lost; runs BEFORE provisioning so an invalid submit never touches Hetzner/ep0.
	if cfg.CustomerName == "" || cfg.Domain == "" {
		var submitted map[string]interface{}
		_ = json.Unmarshal([]byte(buildConfigJSON(r)), &submitted)
		s.renderConfigForm(w, r, false, cfg, submitted, "Display Name and Domain are required.")
		return
	}

	cfg.ConfigJSON = buildConfigJSON(r)

	if err := s.applyOffsite(r.Context(), r, cfg); err != nil {
		s.logger.Printf("[ERROR] offsite provision for %s: %v", customerID, err)
		http.Error(w, "Offsite provisioning failed: "+err.Error(), http.StatusBadGateway)
		return
	}

	// PBS DR tier (fail-closed; idempotent on an already-provisioned descriptor — no re-key,
	// no second secret, no spurious generation bump).
	if err := s.applyPBSDR(r.Context(), r, cfg); err != nil {
		s.logger.Printf("[ERROR] pbsdr provision for %s: %v", customerID, err)
		http.Error(w, "PBS DR provisioning failed: "+err.Error(), http.StatusBadGateway)
		return
	}

	if err := s.store.SaveCustomerConfig(cfg); err != nil {
		s.logger.Printf("[ERROR] Failed to update config for %s: %v", customerID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}

	s.logger.Printf("[INFO] Customer config updated: %s", customerID)
	s.bumpIntent(customerID) // Direction-2: wake a long-polling box in seconds
	http.Redirect(w, r, "/customers/"+customerID+"?flash=updated#tab=edit", http.StatusSeeOther)
}

// handleClaimResend (v0.50.0, customer-claim arc) rotates the claim/reset code and re-sends it to
// the REGISTERED customer address — the operator "Kód újraküldése" / "Beállító kód küldése"
// button (R-295: the button used to read „Visszaállító kód küldése"; that name is retired).
// The old code stops verifying immediately (single active code); the fresh hash reaches
// the box on its next report ACK (no config bump needed). No plaintext is ever rendered or logged.
func (s *Server) handleClaimResend(w http.ResponseWriter, r *http.Request, customerID string) {
	if s.claimEngine == nil {
		http.Error(w, "Claim engine is not configured on this hub", http.StatusBadGateway)
		return
	}
	cfg, err := s.store.GetCustomerConfig(customerID)
	if err != nil || cfg == nil {
		http.NotFound(w, r)
		return
	}
	if err := s.claimEngine.Resend(cfg); err != nil {
		s.logger.Printf("[ERROR] claim resend for %s: %v", customerID, err)
		http.Redirect(w, r, "/customers/"+customerID+"?flash=claim-resend-failed#tab=setup", http.StatusSeeOther)
		return
	}
	s.logger.Printf("[INFO] claim code re-sent for %s (operator resend; generation rotated)", customerID)
	s.bumpIntent(customerID) // Direction-2: the fresh claim hash rides the next report ACK
	http.Redirect(w, r, "/customers/"+customerID+"?flash=claim-resent#tab=setup", http.StatusSeeOther)
}

// handleOffsiteReissue (F4) resets the customer's offsite credential and stores a fresh one-time password —
// the explicit operator recovery for a consumed-password dead-end (fresh-guest DR, consumed-but-failed
// install). Scoped to the resource labelled for THIS customer (the provisioner refuses unless exactly one).
// The config is re-saved unchanged so ConfigVersion bumps → the stuck guest's next refresh re-runs the
// bridge, which consumes the fresh password. The password value is never logged or rendered.
func (s *Server) handleOffsiteReissue(w http.ResponseWriter, r *http.Request, customerID string) {
	if s.offsite == nil {
		http.Error(w, "Offsite provisioning is not configured on this hub", http.StatusBadGateway)
		return
	}
	cfg, err := s.store.GetCustomerConfig(customerID)
	if err != nil || cfg == nil {
		http.NotFound(w, r)
		return
	}
	var overrides struct {
		Offsite struct {
			Enabled bool   `json:"enabled"`
			Type    string `json:"type"`
		} `json:"offsite"`
	}
	_ = json.Unmarshal([]byte(cfg.ConfigJSON), &overrides)
	if !overrides.Offsite.Enabled || overrides.Offsite.Type == "" {
		http.Error(w, "No provisioned offsite tier for this customer", http.StatusBadRequest)
		return
	}
	// Same detached-ctx discipline as applyOffsite (F1): once the reset starts, reset→store must complete.
	ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 3*time.Minute)
	defer cancel()
	if err := s.offsite.ReissueCredentials(ctx, customerID, overrides.Offsite.Type); err != nil {
		s.logger.Printf("[ERROR] offsite reissue for %s: %v", customerID, err)
		http.Error(w, "Offsite credential re-issue failed: "+err.Error(), http.StatusBadGateway)
		return
	}
	// Re-save unchanged → ConfigVersion bump → the customer's controller re-pulls + re-runs the bridge.
	if err := s.store.SaveCustomerConfig(cfg); err != nil {
		s.logger.Printf("[ERROR] offsite reissue for %s: config bump failed: %v", customerID, err)
		http.Error(w, "Credential re-issued but the config bump failed — save the config once to trigger the pickup", http.StatusInternalServerError)
		return
	}
	s.logger.Printf("[INFO] offsite credentials re-issued for %s (fresh one-time password stored; ConfigVersion bumped)", customerID)
	s.bumpIntent(customerID) // Direction-2: wake the stuck box to re-pull + re-run the bridge
	http.Redirect(w, r, "/customers/"+customerID+"?flash=offsite_reissued#tab=edit", http.StatusSeeOther)
}

// ReissueOffsiteForCustomer is the programmatic form of handleOffsiteReissue — the seam the API
// host-enroll path calls on a clean-slate re-enrollment (F3, v0.57.0): the offsite one-time password
// only ever reached the OLD controller, so the fresh box has no target. It re-issues (and, via
// ReissueCredentials, invalidates the now-stale escrow + emits events), then bumps ConfigVersion so
// the controller re-pulls and the bridge consumes the fresh password. Silent NO-OP (nil) when the
// customer has no provisioned/enabled offsite tier — that is the common non-DR case, not an error.
func (s *Server) ReissueOffsiteForCustomer(ctx context.Context, customerID string) error {
	if s.offsite == nil {
		return nil // offsite not configured on this hub
	}
	cfg, err := s.store.GetCustomerConfig(customerID)
	if err != nil {
		return fmt.Errorf("offsite re-issue: customer lookup: %w", err)
	}
	if cfg == nil {
		return nil
	}
	var overrides struct {
		Offsite struct {
			Enabled bool   `json:"enabled"`
			Type    string `json:"type"`
		} `json:"offsite"`
	}
	_ = json.Unmarshal([]byte(cfg.ConfigJSON), &overrides)
	if !overrides.Offsite.Enabled || overrides.Offsite.Type == "" {
		return nil // no provisioned offsite tier — nothing to re-issue
	}
	rctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 3*time.Minute)
	defer cancel()
	if err := s.offsite.ReissueCredentials(rctx, customerID, overrides.Offsite.Type); err != nil {
		return fmt.Errorf("offsite re-issue: %w", err)
	}
	if err := s.store.SaveCustomerConfig(cfg); err != nil {
		return fmt.Errorf("offsite re-issue: config bump: %w", err)
	}
	s.logger.Printf("[INFO] offsite credentials re-issued for %s on re-enroll (fresh one-time password; ConfigVersion bumped)", customerID)
	s.bumpIntent(customerID) // Direction-2: the fresh box's first wait wakes on this
	return nil
}

// handleOffsiteFreeze (SLICE 4) freezes/unfreezes the customer's shared sub-account (readonly) — an
// OPERATOR lever, never automatic (freezing also blocks prune, the customer's only way down from
// over-quota). Shared model only; the exactly-1 label guard lives in the provisioner. Action logged,
// no secrets involved.
func (s *Server) handleOffsiteFreeze(w http.ResponseWriter, r *http.Request, customerID string, frozen bool) {
	if s.offsite == nil {
		http.Error(w, "Offsite provisioning is not configured on this hub", http.StatusBadGateway)
		return
	}
	cfg, err := s.store.GetCustomerConfig(customerID)
	if err != nil || cfg == nil {
		http.NotFound(w, r)
		return
	}
	var overrides struct {
		Offsite struct {
			Enabled bool   `json:"enabled"`
			Type    string `json:"type"`
		} `json:"offsite"`
	}
	_ = json.Unmarshal([]byte(cfg.ConfigJSON), &overrides)
	if !overrides.Offsite.Enabled || overrides.Offsite.Type != "shared" {
		http.Error(w, "Freeze applies to a provisioned SHARED offsite tier only", http.StatusBadRequest)
		return
	}
	ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 2*time.Minute)
	defer cancel()
	if err := s.offsite.SetOffsiteFrozen(ctx, customerID, frozen); err != nil {
		s.logger.Printf("[ERROR] offsite freeze(%v) for %s: %v", frozen, customerID, err)
		http.Error(w, "Offsite freeze/unfreeze failed: "+err.Error(), http.StatusBadGateway)
		return
	}
	s.logger.Printf("[INFO] offsite frozen=%v (readonly) for %s (operator action)", frozen, customerID)
	s.bumpIntent(customerID) // Direction-2: reflect the freeze state change to the box promptly
	flash := "offsite_frozen"
	if !frozen {
		flash = "offsite_unfrozen"
	}
	http.Redirect(w, r, "/customers/"+customerID+"?flash="+flash+"#tab=edit", http.StatusSeeOther)
}

// The shallow customer DELETE that used to live here (a bare DeleteCustomerConfig behind a native
// confirm) was REPLACED in v0.69.0 by the guided full-teardown cascade — see web/customer_delete.go
// (R-25b). The route is unchanged (POST /configs/{id}/delete); what changed is that it now tears the
// hosts, the offsite repo, the PBS namespace and the tunnel/zone down before purging the record,
// behind three acknowledgements and a typed customer-id. Do NOT reintroduce a shallow delete path.

// handleConfigPreview returns the generated YAML for a customer config.
func (s *Server) handleConfigPreview(w http.ResponseWriter, r *http.Request, customerID string) {
	cfg, err := s.store.GetCustomerConfig(customerID)
	if err != nil || cfg == nil {
		http.NotFound(w, r)
		return
	}

	templateYAML := defaultControllerTemplate
	if s.templateFetcher != nil {
		templateYAML = s.templateFetcher.Template()
	}

	// Claim arc (v0.50.0): the preview BAKES an existing claim hash (so it matches what a box
	// would pull) but never ISSUES one — issuing + emailing belongs to the real config retrieve.
	claimState, _ := s.store.GetClaim(customerID)
	yamlOutput, err := configgen.Generate(templateYAML, cfg, claimState)
	if err != nil {
		s.logger.Printf("[ERROR] Failed to generate preview for %s: %v", customerID, err)
		http.Error(w, "Generation error: "+err.Error(), http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "text/yaml; charset=utf-8")
	w.Write([]byte(yamlOutput))
}

// handleConfigRegenPassword regenerates the retrieval password.
func (s *Server) handleConfigRegenPassword(w http.ResponseWriter, r *http.Request, customerID string) {
	newPassword, err := configgen.RandomPassphrase(5)
	if err != nil {
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}

	if err := s.store.UpdateRetrievalPassword(customerID, newPassword); err != nil {
		s.logger.Printf("[ERROR] Failed to regen password for %s: %v", customerID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}

	s.logger.Printf("[INFO] Retrieval password regenerated for %s", customerID)
	s.bumpIntent(customerID) // Direction-2: nudge the box promptly after a credential change
	http.Redirect(w, r, "/customers/"+customerID+"?flash=password_regenerated#tab=setup", http.StatusSeeOther)
}

// handleBlockCustomer sets a customer's status to "blocked".
func (s *Server) handleBlockCustomer(w http.ResponseWriter, r *http.Request, customerID string) {
	cfg, _ := s.store.GetCustomerConfig(customerID)
	if cfg == nil {
		http.NotFound(w, r)
		return
	}
	if err := s.store.SetCustomerConfigStatus(customerID, "blocked"); err != nil {
		s.logger.Printf("[ERROR] Failed to block %s: %v", customerID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	s.logger.Printf("[INFO] Customer blocked: %s", customerID)
	s.bumpIntent(customerID) // Direction-2: deliver the blocked flag to the box in seconds
	http.Redirect(w, r, "/customers/"+customerID+"?flash=blocked#tab=edit", http.StatusSeeOther)
}

// handleUnblockCustomer sets a customer's status back to "active".
func (s *Server) handleUnblockCustomer(w http.ResponseWriter, r *http.Request, customerID string) {
	cfg, _ := s.store.GetCustomerConfig(customerID)
	if cfg == nil {
		http.NotFound(w, r)
		return
	}
	if err := s.store.SetCustomerConfigStatus(customerID, "active"); err != nil {
		s.logger.Printf("[ERROR] Failed to unblock %s: %v", customerID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	s.logger.Printf("[INFO] Customer unblocked: %s", customerID)
	s.bumpIntent(customerID) // Direction-2: clear the blocked flag on the box promptly
	http.Redirect(w, r, "/customers/"+customerID+"?flash=unblocked#tab=edit", http.StatusSeeOther)
}

// countBoxesBelowFloor counts reporting boxes whose EFFECTIVE floor (per-customer override else the
// proposed global) would exceed their reported controller version — i.e. how many boxes a proposed
// global-floor save would immediately push into an update. Boxes with a per-customer override are
// governed by that override, not the proposed global, so they are excluded from the global-save
// blast radius (the confirm dialog is about the GLOBAL knob). Reused by the confirm-count endpoint.
func (s *Server) countBoxesBelowFloor(proposedGlobal string) int {
	customers, err := s.store.GetCustomers()
	if err != nil {
		return 0
	}
	configs, _ := s.store.ListCustomerConfigs()
	override := make(map[string]string, len(configs))
	for _, c := range configs {
		if c.MinControllerVersion != "" {
			override[c.CustomerID] = c.MinControllerVersion
		}
	}
	n := 0
	for _, c := range customers {
		floor := proposedGlobal
		if ov, ok := override[c.CustomerID]; ok {
			floor = ov // an overridden box is not moved by the global knob
		}
		if floor != "" && c.ControllerVersion != "" && compareVersions(floor, c.ControllerVersion) > 0 {
			n++
		}
	}
	return n
}

// handleGlobalFloorImpact answers the confirm dialog's "how many boxes are below <v>?" probe
// (GET /configuration/global-floor/impact?v=X.Y.Z). Read-only JSON; blank v = 0.
func (s *Server) handleGlobalFloorImpact(w http.ResponseWriter, r *http.Request) {
	v, ok := normalizeFloorInput(r.URL.Query().Get("v"))
	count := 0
	if ok && v != "" {
		count = s.countBoxesBelowFloor(v)
	}
	w.Header().Set("Content-Type", "application/json")
	_ = json.NewEncoder(w).Encode(map[string]any{"version": v, "valid": ok, "below": count})
}

// handleSetGlobalFloor sets (or clears) the global controller-version floor (Phase 2 managed
// updates). Empty clears the hub_settings override, falling back to the config/env default.
func (s *Server) handleSetGlobalFloor(w http.ResponseWriter, r *http.Request) {
	if err := r.ParseForm(); err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}
	v, ok := normalizeFloorInput(r.FormValue("min_controller_version"))
	if !ok {
		http.Redirect(w, r, "/configuration?flash=floor_invalid", http.StatusSeeOther)
		return
	}
	if err := s.store.SetGlobalMinControllerVersion(v); err != nil {
		s.logger.Printf("[ERROR] Failed to set global floor: %v", err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	s.logger.Printf("[INFO] Global controller-version floor set to %q", v)
	// Direction-2: the global floor affects every config-managed customer — wake each long-polling
	// box so the new floor lands in seconds (nil-safe; a customer with no held wait just advances).
	if configs, cerr := s.store.ListCustomerConfigs(); cerr == nil {
		for _, c := range configs {
			s.bumpIntent(c.CustomerID)
		}
	}
	http.Redirect(w, r, "/configuration?flash=floor_set", http.StatusSeeOther)
}

// validSHA256 matches a lowercase 64-hex sha256 digest. Empty is also accepted by the artifact
// handler (clears that artifact's checksum).
var validSHA256 = regexp.MustCompile(`^[0-9a-f]{64}$`)

// normalizeSHA256 trims/lowercases and validates a sha256 submitted from the operator UI.
// Returns (value, true) on a valid 64-hex digest or empty string; (_, false) otherwise.
func normalizeSHA256(raw string) (string, bool) {
	v := strings.ToLower(strings.TrimSpace(raw))
	if v == "" {
		return "", true
	}
	if !validSHA256.MatchString(v) {
		return "", false
	}
	return v, true
}

// handleSetArtifacts records the operator-vouched current artifact set (agent binary + golden
// archive) into hub_settings — the checksum TRUST ROOT the host-bootstrap script verifies fetched
// artifacts against. The operator picks a VERSION (from the Gitea-populated dropdown); the hub DERIVES
// that version's sha256 from Gitea itself (never trusting a client-supplied checksum), so there is no
// hand-copied sha to get wrong. When no Gitea client is configured (no registry creds) it falls back
// to the submitted sha256 (legacy manual path). Empty version clears that artifact.
// sha256HexRe validates an operator-typed sha256 (R-50b(a) wrapper hash): exactly 64 lowercase hex
// characters. The agent/golden hashes are resolved from the package registry instead, so this is the
// only manifest field a human types by hand — and a truncated paste must be refused, not stored as a
// hash that can never match.
var sha256HexRe = regexp.MustCompile(`^[0-9a-f]{64}$`)

// installerProbeConfig is the FIRST config felhom-host-install.sh fetches from a vouched agent's
// tag (step 5/8, `fetch_raw "configs/felhom-mkfs-guarded.sh"`). It is the exact file whose 404 broke
// every install on 2026-08-09, and it is what the installability gate asserts — the path the
// installer uses, never a path that merely exists.
const installerProbeConfig = "configs/felhom-mkfs-guarded.sh"

func (s *Server) handleSetArtifacts(w http.ResponseWriter, r *http.Request) {
	if err := r.ParseForm(); err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}
	agentVer, okAV := normalizeFloorInput(r.FormValue("agent_version"))
	goldenVer, okGV := normalizeFloorInput(r.FormValue("golden_version"))
	minAgent, okMA := normalizeFloorInput(r.FormValue("min_agent")) // Part D: empty = uncoupled release
	if !okAV || !okGV || !okMA {
		http.Redirect(w, r, "/configuration?flash=artifact_ver_invalid", http.StatusSeeOther)
		return
	}
	// --- INSTALLABILITY GATE (R-273) -----------------------------------------------------------
	//
	// THE INCIDENT. On 2026-08-08 agent v0.128.0 was published as a package and never git-tagged.
	// It was vouched here. felhom-host-install.sh fetches an agent's config files from
	// `raw/tag/v<version>/configs/`, so EVERY fresh install and every reinstall died at step 5 of 8
	// — as root, on a virgin machine — for most of a day. Nothing checked at the moment of risk, and
	// the moment of risk is this handler: it is the sole UI path to SetArtifactManifest.
	//
	// TWO INDEPENDENT LEGS, because both have failed within two days of each other:
	//   the TAG   — missing on 2026-08-08 (R-273)
	//   the PACKAGE — pruned out from under a still-tagged version on 2026-08-08/09 (R-287)
	// Checking one would have caught one of them.
	//
	// IT ASSERTS THE PATH THE INSTALLER ACTUALLY FETCHES. `configs/felhom-mkfs-guarded.sh` is the
	// FIRST of the sixteen `fetch_raw` calls (felhom-host-install.sh, step 5) and is literally the
	// file whose 404 broke Friday. Probing some other path that merely exists is how that failure
	// stayed invisible.
	//
	// IT RUNS BEFORE resolveArtifactSHA, and that ordering is load-bearing. The sha lookup fails
	// with `artifact_sha_invalid`, whose text reads "version missing / Gitea unreachable / bad sha"
	// — three different facts in one message. If it ran first, an unreachable registry would be
	// reported to the operator as a possibly-missing artifact. Probing first means the operator is
	// told which of those it actually is. (Found by scenario E failing against the first draft.)
	//
	// UNDETERMINED IS ALSO A REFUSAL, and it says something different. A warning beside a success is
	// read as a success, and this project has the scars; so an unreachable registry refuses too,
	// with its own message. There is NO OVERRIDE: the registry is the operator's own server, and if
	// it cannot be reached then vouching is moot rather than urgent.
	if s.gitea != nil {
		ctx := r.Context()
		type probeTarget struct{ label, pkg, file, version, repo string }
		targets := []probeTarget{}
		if agentVer != "" {
			targets = append(targets, probeTarget{"agent", pkgAgent, fileAgent, agentVer, "felhom-agent"})
		}
		if goldenVer != "" {
			targets = append(targets, probeTarget{"golden", pkgGolden, fileGolden, goldenVer, ""})
		}
		for _, t := range targets {
			// Leg 1 — the tag, but only where a tag is what the installer uses. The golden is
			// fetched as a package by version and has no config tree, so a tag probe on it would be
			// asserting something the installer never does.
			if t.repo != "" {
				res := s.gitea.TagServesFile(ctx, t.repo, "v"+t.version, installerProbeConfig)
				if res.Err != nil {
					s.logger.Printf("[WARN] artifact vouch REFUSED: could not verify the %s tag v%s: %v", t.label, t.version, res.Err)
					http.Redirect(w, r, "/configuration?flash=artifact_unverifiable", http.StatusSeeOther)
					return
				}
				if !res.OK {
					s.logger.Printf("[WARN] artifact vouch REFUSED: %s v%s has NO usable git tag — "+
						"raw/tag/v%s/%s does not resolve, so every install would 404 at step 5/8 (R-273)",
						t.label, t.version, t.version, installerProbeConfig)
					http.Redirect(w, r, "/configuration?flash=artifact_tag_missing", http.StatusSeeOther)
					return
				}
			}
			// Leg 2 — the artifact itself is fetchable.
			res := s.gitea.PackageDownloadable(ctx, t.pkg, t.version, t.file)
			if res.Err != nil {
				s.logger.Printf("[WARN] artifact vouch REFUSED: could not verify the %s package %s: %v", t.label, t.version, res.Err)
				http.Redirect(w, r, "/configuration?flash=artifact_unverifiable", http.StatusSeeOther)
				return
			}
			if !res.OK {
				s.logger.Printf("[WARN] artifact vouch REFUSED: %s package %s is NOT downloadable (R-287)", t.label, t.version)
				http.Redirect(w, r, "/configuration?flash=artifact_pkg_missing", http.StatusSeeOther)
				return
			}
		}
	}

	agentSHA, okAS := s.resolveArtifactSHA(r.Context(), pkgAgent, fileAgent, agentVer, r.FormValue("agent_sha256"))
	goldenSHA, okGS := s.resolveArtifactSHA(r.Context(), pkgGolden, fileGolden, goldenVer, r.FormValue("golden_sha256"))
	if !okAS || !okGS {
		http.Redirect(w, r, "/configuration?flash=artifact_sha_invalid", http.StatusSeeOther)
		return
	}
	// R-50b(a): the PBS-DR wrapper hash is operator-typed, not resolved from the package registry —
	// unlike the agent binary and the golden, this artifact is not published there at all. It is
	// installed from raw/branch/main, which is exactly the drift this field makes visible.
	wrapperSHA := strings.ToLower(strings.TrimSpace(r.FormValue("wrapper_sha256")))
	if wrapperSHA != "" && !sha256HexRe.MatchString(wrapperSHA) {
		http.Redirect(w, r, "/configuration?flash=artifact_sha_invalid", http.StatusSeeOther)
		return
	}
	// ── R-120 GATE — refuses a golden older than the controller the fleet is already running ─────────
	//
	// WHY THIS IS A GATE AND NOT A SCRIPT. The golden's version IS the controller it bakes
	// (felhom-agent configs/build-golden.sh: GOLDEN_VERSION defaults to ${CONTROLLER_IMAGE##*:}), so a
	// golden behind the newest deployed controller means every FRESH install lands on stale
	// application code. That has happened three times — R-111 (the golden's agent 17 releases behind),
	// R-115 (an agent built and deployed but never published), R-120 (this: the golden a controller
	// release behind, shipping a customer-facing FALSEHOOD, since 0.186.0 is what made the
	// absent-backup-target message true). The first two were closed by re-baking and remembering, and
	// remembering then failed again — which is why this is enforcement, not a reminder.
	//
	// It lives HERE, immediately before the only write, because handleSetArtifacts is the sole UI path
	// to SetArtifactManifest: it therefore runs without anyone choosing to run it. R-29 is the standing
	// proof that the alternative does not work — `hostinstall_gates.py` sat RED and invoked by nothing
	// across three version bumps while every report said green, and `hub_confirm_gate.py` has never run
	// at all. A check in scripts/ asserting this same fact would have been a fourth orphan.
	//
	// It REFUSES rather than warns (operator ruling, 2026-07-30): a non-blocking check reads as coverage
	// it is not providing, which is R-29's whole finding.
	//
	// FAIL-OPEN, deliberately, in exactly two cases: an empty golden field (clearing the manifest is a
	// legitimate operator act) and an unknown fleet version (no guest has reported one — a brand-new hub
	// must be able to vouch its first golden). Neither is the drift this catches.
	if goldenVer != "" {
		if newest := s.store.NewestReportedControllerVersion(); newest != "" && compareVersions(goldenVer, newest) < 0 {
			s.logger.Printf("[WARN] artifact vouch REFUSED: golden %s is older than the newest controller the fleet reports (%s) — "+
				"a fresh install would land on stale application code (R-120)", goldenVer, newest)
			http.Redirect(w, r, "/configuration?flash=golden_behind_fleet", http.StatusSeeOther)
			return
		}
	}
	if err := s.store.SetArtifactManifest(store.ArtifactManifest{
		AgentVersion:  agentVer,
		AgentSHA256:   agentSHA,
		GoldenVersion: goldenVer,
		GoldenSHA256:  goldenSHA,
		MinAgent:      minAgent,
		WrapperSHA256: wrapperSHA,
	}); err != nil {
		s.logger.Printf("[ERROR] Failed to set artifact manifest: %v", err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	s.logger.Printf("[INFO] Artifact manifest set: agent=%s golden=%s min_agent=%q wrapper_sha=%t", agentVer, goldenVer, minAgent, wrapperSHA != "")
	// Agent-plane immediate-sync (Direction-2a, v0.59.0): a MinAgent-floor / vouched-agent change is
	// a fleet-wide agent-plane intent shift. Fire-and-forget nudge every box so it re-reports at
	// once (the self-update train's signed op / floor re-evaluation lands in seconds, not ≤15 min).
	// The manifest write itself does not bump per-host desired generation; the poke only accelerates
	// the next report where the floor is applied. Never blocks the save.
	s.poke.PokeAllHosts()
	http.Redirect(w, r, "/configuration?flash=artifacts_set", http.StatusSeeOther)
}

// resolveArtifactSHA determines the sha256 to store for a chosen artifact version. An empty version
// clears the artifact (returns "",true). With a Gitea client it fetches the sha AUTHORITATIVELY from
// Gitea (the submitted value is ignored — nothing hand-typed to trust); a fetch failure returns
// (_,false) so the caller refuses the save rather than storing a version with a wrong/blank checksum.
// Without a Gitea client it validates + uses the submitted sha (legacy manual path).
func (s *Server) resolveArtifactSHA(ctx context.Context, pkg, file, version, submittedSHA string) (string, bool) {
	if version == "" {
		return "", true
	}
	if s.gitea != nil {
		sha, err := s.gitea.FileSHA256(ctx, pkg, version, file)
		if err != nil {
			s.logger.Printf("[WARN] artifact sha resolve (%s/%s): %v", pkg, version, err)
			return "", false
		}
		return sha, true
	}
	return normalizeSHA256(submittedSHA)
}

// handleSetCustomerFloor sets (or clears) a customer's per-customer controller-version floor
// override. Empty clears the override (the customer then uses the global floor).
func (s *Server) handleSetCustomerFloor(w http.ResponseWriter, r *http.Request, customerID string) {
	cfg, err := s.store.GetCustomerConfig(customerID)
	if err != nil || cfg == nil {
		http.NotFound(w, r)
		return
	}
	if err := r.ParseForm(); err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}
	v, ok := normalizeFloorInput(r.FormValue("min_controller_version"))
	if !ok {
		http.Redirect(w, r, "/customers/"+customerID+"?flash=floor_invalid", http.StatusSeeOther)
		return
	}
	if err := s.store.SetMinControllerVersion(customerID, v); err != nil {
		s.logger.Printf("[ERROR] Failed to set floor for %s: %v", customerID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	s.logger.Printf("[INFO] Customer %s controller-version floor override set to %q", customerID, v)
	s.bumpIntent(customerID) // Direction-2: deliver the new floor to the box in seconds
	http.Redirect(w, r, "/customers/"+customerID+"?flash=floor_set", http.StatusSeeOther)
}

// handleCreateConfigFromReport auto-creates a config entry from report data.
func (s *Server) handleCreateConfigFromReport(w http.ResponseWriter, r *http.Request, customerID string) {
	// Check if config already exists
	existing, _ := s.store.GetCustomerConfig(customerID)
	if existing != nil {
		http.Redirect(w, r, "/configs/"+customerID+"/edit", http.StatusSeeOther)
		return
	}

	// Get report data to pre-fill
	customer, _ := s.store.GetCustomer(customerID)
	name := customerID
	if customer != nil && customer.CustomerName != "" {
		name = customer.CustomerName
	}

	// Generate credentials
	retrievalPassword, _ := configgen.RandomPassphrase(5)
	apiKey, _ := configgen.RandomHex(32)

	cfg := &store.CustomerConfig{
		CustomerID:        customerID,
		CustomerName:      name,
		RetrievalPassword: retrievalPassword,
		APIKey:            apiKey,
		ConfigJSON:        "{}",
	}

	if err := s.store.SaveCustomerConfig(cfg); err != nil {
		s.logger.Printf("[ERROR] Failed to create config from report for %s: %v", customerID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}

	s.logger.Printf("[INFO] Config auto-created from report for %s", customerID)
	http.Redirect(w, r, "/configs/"+customerID+"/edit", http.StatusSeeOther)
}

// renderConfigForm renders the STANDALONE config form page (chrome + config_form_body) — the
// create flow and the validation-error re-render. The customer page's Edit tab embeds the same
// body via configFormData directly.
func (s *Server) renderConfigForm(w http.ResponseWriter, r *http.Request, isNew bool, cfg *store.CustomerConfig, overrides map[string]interface{}, errMsg string) {
	s.templates.ExecuteTemplate(w, "config_form.html", s.configFormData(r, isNew, cfg, overrides, errMsg))
}

// buildConfigJSON builds the config_json from optional form fields.
// applyOffsite provisions the offsite tier (if enabled in the form) and merges the NON-SECRET descriptor
// into cfg.ConfigJSON. Fail-closed: on any provisioning error it returns the error and leaves cfg.ConfigJSON
// unchanged — the caller must NOT save. When offsite is unchecked, the offsite key is naturally absent from
// the freshly-built ConfigJSON (disabled by omission; the Hetzner resource is NOT deprovisioned this slice).
func (s *Server) applyOffsite(ctx context.Context, r *http.Request, cfg *store.CustomerConfig) error {
	if v := r.FormValue("offsite_enabled"); v != "on" && v != "true" {
		return nil // not enabled → disabled by omission
	}
	// v0.51.0 (decision 3, drill F-6 closed by policy): offsite app backup REQUIRES the DR tier —
	// fork-4 needs the escrow ceremony, and the ceremony hard-requires the PBS key K. Without the
	// tier, provisioning would run straight into the F-6 dead end (EscrowState pending forever).
	// The guard reads cfg.DRTier, which the handlers set from the form BEFORE calling here.
	if !cfg.DRTier {
		return fmt.Errorf("Offsite backup requires the DR tier — enable it first (the escrow ceremony depends on the PBS key)")
	}
	if s.offsite == nil {
		return fmt.Errorf("offsite provisioning is not configured on this hub (no Hetzner token)")
	}
	// Detach from the client's request context: provisioning takes ~25s (create + wait + host-key scan) and
	// an impatient re-click cancels r.Context() MID-SEQUENCE — live finding: the cancel landed between
	// CreateSubaccount and SaveOneTimeSecret, stranding a sub-account whose one-time password was lost
	// forever. Once provisioning starts it must run to completion (create→wait→store is the atom); the
	// absolute timeout still bounds a hung Hetzner call.
	ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 3*time.Minute)
	defer cancel()
	in := offsite.Input{
		Enabled: true,
		Type:    strings.TrimSpace(r.FormValue("offsite_type")),
		BoxType: strings.TrimSpace(r.FormValue("offsite_box_type")),
	}
	if q := strings.TrimSpace(r.FormValue("offsite_quota_gb")); q != "" {
		in.QuotaGB, _ = strconv.Atoi(q)
	}
	d, err := s.offsite.ProvisionOffsite(ctx, cfg.CustomerID, in)
	if err != nil {
		return err
	}
	merged, err := offsite.MergeDescriptor(cfg.ConfigJSON, d)
	if err != nil {
		return err
	}
	cfg.ConfigJSON = merged
	return nil
}

func buildConfigJSON(r *http.Request) string {
	overrides := make(map[string]interface{})

	// Infrastructure
	infra := make(map[string]interface{})
	if v := strings.TrimSpace(r.FormValue("cf_tunnel_token")); v != "" {
		infra["cf_tunnel_token"] = v
	}
	if v := strings.TrimSpace(r.FormValue("cf_api_token")); v != "" {
		infra["cf_api_token"] = v
	}
	if len(infra) > 0 {
		overrides["infrastructure"] = infra
	}

	// Git
	git := make(map[string]interface{})
	if v := strings.TrimSpace(r.FormValue("git_username")); v != "" {
		git["username"] = v
	}
	if v := strings.TrimSpace(r.FormValue("git_token")); v != "" {
		git["token"] = v
	}
	if len(git) > 0 {
		overrides["git"] = git
	}

	// Logging (remote debug-mode toggle). The controller's /debug menu + verbose log key off
	// Logging.Level=="debug" (controller isDebug()). This lives in the FORM — not raw-JSON injection —
	// on purpose: handleConfigUpdate rebuilds ConfigJSON from the form on every save (buildConfigJSON),
	// so a foreign key would be dropped on the next save. Checked → logging.level=debug; unchecked → the
	// logging key is OMITTED entirely (the generated controller.yaml default stands — no needless "info").
	if r.FormValue("debug_mode") != "" {
		overrides["logging"] = map[string]interface{}{"level": "debug"}
	}

	data, _ := json.Marshal(overrides)
	return string(data)
}

// formBool reads a checkbox-style form value ("on"/"true" → true; anything else, incl. absent,
// → false).
func formBool(r *http.Request, name string) bool {
	v := r.FormValue(name)
	return v == "on" || v == "true"
}

// handleGeoDisable removes all [felhom-geo] WAF rules from Cloudflare for a customer. The Cloudflare
// WAF rules ARE the geo enforcement, so removing them disables geo-restriction. This is a hub→Cloudflare
// call (NOT into the box) and stays. The old secondary inbound notify to the controller
// (notifyControllerGeoDisable) was retired in v0.26.0 to honour the never-inbound posture — the
// controller-side geo intent is left as a noted follow-up (there is no periodic re-apply, so the CF
// removal sticks).
func (s *Server) handleGeoDisable(w http.ResponseWriter, r *http.Request, customerID string) {
	cfg, err := s.store.GetCustomerConfig(customerID)
	if err != nil || cfg == nil {
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusNotFound)
		json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "Customer not found"})
		return
	}

	// Extract CF API token from config_json → infrastructure.cf_api_token
	var overrides map[string]interface{}
	if err := json.Unmarshal([]byte(cfg.ConfigJSON), &overrides); err != nil {
		overrides = make(map[string]interface{})
	}
	var cfToken string
	if infra, ok := overrides["infrastructure"].(map[string]interface{}); ok {
		cfToken, _ = infra["cf_api_token"].(string)
	}
	if cfToken == "" {
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusBadRequest)
		json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "No Cloudflare API token configured for this customer"})
		return
	}
	if cfg.Domain == "" {
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusBadRequest)
		json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "No domain configured for this customer"})
		return
	}

	// 1. Remove WAF rules directly via Cloudflare API
	if err := cfClient.RemoveGeoRules(cfToken, cfg.Domain, s.logger); err != nil {
		s.logger.Printf("[ERROR] Geo disable for %s: Cloudflare removal failed: %v", customerID, err)
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusBadGateway)
		json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": fmt.Sprintf("Cloudflare API error: %v", err)})
		return
	}

	s.logger.Printf("[INFO] Geo disable for %s: Cloudflare WAF rules removed", customerID)

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Geo-restriction removed from Cloudflare."})
}
