package web

import (
	"context"
	"crypto/rand"
	"crypto/subtle"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"html/template"
	"log"
	"math"
	"net/http"
	"strings"
	"sync"
	"time"

	"gitea.dooplex.hu/admin/felhom-hub/internal/assets"
	"gitea.dooplex.hu/admin/felhom-hub/internal/claim"
	"gitea.dooplex.hu/admin/felhom-hub/internal/gitea"
	"gitea.dooplex.hu/admin/felhom-hub/internal/intent"
	"gitea.dooplex.hu/admin/felhom-hub/internal/monitor"
	"gitea.dooplex.hu/admin/felhom-hub/internal/offsite"
	"gitea.dooplex.hu/admin/felhom-hub/internal/poke"
	"gitea.dooplex.hu/admin/felhom-hub/internal/semver"
	"gitea.dooplex.hu/admin/felhom-hub/internal/store"
	"golang.org/x/crypto/bcrypt"
)

// Generic-package names + the artifact filename inside each version (used to resolve version lists +
// sha256 from Gitea for the Day-0 artifact manifest UI).
const (
	pkgAgent   = "felhom-agent"
	fileAgent  = "felhom-agent"
	pkgGolden  = "felhom-golden"
	fileGolden = "golden.tar.zst"
)

// artifactChoice is one selectable artifact version + its Gitea-resolved sha256 (for the dropdown +
// the read-only sha display).
type artifactChoice struct {
	Version string
	SHA256  string
}

// hubSession holds per-session auth and CSRF data.
type hubSession struct {
	expiresAt time.Time
	csrfToken string
}

// Server handles the dashboard web UI.
type Server struct {
	store *store.Store
	// configPasswordHash is the operator login password bcrypt hash SEEDED from hub.yaml
	// (auth.password_hash) at startup. It is the fallback only — a hub_settings DB override set via
	// the Configuration UI wins. Never read this field directly for an auth decision; call
	// effectivePasswordHash().
	configPasswordHash string
	apiKey             string // report API key — used for controller callbacks
	version            string
	logger             *log.Logger
	templates          *template.Template
	staleThreshold     time.Duration
	versionChecker     *VersionChecker
	templateFetcher    *TemplateFetcher
	assetsMgr          *assets.Manager
	gitea              *gitea.Client                         // optional; enables the Day-0 artifact version dropdowns
	offsite            *offsite.Provisioner                  // optional; enables Hetzner offsite provisioning (SLICE 1)
	offsiteBox         func() (monitor.BoxSnapshot, bool)    // optional (v0.64.0, R-5); the restic pool-box aggregate snapshot accessor
	pbsdrBox           func() (monitor.PBSBoxSnapshot, bool) // optional (v0.65.0, R-5); the PBS-DR datastore fill snapshot accessor
	tenantsync         tenancyProvisioner                    // optional; enables PBS DR tier provisioning (web/pbsdr.go)
	claimEngine        *claim.Engine                         // optional; enables the customer-claim resend button (v0.50.0)
	selfBindMailer     SelfBindMailer                        // optional; enables the customer self-bind link button (v0.66.0, R-27)
	bindLimiter        *bindRateLimiter                      // per-IP throttle for the PUBLIC /bind/ surface (v0.66.0, R-27)
	// intentHub (v0.58.0, Direction-2 immediate-sync) is Bumped by every operator-intent handler
	// (config save/delete, claim resend, offsite re-issue/freeze, floor, block/unblock, log pull)
	// so a box long-polling GET /api/v1/wait wakes in seconds. Shared with the API handler. nil =
	// no immediacy (bumps are no-ops; the 15-min cycle still reconciles).
	intentHub *intent.Hub

	// poke (v0.59.0, Direction-2a agent-plane immediate-sync) fires a fire-and-forget UDP nudge to
	// a host's box (via the ep0 forced command) when an AGENT-plane desired-state change is saved
	// (a pbsdr descriptor, the MinAgent floor) so the box ticks in seconds instead of ≤15 min. nil
	// = no immediacy (a no-op; the report cycle still reconciles). Its sibling intentHub handles
	// the CONTROLLER plane (customer/app config) via the long-poll wait channel.
	poke *poke.Notifier

	sessions   map[string]*hubSession
	sessionsMu sync.RWMutex

	// artifactCache (R-267) memoises the Day-0 dropdown contents for artifactChoicesTTL. In memory
	// only — see the constant's comment for why this must never become a database row.
	artifactCache   map[string]artifactChoiceCacheEntry
	artifactCacheMu sync.Mutex
}

// New creates a new web server.
func New(store *store.Store, passwordHash, apiKey, version string, staleThreshold time.Duration, logger *log.Logger) *Server {
	funcMap := template.FuncMap{
		"timeAgo": timeAgo,
		"timeAgoPtr": func(t *time.Time) string {
			if t == nil {
				return "—"
			}
			return timeAgo(*t)
		},
		"statusColor": statusColor,
		"formatFloat": func(f float64) string { return fmt.Sprintf("%.0f", f) },
		"joinStrings": func(s []string, sep string) string { return strings.Join(s, sep) },
		"json": func(v interface{}) template.JS {
			b, _ := json.Marshal(v)
			return template.JS(b)
		},
		"hubVersion": func() string { return version },
		"add":        func(a, b int) int { return a + b },
		"mapGet": func(m map[string]int, key string) int {
			if m == nil {
				return 0
			}
			return m[key]
		},
		"memoryColor":   memoryColor,
		"accuracyClass": accuracyClass,
		"gt":            func(a, b int) bool { return a > b },
	}

	tmpl := template.Must(template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html"))

	return &Server{
		store:              store,
		configPasswordHash: passwordHash,
		apiKey:             apiKey,
		version:            version,
		logger:             logger,
		templates:          tmpl,
		staleThreshold:     staleThreshold,
		sessions:           make(map[string]*hubSession),
		bindLimiter:        newBindRateLimiter(30), // public /bind/ surface: 30 req/min/IP burst (R-27)
	}
}

// effectivePasswordHash returns the operator login password bcrypt hash in force: the UI-set DB
// override (hub_settings, via the Configuration page) when present, otherwise the config/env seed
// (auth.password_hash from hub.yaml). This is the SINGLE source of truth for every auth check — the
// DB override wins and the ConfigMap value is the break-glass fallback, mirroring the
// controller-version floor's precedence. Empty return = auth disabled (dev/test only).
func (s *Server) effectivePasswordHash() string {
	if h := s.store.GetOperatorPasswordHash(); h != "" {
		return h
	}
	return s.configPasswordHash
}

// CleanupSessions removes expired sessions. Call with: go s.CleanupSessions(ctx).
func (s *Server) CleanupSessions(ctx context.Context) {
	ticker := time.NewTicker(15 * time.Minute)
	defer ticker.Stop()
	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
			s.sessionsMu.Lock()
			now := time.Now()
			for t, sess := range s.sessions {
				if now.After(sess.expiresAt) {
					delete(s.sessions, t)
				}
			}
			s.sessionsMu.Unlock()
		}
	}
}

// SetVersionChecker sets the version checker (optional, may be nil if no registry credentials).
func (s *Server) SetVersionChecker(vc *VersionChecker) {
	s.versionChecker = vc
}

// SetTemplateFetcher sets the template fetcher for config generation (optional).
func (s *Server) SetTemplateFetcher(tf *TemplateFetcher) {
	s.templateFetcher = tf
}

// SetAssetManager sets the asset manager for the Configuration page (optional).
func (s *Server) SetAssetManager(am *assets.Manager) {
	s.assetsMgr = am
}

// SetOffsiteProvisioner enables Hetzner offsite provisioning (optional). Without it, saving a config with
// offsite enabled returns an error (offsite not configured on this hub).
func (s *Server) SetOffsiteProvisioner(p *offsite.Provisioner) { s.offsite = p }

// SetOffsiteBox wires the pool-box aggregate snapshot accessor (v0.64.0, R-5): the checker's cached
// snapshot, read on the Offsite tab + the Dashboard tile. nil (no HETZNER_TOKEN/box id) → both render an
// honest "not configured". The web layer NEVER fetches from Hetzner — it only reads this cache.
func (s *Server) SetOffsiteBox(fn func() (monitor.BoxSnapshot, bool)) { s.offsiteBox = fn }

// SetPBSDRBox wires the PBS-DR datastore fill snapshot accessor (v0.65.0, R-5): read on the Offsite
// "PBS DR" tab + the Dashboard PBS gauge. nil (no tenantsync client) → "not configured". The snapshot
// carries its own state (ok/unavailable/degraded); the web layer never polls ep0.
func (s *Server) SetPBSDRBox(fn func() (monitor.PBSBoxSnapshot, bool)) { s.pbsdrBox = fn }

// SetClaimEngine wires the customer-claim code engine for the Setup-tab resend button (v0.50.0).
func (s *Server) SetClaimEngine(e *claim.Engine) { s.claimEngine = e }

// SetIntentHub wires the operator-intent notifier (v0.58.0). Every intent handler bumps it via
// s.bumpIntent; nil-safe (bumps become no-ops).
func (s *Server) SetIntentHub(hub *intent.Hub) { s.intentHub = hub }

// bumpIntent advances the customer's wait generation so a box long-polling GET /api/v1/wait wakes
// immediately. nil-safe. Call AFTER the successful store write (mirror the report.Trigger
// fire-after-commit rule — never on an error path).
func (s *Server) bumpIntent(customerID string) {
	if s.intentHub != nil {
		s.intentHub.Bump(customerID)
	}
}

// SetGiteaClient enables the Day-0 artifact version dropdowns (optional). Without it the artifact form
// degrades to manual text entry.
func (s *Server) SetGiteaClient(c *gitea.Client) {
	s.gitea = c
}

// artifactChoices resolves the currently-available versions of a generic package + each one's sha256
// from Gitea, newest first, for the Day-0 artifact dropdown. Returns nil (→ manual text-entry
// fallback) when no Gitea client is configured or Gitea is unreachable. A failed sha lookup for a
// single version drops just that version, not the whole list.
// artifactChoicesTTL bounds how stale the dropdown may be (R-267, operator ruling 2026-08-08).
//
// 60 s, chosen deliberately against the one workflow that cares: a bake-and-vouch session publishes
// an artifact and then goes straight to this page to select it. A minute is short enough that the
// operator does not notice waiting for it, and long enough that the page is instant for every reload
// during that session. A longer TTL was rejected for exactly that case — it would hide a
// just-published golden at the moment someone is looking for it.
//
// NOT persisted, and that is the point. Gitea IS the store for both the version list and the sha;
// a copy in the hub's database would be a second source of truth that can drift from the registry
// it describes, and the operator reads the sha here to confirm what they are about to vouch. An
// in-memory cache dies with the process and can never be mistaken for a record.
const artifactChoicesTTL = 60 * time.Second

type artifactChoiceCacheEntry struct {
	choices []artifactChoice
	at      time.Time
}

func (s *Server) artifactChoices(ctx context.Context, pkg, file string) []artifactChoice {
	if s.gitea == nil {
		return nil
	}

	s.artifactCacheMu.Lock()
	if e, ok := s.artifactCache[pkg]; ok && time.Since(e.at) < artifactChoicesTTL {
		s.artifactCacheMu.Unlock()
		return e.choices
	}
	s.artifactCacheMu.Unlock()

	vers, err := s.gitea.ListVersions(ctx, pkg)
	if err != nil {
		s.logger.Printf("[WARN] artifact versions (%s): %v", pkg, err)
		return nil
	}
	const maxChoices = 20
	if len(vers) > maxChoices {
		s.logger.Printf("[INFO] artifact versions (%s): showing newest %d of %d", pkg, maxChoices, len(vers))
		vers = vers[:maxChoices]
	}
	// CONCURRENTLY (v0.100.0). Each sha is one independent metadata round-trip, and doing them in
	// series made /configuration take 26 SECONDS on a page with two dropdowns: 2 packages x (1
	// version list + 20 sha lookups) = 42 sequential requests at ~0.6 s each.
	//
	// ⚠ NOTHING IS BEING HASHED HERE, and the operator's reasonable guess that it was is worth
	// recording so nobody re-derives it: Gitea stores the sha256 with the package file and
	// FileSHA256 reads it as metadata — the artifact bytes are never downloaded (see its comment).
	// The cost was never CPU; it was latency x count, and the fix is therefore concurrency, not a
	// cached or precomputed hash. A hash copied into the hub's own DB would be a SECOND SOURCE OF
	// TRUTH that can drift from the registry it describes, and the operator reads this value to
	// confirm what they are about to vouch — a stale one would be a confident wrong answer.
	//
	// Bounded at 8 in flight: enough to collapse the wall-clock, few enough not to stampede Gitea
	// on a page an operator may reload. Order is preserved by writing into a slot, not appending —
	// the dropdown is newest-first and must stay that way.
	const parallel = 8
	shas := make([]string, len(vers))
	errs := make([]error, len(vers))
	sem := make(chan struct{}, parallel)
	var wg sync.WaitGroup
	for i, v := range vers {
		wg.Add(1)
		go func(i int, v string) {
			defer wg.Done()
			sem <- struct{}{}
			defer func() { <-sem }()
			shas[i], errs[i] = s.gitea.FileSHA256(ctx, pkg, v, file)
		}(i, v)
	}
	wg.Wait()

	out := make([]artifactChoice, 0, len(vers))
	for i, v := range vers {
		if errs[i] != nil {
			// unchanged behaviour: a failed lookup drops THAT version, never the whole list
			s.logger.Printf("[WARN] artifact sha (%s/%s): %v", pkg, v, errs[i])
			continue
		}
		out = append(out, artifactChoice{Version: v, SHA256: shas[i]})
	}

	// Only a SUCCESSFUL resolve is cached. A Gitea blip must not pin an empty list in front of the
	// operator for a minute — the earlier returns leave the cache untouched, and so does this one
	// when every version's sha lookup failed.
	//
	// The `len(vers) > 0` half matters: a package that genuinely has no versions yet resolves to an
	// empty list legitimately, and caching THAT is correct. Distinguishing the two is the whole
	// point — "we found nothing" and "we could not look" are not the same answer, which is the rule
	// CONTEXT S-39 states for figures and applies just as well here.
	if len(out) == 0 && len(vers) > 0 {
		s.logger.Printf("[WARN] artifact choices (%s): every version's sha lookup failed — not caching", pkg)
		return out
	}
	s.artifactCacheMu.Lock()
	if s.artifactCache == nil {
		s.artifactCache = map[string]artifactChoiceCacheEntry{}
	}
	s.artifactCache[pkg] = artifactChoiceCacheEntry{choices: out, at: time.Now()}
	s.artifactCacheMu.Unlock()
	return out
}

// ServeHTTP routes web requests.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	path := r.URL.Path

	// CSRF protection for all state-changing requests (web routes only).
	// API routes (/api/v1/) are Bearer-token authenticated and exempt.
	if r.Method != http.MethodGet && r.Method != http.MethodHead && r.Method != http.MethodOptions {
		// /bind/ is the public customer self-bind surface (THE TRAP §9.2): no operator session to
		// ride, so CSRF is exempt here exactly as it is for /login. The URL capability token is the
		// authorization boundary; a cross-site POST without both secrets only burns attempts.
		if path != "/login" && !isPublicBindPath(path) && s.effectivePasswordHash() != "" {
			if !s.validateCSRF(r) {
				s.logger.Printf("[WARN] CSRF rejected: %s %s from %s", r.Method, path, r.RemoteAddr)
				http.Error(w, "CSRF token missing or invalid. Please reload the page.", http.StatusForbidden)
				return
			}
		}
	}

	switch {
	case path == "/":
		s.handleDashboard(w, r)
	case path == "/style.css":
		s.handleCSS(w, r)
	case path == "/static/chart.min.js":
		w.Header().Set("Content-Type", "application/javascript")
		w.Header().Set("Cache-Control", "public, max-age=86400")
		w.Write(chartJS)
	case strings.HasPrefix(path, "/static/fonts/"):
		// Vendored brand fonts (design system v2) — embedded, no CDN.
		name := strings.TrimPrefix(path, "/static/fonts/")
		data, err := fontFS.ReadFile("static/fonts/" + name)
		if err != nil {
			http.NotFound(w, r)
			return
		}
		w.Header().Set("Content-Type", "font/woff2")
		w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
		w.Write(data)
	case path == "/configuration":
		if r.Method == http.MethodPost {
			s.handleConfigurationAction(w, r)
		} else {
			s.handleConfiguration(w, r)
		}
	case path == "/apps" || path == "/apps/":
		s.handleApps(w, r)
	case strings.HasPrefix(path, "/apps/") && strings.HasSuffix(path, "/reset-telemetry"):
		appName := strings.TrimPrefix(path, "/apps/")
		appName = strings.TrimSuffix(appName, "/reset-telemetry")
		if r.Method == http.MethodPost {
			s.handleResetAppTelemetry(w, r, appName)
		} else {
			http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		}
	case strings.HasPrefix(path, "/apps/") && strings.HasSuffix(path, "/dismiss-issues"):
		appName := strings.TrimPrefix(path, "/apps/")
		appName = strings.TrimSuffix(appName, "/dismiss-issues")
		if r.Method == http.MethodPost {
			s.handleDismissAppIssues(w, r, appName)
		} else {
			http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		}
	case strings.HasPrefix(path, "/apps/"):
		appName := strings.TrimPrefix(path, "/apps/")
		s.handleAppDetail(w, r, appName)
	// Offsite — WG endpoint + peer registry (S2), plus endpoint management forms
	// (v0.47.0; peer mutations stay on the admin API, allocation stays lowest-endpoint-id).
	case path == "/offsite":
		s.handleOffsite(w, r)
	case path == "/offsite/endpoints":
		if r.Method == http.MethodPost {
			s.handleOffsiteEndpointSave(w, r)
		} else {
			http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		}
	case strings.HasPrefix(path, "/offsite/endpoints/") && strings.HasSuffix(path, "/delete"):
		endpointID := strings.TrimSuffix(strings.TrimPrefix(path, "/offsite/endpoints/"), "/delete")
		if r.Method == http.MethodPost {
			s.handleOffsiteEndpointDelete(w, r, endpointID)
		} else {
			http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		}
	// Hosts — read-only fleet view (audit F-M1) + the v0.46.0 log-bundle actions.
	case path == "/hosts" || path == "/hosts/":
		s.handleHostsList(w, r)
	// R-21 slice C — unclaimed-appliance operator actions (bind/discard). POST only.
	case strings.HasPrefix(path, "/appliances/") && strings.HasSuffix(path, "/bind"):
		if id, ok := parseApplianceID(path, "bind"); ok && r.Method == http.MethodPost {
			s.handleApplianceBind(w, r, id)
		} else {
			http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		}
	case strings.HasPrefix(path, "/appliances/") && strings.HasSuffix(path, "/discard"):
		if id, ok := parseApplianceID(path, "discard"); ok && r.Method == http.MethodPost {
			s.handleApplianceDiscard(w, r, id)
		} else {
			http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		}
	// v0.47.0 stale host removal — suffix routes BEFORE the bare /hosts/ catch-all
	// (mirroring the request-logs placement).
	case strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/delete-impact"):
		hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/delete-impact")
		if r.Method == http.MethodGet {
			s.handleHostDeleteImpact(w, r, hostID)
		} else {
			http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		}
	case strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/delete"):
		hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/delete")
		if r.Method == http.MethodPost {
			s.handleHostDelete(w, r, hostID)
		} else {
			http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		}
	// v0.84.0 break-glass console credential — suffix route BEFORE the bare /hosts/ catch-all
	// (registered after it, the POST would 404 and the GET would silently render the host page).
	case strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/reveal-recovery-credential"):
		hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/reveal-recovery-credential")
		if r.Method == http.MethodPost {
			s.handleHostRevealRecoveryCredential(w, r, hostID)
		} else {
			http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		}
	case strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/request-logs"):
		hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/request-logs")
		if r.Method == http.MethodPost {
			s.handleRequestLogBundle(w, r, hostID)
		} else {
			http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		}
	case strings.HasPrefix(path, "/hosts/") && strings.Contains(path, "/log-bundles/"):
		rest := strings.TrimPrefix(path, "/hosts/")
		if i := strings.Index(rest, "/log-bundles/"); i > 0 {
			s.handleLogBundleView(w, r, rest[:i], rest[i+len("/log-bundles/"):])
		} else {
			http.NotFound(w, r)
		}
	case strings.HasPrefix(path, "/hosts/"):
		hostID := strings.TrimPrefix(path, "/hosts/")
		s.handleHostDetail(w, r, hostID)
	case path == "/login":
		s.handleLogin(w, r)
	case isPublicBindPath(path):
		// PUBLIC customer self-bind (R-27 slice 1) — GET renders the form/state, POST validates the
		// two factors. Auth + CSRF exempt above via the SAME isPublicBindPath predicate.
		s.handleBind(w, r)
	case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/block"):
		customerID := strings.TrimPrefix(path, "/customers/")
		customerID = strings.TrimSuffix(customerID, "/block")
		if r.Method == http.MethodPost {
			s.handleBlockCustomer(w, r, customerID)
		} else {
			http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		}
	case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/selfbind-link"):
		// R-27 slice 1: mint + email a customer self-bind capability link. POST only.
		customerID := strings.TrimPrefix(path, "/customers/")
		customerID = strings.TrimSuffix(customerID, "/selfbind-link")
		if r.Method == http.MethodPost {
			s.handleSelfBindLinkSend(w, r, customerID)
		} else {
			http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		}
	case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/unblock"):
		customerID := strings.TrimPrefix(path, "/customers/")
		customerID = strings.TrimSuffix(customerID, "/unblock")
		if r.Method == http.MethodPost {
			s.handleUnblockCustomer(w, r, customerID)
		} else {
			http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		}
	case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/geo/disable"):
		customerID := strings.TrimPrefix(path, "/customers/")
		customerID = strings.TrimSuffix(customerID, "/geo/disable")
		if r.Method == http.MethodPost {
			s.handleGeoDisable(w, r, customerID)
		} else {
			http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		}
	case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/floor"):
		customerID := strings.TrimPrefix(path, "/customers/")
		customerID = strings.TrimSuffix(customerID, "/floor")
		if r.Method == http.MethodPost {
			s.handleSetCustomerFloor(w, r, customerID)
		} else {
			http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		}
	case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/create-config"):
		customerID := strings.TrimPrefix(path, "/customers/")
		customerID = strings.TrimSuffix(customerID, "/create-config")
		if r.Method == http.MethodPost {
			s.handleCreateConfigFromReport(w, r, customerID)
		} else {
			http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		}
	case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/dr-recipe.json"):
		customerID := strings.TrimPrefix(path, "/customers/")
		customerID = strings.TrimSuffix(customerID, "/dr-recipe.json")
		s.handleDRRecipeDownload(w, r, customerID)
	case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/request-log-tail"):
		customerID := strings.TrimPrefix(path, "/customers/")
		customerID = strings.TrimSuffix(customerID, "/request-log-tail")
		if r.Method == http.MethodPost {
			s.handleRequestLogTail(w, r, customerID)
		} else {
			http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		}
	case strings.HasPrefix(path, "/customers/") && strings.Contains(path, "/log-tail/"):
		rest := strings.TrimPrefix(path, "/customers/")
		parts := strings.SplitN(rest, "/log-tail/", 2)
		if len(parts) != 2 {
			http.NotFound(w, r)
			return
		}
		s.handleLogTailView(w, r, parts[0], parts[1])
	case strings.HasPrefix(path, "/customers/"):
		customerID := strings.TrimPrefix(path, "/customers/")
		s.handleCustomerUnified(w, r, customerID)
	// Config management routes — exact matches first, then prefix matches
	case path == "/configs":
		s.handleConfigList(w, r)
	case path == "/configs/new":
		if r.Method == http.MethodPost {
			s.handleConfigCreate(w, r)
		} else {
			s.handleConfigNewForm(w, r)
		}
	// Global settings live under the Configuration tab (moved from Customers).
	case path == "/configuration/global-floor/impact":
		if r.Method == http.MethodGet {
			s.handleGlobalFloorImpact(w, r)
		} else {
			http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		}
	case path == "/configuration/global-floor":
		if r.Method == http.MethodPost {
			s.handleSetGlobalFloor(w, r)
		} else {
			http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		}
	case path == "/configuration/artifacts":
		if r.Method == http.MethodPost {
			s.handleSetArtifacts(w, r)
		} else {
			http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		}
	case path == "/configuration/password":
		if r.Method == http.MethodPost {
			s.handleChangePassword(w, r)
		} else {
			http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		}
	case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/delete"):
		// Customer DELETE cascade (v0.69.0, R-25b): GET returns the guided dialog's live inventory
		// (hosts + offsite + PBS + custody + any incomplete journal), POST runs the full teardown.
		customerID := strings.TrimPrefix(path, "/configs/")
		customerID = strings.TrimSuffix(customerID, "/delete")
		if r.Method == http.MethodPost {
			s.handleCustomerDelete(w, r, customerID)
		} else {
			s.handleCustomerDeletePreview(w, r, customerID)
		}
	case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/edit"):
		customerID := strings.TrimPrefix(path, "/configs/")
		customerID = strings.TrimSuffix(customerID, "/edit")
		if r.Method == http.MethodPost {
			s.handleConfigUpdate(w, r, customerID)
		} else {
			s.handleConfigEditForm(w, r, customerID)
		}
	case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/offsite-reissue"):
		customerID := strings.TrimPrefix(path, "/configs/")
		customerID = strings.TrimSuffix(customerID, "/offsite-reissue")
		if r.Method == http.MethodPost {
			s.handleOffsiteReissue(w, r, customerID)
		} else {
			http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		}
	case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/claim-resend"):
		customerID := strings.TrimPrefix(path, "/configs/")
		customerID = strings.TrimSuffix(customerID, "/claim-resend")
		if r.Method == http.MethodPost {
			s.handleClaimResend(w, r, customerID)
		} else {
			http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		}
	case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/pbsdr-reissue"):
		customerID := strings.TrimPrefix(path, "/configs/")
		customerID = strings.TrimSuffix(customerID, "/pbsdr-reissue")
		if r.Method == http.MethodPost {
			s.handlePBSDRReissue(w, r, customerID)
		} else {
			http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		}
	case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/offsite-freeze"):
		customerID := strings.TrimPrefix(path, "/configs/")
		customerID = strings.TrimSuffix(customerID, "/offsite-freeze")
		if r.Method == http.MethodPost {
			s.handleOffsiteFreeze(w, r, customerID, r.FormValue("unfreeze") != "1")
		} else {
			http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		}
	case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/preview"):
		customerID := strings.TrimPrefix(path, "/configs/")
		customerID = strings.TrimSuffix(customerID, "/preview")
		s.handleConfigPreview(w, r, customerID)
	case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/regen-password"):
		customerID := strings.TrimPrefix(path, "/configs/")
		customerID = strings.TrimSuffix(customerID, "/regen-password")
		if r.Method == http.MethodPost {
			s.handleConfigRegenPassword(w, r, customerID)
		} else {
			http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		}
	case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/reset"):
		// Customer RESET (v0.61.0): GET renders the confirm surface (live inventory), POST executes.
		customerID := strings.TrimPrefix(path, "/configs/")
		customerID = strings.TrimSuffix(customerID, "/reset")
		if r.Method == http.MethodPost {
			s.handleCustomerReset(w, r, customerID)
		} else {
			s.handleCustomerResetPreview(w, r, customerID)
		}
	case strings.HasPrefix(path, "/configs/"):
		// Redirect old config detail URL to unified customer page
		customerID := strings.TrimPrefix(path, "/configs/")
		http.Redirect(w, r, "/customers/"+customerID, http.StatusSeeOther)
	default:
		http.NotFound(w, r)
	}
}

// RequireAuth wraps a handler with session or basic authentication.
func (s *Server) RequireAuth(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// Skip auth if no password configured
		if s.effectivePasswordHash() == "" {
			next.ServeHTTP(w, r)
			return
		}

		// Always allow the login page through (GET and POST), and the PUBLIC customer self-bind
		// surface (THE TRAP §9.2): /bind/ is exempt from operator auth exactly as /login is — the
		// emailed URL capability token IS the auth model there. isPublicBindPath is the SINGLE
		// definition of the prefix (matched tightly: trailing slash, path already .. -cleaned by the
		// ServeMux) so this exemption cannot reach any operator-gated route.
		if r.URL.Path == "/login" || isPublicBindPath(r.URL.Path) {
			next.ServeHTTP(w, r)
			return
		}

		// Check session cookie (random token stored server-side)
		if cookie, err := r.Cookie("hub_session"); err == nil {
			s.sessionsMu.RLock()
			sess, ok := s.sessions[cookie.Value]
			s.sessionsMu.RUnlock()
			if ok && time.Now().Before(sess.expiresAt) {
				next.ServeHTTP(w, r)
				return
			}
		}

		// Check basic auth (for programmatic/CLI access)
		_, password, ok := r.BasicAuth()
		if ok && bcrypt.CompareHashAndPassword([]byte(s.effectivePasswordHash()), []byte(password)) == nil {
			next.ServeHTTP(w, r)
			return
		}

		// Redirect browsers to login page; send 401 for API-like requests
		if r.Header.Get("Accept") == "application/json" || r.Header.Get("X-Requested-With") != "" {
			w.Header().Set("WWW-Authenticate", `Basic realm="Felhom Hub"`)
			http.Error(w, "Unauthorized", http.StatusUnauthorized)
			return
		}
		http.Redirect(w, r, "/login", http.StatusFound)
	})
}

func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
	if r.Method == http.MethodPost {
		password := r.FormValue("password")
		effHash := s.effectivePasswordHash()
		if effHash != "" && bcrypt.CompareHashAndPassword([]byte(effHash), []byte(password)) == nil {
			// Generate random session token
			b := make([]byte, 32)
			_, _ = rand.Read(b)
			sessionToken := hex.EncodeToString(b)

			// Generate CSRF token
			cb := make([]byte, 32)
			_, _ = rand.Read(cb)
			csrfToken := hex.EncodeToString(cb)

			s.sessionsMu.Lock()
			s.sessions[sessionToken] = &hubSession{
				expiresAt: time.Now().Add(7 * 24 * time.Hour),
				csrfToken: csrfToken,
			}
			s.sessionsMu.Unlock()

			isSecure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
			http.SetCookie(w, &http.Cookie{
				Name:     "hub_session",
				Value:    sessionToken,
				Path:     "/",
				HttpOnly: true,
				SameSite: http.SameSiteLaxMode,
				Secure:   isSecure,
				MaxAge:   86400 * 7,
			})
			http.Redirect(w, r, "/", http.StatusSeeOther)
			return
		}
		// Render login with error
		w.WriteHeader(http.StatusUnauthorized)
		w.Write([]byte(`<html><head><title>Felhom Hub — Bejelentkezés</title></head><body style="font-family:sans-serif;display:flex;justify-content:center;padding-top:4rem"><form method="post" style="display:flex;flex-direction:column;gap:.75rem;width:300px"><h2>Felhom Hub</h2><p style="color:red">Hibás jelszó</p><input type="password" name="password" placeholder="Jelszó" autofocus style="padding:.5rem;border:1px solid #ccc;border-radius:4px"><button type="submit" style="padding:.5rem;background:#0083D8;color:#fff;border:none;border-radius:4px;cursor:pointer">Bejelentkezés</button></form></body></html>`))
		return
	}
	w.WriteHeader(http.StatusOK)
	w.Write([]byte(`<html><head><title>Felhom Hub — Bejelentkezés</title></head><body style="font-family:sans-serif;display:flex;justify-content:center;padding-top:4rem"><form method="post" style="display:flex;flex-direction:column;gap:.75rem;width:300px"><h2>Felhom Hub</h2><input type="password" name="password" placeholder="Jelszó" autofocus style="padding:.5rem;border:1px solid #ccc;border-radius:4px"><button type="submit" style="padding:.5rem;background:#0083D8;color:#fff;border:none;border-radius:4px;cursor:pointer">Bejelentkezés</button></form></body></html>`))
}

// validateCSRF checks the CSRF token for a session-based request.
// Returns true if CSRF is valid or if no session cookie is present (Basic Auth path).
func (s *Server) validateCSRF(r *http.Request) bool {
	cookie, err := r.Cookie("hub_session")
	if err != nil {
		// No session cookie — likely Basic Auth or programmatic access; skip CSRF
		return true
	}

	s.sessionsMu.RLock()
	sess, ok := s.sessions[cookie.Value]
	s.sessionsMu.RUnlock()
	if !ok {
		return false
	}

	submitted := r.FormValue("_csrf")
	if submitted == "" {
		submitted = r.Header.Get("X-CSRF-Token")
	}
	return submitted != "" && subtle.ConstantTimeCompare([]byte(submitted), []byte(sess.csrfToken)) == 1
}

// csrfToken returns the CSRF token for the current session.
func (s *Server) csrfToken(r *http.Request) string {
	cookie, err := r.Cookie("hub_session")
	if err != nil {
		return ""
	}
	s.sessionsMu.RLock()
	sess, ok := s.sessions[cookie.Value]
	s.sessionsMu.RUnlock()
	if !ok {
		return ""
	}
	return sess.csrfToken
}

// csrfField returns an HTML hidden input for embedding in forms.
func (s *Server) csrfField(r *http.Request) template.HTML {
	tok := s.csrfToken(r)
	return template.HTML(`<input type="hidden" name="_csrf" value="` + template.HTMLEscapeString(tok) + `">`)
}

func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
	customers, err := s.store.GetCustomers()
	if err != nil {
		s.logger.Printf("[ERROR] Dashboard: %v", err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}

	configs, _ := s.store.ListCustomerConfigs()

	type dashboardCustomer struct {
		store.CustomerSummary
		OverallStatus  string // "ok", "warn", "down", "pending"
		HostCause      string // v0.53.0 roll-up: "" or "host down|stale|pending: <id>"
		BackupAge      string
		EventCriticals int
		EventErrors    int
		EventWarnings  int
	}

	// Build map of report customers keyed by ID
	seen := make(map[string]bool)
	var data []dashboardCustomer
	for _, c := range customers {
		// Skip blocked customers
		if s.store.IsCustomerBlocked(c.CustomerID) {
			continue
		}

		seen[c.CustomerID] = true
		dc := dashboardCustomer{CustomerSummary: c}

		// Controller-derived status + the v0.53.0 dead-host roll-up (rollup.go): a customer
		// may never look better than its worst expected host.
		dc.OverallStatus, dc.HostCause = s.foldHostStatus(c.CustomerID, controllerStatus(&c), true)

		// Backup age
		if c.BackupLastSnapshot != nil {
			dc.BackupAge = timeAgo(*c.BackupLastSnapshot)
		} else {
			dc.BackupAge = "–"
		}

		// Event counts (last 24h)
		if counts, err := s.store.CountEventsBySeverity(c.CustomerID, time.Now().Add(-24*time.Hour)); err == nil {
			dc.EventCriticals = counts["critical"]
			dc.EventErrors = counts["error"]
			dc.EventWarnings = counts["warning"]
		}

		data = append(data, dc)
	}

	// Add config-only customers (no reports yet) as "pending"
	for _, cfg := range configs {
		if seen[cfg.CustomerID] || cfg.Status == "blocked" {
			continue
		}
		dc := dashboardCustomer{
			CustomerSummary: store.CustomerSummary{
				CustomerID:   cfg.CustomerID,
				CustomerName: cfg.CustomerName,
			},
			OverallStatus: "pending",
			BackupAge:     "–",
		}
		// Roll-up for the never-reported customer too: a down/stale host worsens even during
		// onboarding — only never-reported ("pending") hosts are excluded here.
		dc.OverallStatus, dc.HostCause = s.foldHostStatus(cfg.CustomerID, dc.OverallStatus, false)
		data = append(data, dc)
	}

	payload := struct {
		Customers   []dashboardCustomer
		OffsiteTile *offsiteTile // R-5: restic pool box; nil → no gauge
		PBSTile     *pbsdrTile   // R-5 v0.65.0: PBS DR datastore; nil → no gauge
	}{Customers: data, OffsiteTile: s.offsiteBoxTile(), PBSTile: s.pbsdrBoxTile()}

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

// compareVersions delegates to THE hub comparator (internal/semver). Kept as a thin wrapper so the
// web package's many call sites are unchanged.
func compareVersions(a, b string) int { return semver.Compare(a, b) }

func (s *Server) handleCSS(w http.ResponseWriter, r *http.Request) {
	data, err := templateFS.ReadFile("templates/style.css")
	if err != nil {
		http.NotFound(w, r)
		return
	}
	w.Header().Set("Content-Type", "text/css")
	w.Header().Set("Cache-Control", "public, max-age=3600")
	w.Write(data)
}

func timeAgo(t time.Time) string {
	d := time.Since(t)

	if d < time.Minute {
		return "just now"
	}
	if d < time.Hour {
		m := int(math.Round(d.Minutes()))
		return fmt.Sprintf("%d min ago", m)
	}
	if d < 24*time.Hour {
		h := int(math.Round(d.Hours()))
		return fmt.Sprintf("%dh ago", h)
	}
	days := int(d.Hours() / 24)
	return fmt.Sprintf("%dd ago", days)
}

// statusColor maps a status value to a design-system-v2 semantic token, consumed as a
// class suffix (status-dot-nominal, tag-nominal, …) — never as an inline color (D4).
// Exception-color principle: a healthy fleet is blue/neutral; amber/red only on deviation.
//
//	ok           -> nominal (blue — operating normally)
//	warn, stale  -> warn    (amber — degraded / stale report)
//	down, fail   -> crit    (red — outage)
//	pending      -> neutral (a not-yet-provisioned customer is a normal fleet state)
//	disabled     -> neutral (deliberately paused — not a deviation)
//	blocked      -> warn    (an operator cut a customer off: intentional but attention-worthy)
func statusColor(status string) string {
	switch status {
	case "ok":
		return "nominal"
	case "warn", "stale":
		return "warn"
	case "down", "fail":
		return "crit"
	case "blocked":
		return "warn"
	case "disabled", "pending":
		return "neutral"
	default:
		return "neutral"
	}
}

// handleConfiguration renders the Configuration page.
func (s *Server) handleConfiguration(w http.ResponseWriter, r *http.Request) {
	csrfToken := s.getCSRFToken(r)

	assetCount := 0
	assetLastSync := ""
	if s.assetsMgr != nil {
		assetCount = s.assetsMgr.FileCount()
		if m := s.assetsMgr.GetManifest(); m != nil {
			assetLastSync = m.Generated
		}
	}

	ctx := r.Context()

	// The two dropdowns are independent, so they are resolved side by side rather than one after the
	// other (v0.100.0). Each is internally concurrent already; doing them in series still cost the
	// full latency of BOTH package searches, and that search is the slowest single call on this page
	// — measured in-cluster at 1.1–2.2 s each against ~0.24 s for a file's metadata.
	var agentChoices, goldenChoices []artifactChoice
	var choicesWG sync.WaitGroup
	choicesWG.Add(2)
	go func() { defer choicesWG.Done(); agentChoices = s.artifactChoices(ctx, pkgAgent, fileAgent) }()
	go func() { defer choicesWG.Done(); goldenChoices = s.artifactChoices(ctx, pkgGolden, fileGolden) }()
	choicesWG.Wait()

	data := map[string]interface{}{
		"CSRFToken":     csrfToken,
		"CSRFField":     s.csrfField(r),
		"AssetCount":    assetCount,
		"AssetLastSync": assetLastSync,
		"GlobalFloor":   s.store.GetGlobalMinControllerVersion(),
		"FloorRes":      s.store.ResolveGlobalFloor(),
		"Artifacts":     s.store.GetArtifactManifest(),
		"AgentChoices":  agentChoices,
		"GoldenChoices": goldenChoices,
		"Flash":         r.URL.Query().Get("flash"),
	}
	if err := s.templates.ExecuteTemplate(w, "configuration.html", data); err != nil {
		s.logger.Printf("[ERROR] configuration.html template: %v", err)
	}
}

// handleConfigurationAction handles POST actions on the Configuration page.
func (s *Server) handleConfigurationAction(w http.ResponseWriter, r *http.Request) {
	action := r.FormValue("action")
	switch action {
	case "refresh_assets":
		if s.assetsMgr == nil {
			http.Redirect(w, r, "/configuration?flash=assets_not_configured", http.StatusSeeOther)
			return
		}
		if err := s.assetsMgr.ReSeed(); err != nil {
			s.logger.Printf("[ERROR] Asset re-seed failed: %v", err)
			http.Redirect(w, r, "/configuration?flash=assets_error", http.StatusSeeOther)
			return
		}
		s.logger.Printf("[INFO] Manual asset re-seed completed")
		http.Redirect(w, r, "/configuration?flash=assets_refreshed", http.StatusSeeOther)
	default:
		http.Redirect(w, r, "/configuration", http.StatusSeeOther)
	}
}

// minOperatorPasswordLen is the minimum accepted new operator login password length (bytes). A low
// floor by design — this is the single operator's own login, not a customer-facing credential — but
// it stops fat-finger empties/typos from silently becoming the password. bcrypt caps input at 72
// bytes, so that is the hard upper bound.
const minOperatorPasswordLen = 8

// handleChangePassword updates the operator login password from the Configuration page (v0.54.0).
// It requires the CURRENT password (verified against the effective hash — DB override → config seed),
// a new password of at least minOperatorPasswordLen bytes, and a matching confirmation. On success it
// bcrypts the new password (cost 10, matching the ConfigMap seed) and persists it to hub_settings, the
// DB override that wins over the hub.yaml seed. The ConfigMap value stays the break-glass fallback:
// blank the DB row (or edit the manifest + redeploy) to reset a lost password. Existing sessions are
// intentionally left valid — only the /login and Basic-Auth checks consult the new hash. CSRF is
// already enforced by ServeHTTP for this POST.
func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) {
	if err := r.ParseForm(); err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}
	current := r.FormValue("current_password")
	next := r.FormValue("new_password")
	confirm := r.FormValue("confirm_password")

	// Verify the current password against the effective hash. Empty effective hash (auth disabled)
	// also blocks the change — there is nothing to authenticate against.
	if eff := s.effectivePasswordHash(); eff == "" || bcrypt.CompareHashAndPassword([]byte(eff), []byte(current)) != nil {
		s.logger.Printf("[WARN] Change-password rejected: current password mismatch from %s", r.RemoteAddr)
		http.Redirect(w, r, "/configuration?flash=pw_current_wrong", http.StatusSeeOther)
		return
	}
	if len(next) < minOperatorPasswordLen {
		http.Redirect(w, r, "/configuration?flash=pw_too_short", http.StatusSeeOther)
		return
	}
	if len(next) > 72 { // bcrypt hard limit — reject up front for a friendly message
		http.Redirect(w, r, "/configuration?flash=pw_too_long", http.StatusSeeOther)
		return
	}
	if next != confirm {
		http.Redirect(w, r, "/configuration?flash=pw_mismatch", http.StatusSeeOther)
		return
	}
	if next == current { // no-op change — keep the flash honest
		http.Redirect(w, r, "/configuration?flash=pw_unchanged", http.StatusSeeOther)
		return
	}
	hash, err := bcrypt.GenerateFromPassword([]byte(next), bcrypt.DefaultCost)
	if err != nil {
		s.logger.Printf("[ERROR] Change-password: bcrypt generate failed: %v", err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	if err := s.store.SetOperatorPasswordHash(string(hash)); err != nil {
		s.logger.Printf("[ERROR] Change-password: persist failed: %v", err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	s.logger.Printf("[INFO] Operator login password changed via Configuration UI from %s", r.RemoteAddr)
	http.Redirect(w, r, "/configuration?flash=pw_changed", http.StatusSeeOther)
}
