package web

import (
	"bytes"
	"context"
	"fmt"
	"net/http"
	"net/url"
	"os"
	"os/exec"
	"path/filepath"
	"sort"
	"strings"
	"time"

	"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
	"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
	"gitea.dooplex.hu/admin/felhom-controller/internal/crypto"
	"gitea.dooplex.hu/admin/felhom-controller/internal/infra"
	"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
	"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
	"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
	"gitea.dooplex.hu/admin/felhom-controller/internal/system"
	"golang.org/x/crypto/bcrypt"
)

// protectedStackSubdomains maps programmatically managed protected stacks
// to their well-known subdomains (these stacks have no .felhom.yml or app.yaml).
var protectedStackSubdomains = map[string]string{
	"filebrowser": "files",
}

// StorageBarInfo holds data for rendering a storage usage bar on dashboard/monitoring.
type StorageBarInfo struct {
	Label        string // e.g., "USB HDD 1TB", "SYS Storage 350G"
	Path         string // e.g., "/mnt/hdd_1"
	Purpose      string // Hungarian explanation of what this drive holds (monitoring page)
	TotalGB      float64
	UsedGB       float64
	Percent      float64
	Disconnected bool
}

// storageBarPurpose is the Hungarian description for the registered user-data drives shown in the
// monitoring "Tárolók kapacitása" list. These are all external/user-data drives (the agent's
// system/PBS storage is not in the controller's storage-path registry), matching the user-data
// purpose text on the storage-management page (Phase 4C).
const storageBarPurpose = "Külső adattároló — a telepített alkalmazások nagy méretű fájljai (média, dokumentumok) ide kerülnek; az adatbázisok a belső SSD-n vannak."

// buildStorageBars returns usage bars for all registered storage paths, in a stable order
// (by path) with a purpose description.
func (s *Server) buildStorageBars() []StorageBarInfo {
	var bars []StorageBarInfo
	for _, sp := range s.settings.GetStoragePaths() {
		// Skip decommissioned drives — they are no longer in active use
		if sp.Decommissioned {
			continue
		}
		if sp.Disconnected {
			bars = append(bars, StorageBarInfo{
				Label:        sp.Label,
				Path:         sp.Path,
				Purpose:      storageBarPurpose,
				Disconnected: true,
			})
			continue
		}
		di := system.GetDiskUsage(sp.Path)
		if di == nil {
			continue
		}
		bars = append(bars, StorageBarInfo{
			Label:   sp.Label,
			Path:    sp.Path,
			Purpose: storageBarPurpose,
			TotalGB: di.TotalGB,
			UsedGB:  di.UsedGB,
			Percent: di.UsedPercent,
		})
	}
	// Deterministic order regardless of registry insertion order.
	sort.Slice(bars, func(i, j int) bool { return bars[i].Path < bars[j].Path })
	return bars
}

// DeployStoragePath extends StoragePath with free space data for the deploy dropdown.
type DeployStoragePath struct {
	settings.StoragePath
	FreeHuman   string  // "234.5 GB"
	FreePercent float64 // 67.5
}

// StorageAppDetail holds info about an app using a specific storage path.
type StorageAppDetail struct {
	Name      string // Display name (e.g., "Immich")
	Stack     string // Stack name (for link)
	SizeHuman string // Data size on this path
}

// StoragePathView extends StoragePath with display data for the settings page.
type StoragePathView struct {
	settings.StoragePath
	DiskInfo        *system.DiskUsageInfo
	AppCount        int
	IsMounted       bool
	AppDetails      []StorageAppDetail
	FSInfo          *system.FSInfo
	IsUSB           bool     // true if this is a USB-attached device (safe disconnect available)
	StoppedApps     []string // stacks auto-stopped due to disconnect (for restart UI)
	MigratedToLabel string   // label of the drive data was migrated to
	HasOtherPaths   bool     // true if other connected non-decommissioned paths exist
	IsEnrolled      bool     // enrolled via the wizard (stable /mnt/felhom-drives/ path) — lifecycle is disconnect/decommission, not list-removal
}

func (s *Server) baseData(page, title string) map[string]interface{} {
	data := map[string]interface{}{
		"Page":         page,
		"Title":        title,
		"CustomerName": s.cfg.Customer.Name,
		"Domain":       s.cfg.Customer.Domain,
		"Version":      s.version,
		"AuthEnabled":  s.authEnabled(),
		"DebugMode":    s.isDebug(),
	}
	if s.alertManager != nil {
		data["Alerts"] = s.alertManager.GetAlerts()
	}
	return data
}

func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) {
	stackList := s.stackMgr.GetStacks()

	// Filter to deployed + protected stacks first
	var deployedStacks []stacks.Stack
	for _, st := range stackList {
		if st.Deployed || st.Protected {
			deployedStacks = append(deployedStacks, st)
		}
	}

	// Count from the DISPLAYED set only
	running, stopped := 0, 0
	for _, st := range deployedStacks {
		switch st.State {
		case stacks.StateRunning, stacks.StateStarting, stacks.StateUnhealthy, stacks.StateRestarting:
			running++
		case stacks.StateStopped, stacks.StateExited:
			stopped++
		}
	}

	sysInfo := system.GetInfo(s.primaryHDDPath(), s.cpuCollector)

	data := s.baseData("dashboard", "Vezérlőpult")
	data["SettingsWarning"] = s.settings.LoadWarning // non-empty if settings.json was recovered from corruption
	data["Stacks"] = deployedStacks
	data["MissingStorage"] = s.missingStorageMap(deployedStacks)
	data["NetworkWarnings"] = s.networkStorageWarnings(deployedStacks) // NAS unreachable — recoverable warning, not "missing"
	data["RunningCount"] = running
	data["StoppedCount"] = stopped
	data["TotalCount"] = len(stackList)
	data["SystemInfo"] = sysInfo
	data["StorageBars"] = s.buildStorageBars()

	// Backup status
	data["BackupEnabled"] = s.cfg.Backup.Enabled
	if s.backupMgr != nil {
		nextDBDump := scheduler.NextDailyRun(s.cfg.Backup.DBDumpSchedule)
		fullStatus := s.backupMgr.GetFullStatus(nextDBDump)
		data["DBDumpStatus"] = fullStatus.LastDBDump
		data["BackupRunning"] = fullStatus.Running
		data["BackupMaxAgeHours"] = s.cfg.Monitoring.Thresholds.BackupMaxAgeHours
	}

	// Build subdomain map for "Megnyitás" buttons
	subdomains := make(map[string]string)
	for _, stack := range deployedStacks {
		if stack.Deployed {
			if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil {
				if sd, ok := appCfg.Env["SUBDOMAIN"]; ok && sd != "" {
					subdomains[stack.Name] = sd
					continue
				}
			}
		}
		if stack.Meta.Subdomain != "" {
			subdomains[stack.Name] = stack.Meta.Subdomain
		} else if sd, ok := protectedStackSubdomains[stack.Name]; ok {
			subdomains[stack.Name] = sd
		}
	}
	data["Subdomains"] = subdomains

	if s.alertManager != nil {
		data["DiskWarnings"] = s.alertManager.GetInlineAlerts("dashboard")
	}

	s.executeTemplate(w, r, "dashboard", data)
}

func (s *Server) stacksHandler(w http.ResponseWriter, r *http.Request) {
	data := s.baseData("stacks", "Alkalmazások")
	allStacks := s.stackMgr.GetStacks()
	data["Stacks"] = allStacks
	data["MissingStorage"] = s.missingStorageMap(allStacks)
	data["NetworkWarnings"] = s.networkStorageWarnings(allStacks) // NAS unreachable — recoverable warning, not "missing"

	// Build storage label lookup for deployed apps
	storageLabels := make(map[string]string) // stack name → storage label
	storagePaths := s.settings.GetStoragePaths()
	for _, stack := range s.stackMgr.GetStacks() {
		if !stack.Deployed {
			continue
		}
		if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil {
			if hddPath := appCfg.Env["HDD_PATH"]; hddPath != "" {
				for _, sp := range storagePaths {
					if sp.Path == hddPath {
						storageLabels[stack.Name] = sp.Label
						break
					}
				}
			}
		}
	}
	data["StorageLabels"] = storageLabels

	// Build effective subdomain lookup (stored env > metadata > well-known fallback)
	subdomains := make(map[string]string)
	for _, stack := range s.stackMgr.GetStacks() {
		if stack.Deployed {
			if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil {
				if sd, ok := appCfg.Env["SUBDOMAIN"]; ok && sd != "" {
					subdomains[stack.Name] = sd
					continue
				}
			}
		}
		if stack.Meta.Subdomain != "" {
			subdomains[stack.Name] = stack.Meta.Subdomain
		} else if sd, ok := protectedStackSubdomains[stack.Name]; ok {
			subdomains[stack.Name] = sd
		}
	}
	data["Subdomains"] = subdomains

	s.executeTemplate(w, r, "stacks", data)
}

func (s *Server) logsHandler(w http.ResponseWriter, r *http.Request, name string) {
	stack, ok := s.stackMgr.GetStack(name)
	if !ok {
		http.NotFound(w, r)
		return
	}

	logs, err := s.stackMgr.GetLogs(name, 200)
	if err != nil {
		logs = fmt.Sprintf("Hiba a naplók lekérésekor: %v", err)
	}

	// Raw mode: return plain text for AJAX polling
	if r.URL.Query().Get("raw") == "1" {
		w.Header().Set("Content-Type", "text/plain; charset=utf-8")
		fmt.Fprint(w, logs)
		return
	}

	data := s.baseData("logs", stack.Meta.DisplayName+" — Naplók")
	data["Stack"] = stack
	data["Logs"] = logs
	s.executeTemplate(w, r, "logs", data)
}

func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name string) {
	if s.isDebug() {
		s.logger.Printf("[DEBUG] [web] deployHandler: stack=%s method=%s", name, r.Method)
	}
	meta, appCfg, err := s.stackMgr.GetDeployFields(name)
	if err != nil {
		if s.isDebug() {
			s.logger.Printf("[DEBUG] [web] deployHandler: stack=%s not found: %v", name, err)
		}
		http.NotFound(w, r)
		return
	}

	stack, _ := s.stackMgr.GetStack(name)
	alreadyDeployed := appCfg != nil && appCfg.Deployed

	pageTitle := meta.DisplayName + " — Telepítés"
	if alreadyDeployed {
		pageTitle = meta.DisplayName + " — Beállítások"
	}
	data := s.baseData("deploy", pageTitle)
	data["Stack"] = stack
	data["Meta"] = meta
	data["AppConfig"] = appCfg
	data["AlreadyDeployed"] = alreadyDeployed
	data["LogoURL"] = s.cfg.AppLogoURL(meta.Slug)
	data["LogoPNGURL"] = s.cfg.AppLogoPNGURL(meta.Slug)
	data["AppPageURL"] = s.cfg.AppPageURL(meta.Slug)
	data["UserFields"] = meta.UserFacingFields()
	data["AutoFields"] = meta.AutoGeneratedFields()
	// Auto-generated field values: existing values for deployed apps, pre-generated for new deploys
	autoFieldValues := make(map[string]string)
	var decryptedEnv map[string]string
	if appCfg != nil {
		decryptedEnv = crypto.DecryptMap(s.encKey, appCfg.Env)
	}
	if alreadyDeployed && appCfg != nil {
		for _, f := range meta.AutoGeneratedFields() {
			if val, ok := decryptedEnv[f.EnvVar]; ok {
				autoFieldValues[f.EnvVar] = val
			}
		}
	} else if !alreadyDeployed {
		// Pre-generate values so the user sees (and can note down) domain/passwords before deploying.
		// These same values are submitted back in the form and saved to app.yaml.
		if preview, err := s.stackMgr.PreviewDeployValues(name); err == nil {
			autoFieldValues = preview
		}
	}
	data["AutoFieldValues"] = autoFieldValues
	// For deployed apps, pass stored field values (decrypted) so fields show current values
	if alreadyDeployed && decryptedEnv != nil {
		data["DeployedFieldValues"] = decryptedEnv
	}
	// Storage paths with free space info for deploy dropdown
	var deployPaths []DeployStoragePath
	for _, sp := range s.settings.GetSchedulableStoragePaths() {
		dp := DeployStoragePath{StoragePath: sp}
		if di := system.GetDiskUsage(sp.Path); di != nil {
			dp.FreeHuman = formatFreeSpace(di.AvailGB)
			if di.TotalGB > 0 {
				dp.FreePercent = di.AvailGB / di.TotalGB * 100
			}
		}
		deployPaths = append(deployPaths, dp)
	}
	data["StoragePaths"] = deployPaths

	// Prevention layer (storage-split): surface the Docker-data volume's reserved-buffer state so the
	// customer sees BEFORE deploying when free space is too low (the API gate also hard-refuses). Only
	// meaningful for a NEW deploy (an existing app's config save doesn't consume fresh image space).
	if !alreadyDeployed {
		if hr := system.GetDockerVolumeHeadroom(); hr.OK {
			data["DockerBelowReserve"] = hr.BelowReserve
			data["DockerFreeHuman"] = formatFreeSpace(hr.AvailGB)
			data["DockerReserveHuman"] = formatFreeSpace(hr.ReserveGB)
		}
	}

	// Effective subdomain for "Megnyitás" button
	if alreadyDeployed && appCfg != nil {
		if sd, ok := appCfg.Env["SUBDOMAIN"]; ok && sd != "" {
			data["EffectiveSubdomain"] = sd
		}
	}

	// Disk-tier storage management (drive info, stale-data cleanup, cross-drive
	// backup) has moved to the host agent (slice 8C); the deploy page no longer
	// renders those sections.
	if alreadyDeployed {
		// App-to-app integrations
		if im := s.integrationMgr.Load(); meta.HasIntegrations() && im != nil {
			data["HasIntegrations"] = true
			data["Integrations"] = im.ListForProvider(meta.Slug)
		}

		// Geo-restriction per-app data
		geo := s.settings.GetGeoRestriction()
		if geo != nil && geo.Enabled && s.cfg.Infrastructure.CFAPIToken != "" {
			data["GeoGlobalEnabled"] = true
			data["GeoGlobalCountries"] = geo.AllowedCountries
			if ov, ok := geo.AppOverrides[name]; ok {
				data["GeoAppOverride"] = true
				data["GeoAppOverrideCountries"] = ov.AllowedCountries
			} else {
				data["GeoAppOverrideCountries"] = []string{}
			}
		} else {
			data["GeoGlobalCountries"] = []string{}
			data["GeoAppOverrideCountries"] = []string{}
		}

		// Optional config (metadata providers, etc.)
		if meta.HasOptionalConfig() {
			data["HasOptionalConfig"] = true
			data["OptionalConfig"] = meta.OptionalConfig
			optValues := make(map[string]string)
			if decryptedEnv != nil {
				for _, group := range meta.OptionalConfig {
					for _, field := range group.Fields {
						if val, ok := decryptedEnv[field.EnvVar]; ok {
							optValues[field.EnvVar] = val
						}
					}
				}
			}
			data["CurrentValues"] = optValues
		}

		// App-email per-app toggle — only for apps that declare an smtp_mapping. Shown with
		// honest context whether or not the global toggle is on.
		if supported, enabled := s.stackMgr.AppEmailStatus(name); supported {
			data["AppEmailSupported"] = true
			data["AppEmailAppOn"] = enabled
			data["AppEmailGlobalOn"] = s.settings.AppEmailEnabled()
			local := meta.SMTPMapping.FromLocal
			if local == "" {
				local = meta.Slug
			}
			domain := "felhom.eu"
			if len(s.cfg.MailRelay.FromDomains) > 0 && s.cfg.MailRelay.FromDomains[0] != "" {
				domain = s.cfg.MailRelay.FromDomains[0]
			}
			data["AppEmailFromAddress"] = local + "@" + domain
		}
	}

	// Memory info for deploy page (only for non-deployed apps)
	if !alreadyDeployed {
		memInfo := map[string]interface{}{"Available": false}
		totalMB, usedMB, memErr := system.GetMemoryMB()
		if memErr == nil {
			reservedMB := s.cfg.System.ReservedMemoryMB
			usableMB := totalMB - reservedMB
			newReqMB := stacks.ParseMemoryMB(meta.Resources.MemRequest)
			afterMB := usedMB + newReqMB
			percent := 0
			if usableMB > 0 {
				percent = afterMB * 100 / usableMB
			}
			usedPercent := 0
			if usableMB > 0 {
				usedPercent = usedMB * 100 / usableMB
			}

			// Overcommit warning still uses declared limits
			_, committedLimitMB := s.stackMgr.CommittedMemory()
			newLimitMB := stacks.ParseMemoryMB(meta.Resources.MemLimit)
			afterLimitMB := committedLimitMB + newLimitMB

			memInfo["Available"] = true
			memInfo["TotalMB"] = totalMB
			memInfo["ReservedMB"] = reservedMB
			memInfo["UsableMB"] = usableMB
			memInfo["UsedMB"] = usedMB
			memInfo["NewRequestMB"] = newReqMB
			memInfo["AfterMB"] = afterMB
			memInfo["Percent"] = percent
			memInfo["UsedPercent"] = usedPercent
			memInfo["Blocked"] = newReqMB > 0 && afterMB > usableMB
			memInfo["OvercommitWarn"] = newLimitMB > 0 && afterLimitMB > totalMB
		}
		data["MemoryInfo"] = memInfo
	}

	// Flash messages from cross-drive backup save redirect
	if flash := r.URL.Query().Get("flash"); flash != "" {
		data["FlashSuccess"] = flash
	}
	if flashErr := r.URL.Query().Get("flash_error"); flashErr != "" {
		data["FlashError"] = flashErr
	}

	s.executeTemplate(w, r, "deploy", data)
}

func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug string) {
	var found *stacks.Stack
	for _, stack := range s.stackMgr.GetStacks() {
		if stack.Meta.Slug == slug {
			found = &stack
			break
		}
	}
	if found == nil {
		http.NotFound(w, r)
		return
	}

	// Determine effective subdomain (stored env > metadata fallback)
	effectiveSubdomain := found.Meta.Subdomain
	if appCfg := s.stackMgr.LoadAppConfigByName(found.Name); appCfg != nil {
		if sd, ok := appCfg.Env["SUBDOMAIN"]; ok && sd != "" {
			effectiveSubdomain = sd
		}
	}

	data := s.baseData("stacks", found.Meta.DisplayName)
	data["Stack"] = found
	data["Meta"] = found.Meta
	data["AppInfo"] = found.Meta.AppInfo
	data["HasAppInfo"] = found.Meta.HasAppInfo()
	data["EffectiveSubdomain"] = effectiveSubdomain

	// Initial auto-generated login (e.g. Crafty writes a random admin password to a file at first
	// boot). Read it live from the container so the customer doesn't have to dig through logs. Only
	// for deployed apps that declare an initial_credentials spec; hidden when unreadable.
	if found.Deployed && found.Meta.InitialCreds != nil {
		if creds, err := s.stackMgr.ReadInitialCredentials(found.Name); err != nil {
			s.logger.Printf("[WARN] [web] initial-creds for %s: %v", found.Name, err)
		} else if creds != nil && creds.Available {
			data["InitialCreds"] = creds
		}
	}

	// Per-app migration (B1): offer to move this app's data to another connected drive (≠ current).
	if found.Deployed {
		current := ""
		if appCfg := s.stackMgr.LoadAppConfigByName(found.Name); appCfg != nil {
			current = appCfg.Env["HDD_PATH"]
		}
		var targets []settings.StoragePath
		for _, sp := range s.settings.GetStoragePaths() {
			if sp.Path == current || sp.Decommissioned || sp.Disconnected || !sp.Schedulable {
				continue
			}
			targets = append(targets, sp)
		}
		data["MigrateTargets"] = targets
		data["MigrateCurrent"] = current
		if label, missing := s.missingStorageLabel(current); missing {
			data["MissingStorageLabel"] = label
		}
	}

	s.executeTemplate(w, r, "app_info", data)
}

func (s *Server) monitoringHandler(w http.ResponseWriter, r *http.Request) {
	data := s.baseData("monitoring", "Rendszermonitor")
	data["SystemInfo"] = system.GetInfo(s.primaryHDDPath(), s.cpuCollector)
	data["StorageBars"] = s.buildStorageBars()

	if s.alertManager != nil {
		data["Alerts"] = s.alertManager.GetAlerts()
		data["DiskWarnings"] = s.alertManager.GetInlineAlerts("monitoring")
	}

	// Hub connection status section
	data["HubEnabled"] = s.cfg.Hub.Enabled && s.cfg.Hub.URL != ""
	data["HubURL"] = s.cfg.Hub.URL
	data["CustomerID"] = s.cfg.Customer.ID

	if s.hubPushStatusFn != nil {
		ps := s.hubPushStatusFn()
		data["HubLastAttempt"] = ps.LastAttempt
		data["HubLastSuccess"] = ps.LastSuccess
		data["HubLastError"] = ps.LastError
		data["HubConsecutiveFailures"] = ps.Consecutive
		// Connected if last success was within 2x the push interval (or 30min default)
		connected := !ps.LastSuccess.IsZero() && time.Since(ps.LastSuccess) < 30*time.Minute
		data["HubConnected"] = connected
	}

	// Legacy ping status section (still shown for backward compat during transition)
	data["MonitoringEnabled"] = s.cfg.Monitoring.Enabled
	if s.cfg.Monitoring.Enabled {
		pings := []map[string]interface{}{
			{"Label": "Eletjel (Heartbeat)", "Icon": "heartbeat", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.Heartbeat), "Schedule": "5 percenkent"},
			{"Label": "Rendszer allapot", "Icon": "system", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.SystemHealth), "Schedule": "5 percenkent"},
			{"Label": "Adatbazis mentes", "Icon": "db", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.DBDump), "Schedule": "Naponta " + s.cfg.Backup.DBDumpSchedule},
			{"Label": "Biztonsagi mentes", "Icon": "backup", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.Backup), "Schedule": "Naponta " + s.cfg.Backup.ResticSchedule},
			{"Label": "Mentes integritas", "Icon": "integrity", "Configured": isPingConfigured(s.cfg.Monitoring.PingUUIDs.BackupIntegrity), "Schedule": "Hetente (vasarnap)"},
		}
		allConfigured := true
		for _, p := range pings {
			if !p["Configured"].(bool) {
				allConfigured = false
				break
			}
		}
		data["PingStatus"] = pings
		data["AllPingsConfigured"] = allConfigured
	}

	s.executeTemplate(w, r, "monitoring", data)
}

// isPingConfigured returns true if a healthcheck ping UUID is non-empty and not a placeholder.
func isPingConfigured(uuid string) bool {
	return uuid != "" && !strings.HasPrefix(uuid, "CHANGEME")
}

func (s *Server) backupsHandler(w http.ResponseWriter, r *http.Request) {
	data := s.baseData("backups", "Biztonsági mentés")

	// System info for storage overview bars
	data["SystemInfo"] = system.GetInfo(s.primaryHDDPath(), s.cpuCollector)
	data["StorageBars"] = s.buildStorageBars()

	// Whole-guest backup view (agent-sourced, read-only) for the "Rendszermentés" section.
	data["GuestBackup"] = s.loadGuestBackup(r.Context())

	if s.backupMgr != nil {
		nextDBDump := scheduler.NextDailyRun(s.cfg.Backup.DBDumpSchedule)
		fullStatus := s.backupMgr.GetFullStatus(nextDBDump)

		// Pass flash messages from query params (set by redirect handlers)
		if flash := r.URL.Query().Get("flash"); flash != "" {
			fullStatus.FlashSuccess = flash
		}
		if flashErr := r.URL.Query().Get("flash_error"); flashErr != "" {
			fullStatus.FlashError = flashErr
		}

		// Enrich AppDataInfo with storage labels
		storagePaths := s.settings.GetStoragePaths()
		for i := range fullStatus.AppDataInfo {
			app := &fullStatus.AppDataInfo[i]
			if len(app.HDDPaths) > 0 {
				hddPath := app.HDDPaths[0].HostPath
				// Match HDD path prefix against registered storage paths
				for _, sp := range storagePaths {
					if strings.HasPrefix(hddPath, sp.Path) {
						app.StorageLabel = sp.Label
						break
					}
				}
			}
		}

		// Build unified per-app backup rows for the app-data backup UI.
		// Disk-tier (cross-drive / restic) backup has moved to the host agent.
		data["AppBackupRows"] = s.buildAppBackupRows(fullStatus)

		data["Backup"] = fullStatus

		// DB dump total size
		var dbDumpTotalBytes int64
		for _, f := range fullStatus.DumpFiles {
			dbDumpTotalBytes += f.Size
		}
		data["DBDumpTotalBytes"] = dbDumpTotalBytes

		// Off-box (NAS) restic-SFTP backup (Part B): the target status + per-app off-box toggles.
		data["Offbox"] = s.settings.GetOffboxTarget()
		data["OffboxConfigured"] = s.backupMgr.OffboxConfigured()
		data["OffboxApps"] = s.buildOffboxApps()
	} else {
		data["Backup"] = nil
	}

	s.executeTemplate(w, r, "backups", data)
}

// OffboxAppRow is one deployed app's off-box toggle state for the backups page.
type OffboxAppRow struct {
	Name        string
	DisplayName string
	Enabled     bool
}

// buildOffboxApps lists deployed, non-protected apps with their off-box toggle state.
func (s *Server) buildOffboxApps() []OffboxAppRow {
	var out []OffboxAppRow
	if s.stackMgr == nil {
		return out
	}
	for _, st := range s.stackMgr.GetStacks() {
		if !st.Deployed || st.Protected {
			continue
		}
		dn := st.Meta.DisplayName
		if dn == "" {
			dn = st.Name
		}
		out = append(out, OffboxAppRow{Name: st.Name, DisplayName: dn, Enabled: s.settings.IsAppOffbox(st.Name)})
	}
	return out
}

// AppBackupRow holds per-tier backup information for one app on the backup page.
type AppBackupRow struct {
	StackName   string
	DisplayName string
	Status      string // "green", "yellow", "red", "auto"
	StatusText  string // short Hungarian tooltip

	// App characteristics
	HasHDDData    bool
	HasDB         bool
	HasVolumeData bool
	StorageLabel  string
	HDDSizeHuman  string

	// What this app's backup contains (for display)
	// e.g., "DB + Konfiguráció + Adatok", "DB + Konfiguráció", "Konfiguráció"
	BackupContents string

	// Tier 1: Nightly backup (always exists)
	Tier1LastRun    string // formatted time of last restic snapshot
	Tier1LastStatus string // "ok", "error", ""
	Tier1DBStatus   string // "ok", "error", "" — separate DB dump status for warning

	// Tier 2: Cross-drive backup (configurable for all apps)
	Tier2Configured  bool
	Tier2Dest        string // destination label
	Tier2Schedule    string // "Naponta", "Hetente"
	Tier2LastRun     string
	Tier2LastStatus  string // "ok", "error", "running", ""
	Tier2LastError   string
	Tier2StatusBadge string // "Sikeres", "Hiba", "Fut...", "—"
	Tier2SizeHuman   string

	// Drive disconnected — app's home drive is currently disconnected
	DriveDisconnected bool
	// Tier2 destination drive is currently disconnected (backup paused, not failed)
	Tier2DestDisconnected bool
	// Tier2 destination drive is inactive (Schedulable=false, backup paused)
	Tier2DestInactive bool
	// Tier2UserDisabled — customer turned Tier 2 off for this app from the config panel.
	Tier2UserDisabled bool

	// Warnings accumulated for this app
	Warnings []string
}

// buildAppBackupRows constructs one AppBackupRow per deployed app for the backup page.
// Disk-tier (cross-drive / restic) backup has moved to the host agent; this now
// reflects only the app-data backup (DB dumps + Docker-volume tars).
func (s *Server) buildAppBackupRows(status *backup.FullBackupStatus) []AppBackupRow {
	// Build DB stack lookup
	dbStacks := make(map[string]bool)
	for _, db := range status.DiscoveredDBs {
		dbStacks[db.StackName] = true
	}
	for _, f := range status.DumpFiles {
		dbStacks[f.StackName] = true
	}

	tier1DBStatus := ""
	if status.LastDBDump != nil {
		if status.LastDBDump.Success {
			tier1DBStatus = "ok"
		} else {
			tier1DBStatus = "error"
		}
	}

	// Build disconnected paths set for drive-disconnected detection
	disconnectedPaths := make(map[string]bool)
	for _, dp := range s.settings.GetDisconnectedPaths() {
		disconnectedPaths[dp.Path] = true
	}

	var rows []AppBackupRow
	for _, app := range status.AppDataInfo {
		hasDB := dbStacks[app.StackName] || app.HasDBDump

		// Check if this app's home drive is disconnected
		driveDisconnected := false
		if app.HasHDDData && len(app.HDDPaths) > 0 {
			for dp := range disconnectedPaths {
				for _, hp := range app.HDDPaths {
					if strings.HasPrefix(hp.HostPath, dp+"/") || hp.HostPath == dp {
						driveDisconnected = true
						break
					}
				}
				if driveDisconnected {
					break
				}
			}
		}

		// Build backup contents label
		var parts []string
		if hasDB {
			parts = append(parts, "DB")
		}
		parts = append(parts, "Konfig")
		if app.HasHDDData || app.HasVolumeData {
			parts = append(parts, "Adatok")
		}
		contents := strings.Join(parts, " + ")

		row := AppBackupRow{
			StackName:         app.StackName,
			DisplayName:       app.DisplayName,
			HasHDDData:        app.HasHDDData,
			HasDB:             hasDB,
			HasVolumeData:     app.HasVolumeData,
			DriveDisconnected: driveDisconnected,
			StorageLabel:      app.StorageLabel,
			HDDSizeHuman:      app.HDDSizeHuman,
			BackupContents:    contents,
			Tier1DBStatus:     tier1DBStatus,
		}

		// Status dot — app-data backup status
		row.Status = "green"
		row.StatusText = "Alkalmazás-adat mentés rendben"
		if hasDB && tier1DBStatus == "error" {
			row.Status = "yellow"
			row.StatusText = "Adatbázis mentés sikertelen"
		}

		// Tier 2 (off-drive copy) status, from the config the Tier 2 runner persists.
		if cd := s.settings.GetCrossDriveConfig(app.StackName); cd != nil {
			row.Tier2UserDisabled = cd.UserDisabled
			if cd.UserDisabled {
				// Customer turned Tier 2 off — show nothing more; the panel button still appears.
			} else if cd.LastStatus == "no_target" {
				// Auto Tier 2 found no off-drive target — surface the honest reason (no silent gap).
				row.Tier2Configured = false
				row.Tier2StatusBadge = "Nincs 2. meghajtó"
				row.Tier2LastError = cd.LastError
			} else if cd.Enabled {
				row.Tier2Configured = true
				row.Tier2Dest = tier2DestLabel(cd.DestinationPath, s.cfg.Paths.SystemDataPath)
				row.Tier2Schedule = "Naponta"
				row.Tier2LastRun = cd.LastRun
				row.Tier2LastStatus = cd.LastStatus
				row.Tier2LastError = cd.LastError
				row.Tier2SizeHuman = cd.LastSizeHuman
				switch cd.LastStatus {
				case "ok":
					row.Tier2StatusBadge = "Sikeres"
				case "error":
					row.Tier2StatusBadge = "Hiba"
				case "running":
					row.Tier2StatusBadge = "Fut..."
				default:
					row.Tier2StatusBadge = "—"
				}
			}
		}

		rows = append(rows, row)
	}
	return rows
}

// tier2DestLabel renders a friendly destination label for the "2. mentés" card. A destination under
// the system-data path is the internal SSD (DB/config only); otherwise it's an external drive.
func tier2DestLabel(destPath, systemDataPath string) string {
	if systemDataPath != "" && strings.HasPrefix(destPath, systemDataPath) {
		return "belső SSD (csak DB/konfiguráció)"
	}
	return filepath.Base(strings.TrimSuffix(destPath, "/"+backup.FelhomDataDir))
}

func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
	_ = r.ParseForm()

	stackName := r.FormValue("stack_name")
	snapshotID := r.FormValue("snapshot_id")

	if s.isDebug() {
		s.logger.Printf("[DEBUG] [web] backupRestoreHandler: stack=%s snapshot=%s from %s", stackName, snapshotID, r.RemoteAddr)
	}

	if stackName == "" || snapshotID == "" {
		http.Redirect(w, r, "/backups?flash_error=Hi%C3%A1nyz%C3%B3+param%C3%A9terek", http.StatusFound)
		return
	}
	// F2 (defense-in-depth): a stack name is a single segment, never a path. Reject traversal before any
	// restore work — never let it reach RestoreFromRecoveryUnit.
	if !validStackName(stackName) {
		s.logger.Printf("[WARN] [web] restore rejected: invalid stack_name %q from %s", stackName, r.RemoteAddr)
		http.Redirect(w, r, "/backups?flash_error=%C3%89rv%C3%A9nytelen+alkalmaz%C3%A1sn%C3%A9v", http.StatusFound)
		return
	}

	if s.backupMgr == nil {
		http.Redirect(w, r, "/backups?flash_error=Ment%C3%A9s+nincs+be%C3%A1ll%C3%ADtva", http.StatusFound)
		return
	}
	// Part B: restore is a long SYNCHRONOUS op (F4 — through cloudflared's hard 100s cap the customer
	// got an error page while it silently succeeded). Fast-path refuse a concurrent op, then run it in
	// a BACKGROUND goroutine (survives the request; the poll banner shows progress → result).
	if s.backupMgr.IsRunning() {
		http.Redirect(w, r, "/backups?flash_error="+url.QueryEscape("Egy mentési/visszaállítási művelet már fut."), http.StatusFound)
		return
	}
	s.logger.Printf("[WARN] [web] Restore requested (async): stack=%s, snapshot=%s from %s", stackName, snapshotID, r.RemoteAddr)
	s.backupMgr.BeginRestoreOp("restore", stackName)
	go func() {
		start := time.Now()
		// Phase 2b: restore from the app's recovery unit (recovers secrets from the guest, fail-closed
		// on an unrecoverable data-encrypting key; falls back to volume-only restore if no unit exists).
		if err := s.backupMgr.RestoreFromRecoveryUnit(stackName); err != nil {
			s.logger.Printf("[ERROR] [web] Restore failed (async): stack=%s: %v", stackName, err)
			s.backupMgr.EndRestoreOp(false, "Visszaállítás sikertelen: "+err.Error())
			return
		}
		s.logger.Printf("[INFO] [web] Restore completed (async): stack=%s in %s", stackName, time.Since(start))
		s.backupMgr.EndRestoreOp(true, stackName+" visszaállítva ("+snapshotID+").")
	}()
	http.Redirect(w, r, "/backups?flash="+url.QueryEscape("Visszaállítás elindult — az állapot itt frissül."), http.StatusFound)
}

// backupTier2RestoreHandler (C2, closes F2) restores an app's MISSING user files in place from its
// recorded Tier-2 copy — additive-only: existing live files are never overwritten and nothing is
// ever deleted (see backup.RestoreTier2Files). Same handler shape as backupRestoreHandler.
func (s *Server) backupTier2RestoreHandler(w http.ResponseWriter, r *http.Request) {
	_ = r.ParseForm()
	stackName := r.FormValue("stack_name")

	if stackName == "" {
		http.Redirect(w, r, "/backups?flash_error=Hi%C3%A1nyz%C3%B3+param%C3%A9terek", http.StatusFound)
		return
	}
	// Same F2-defense as the unit restore: a stack name is a single segment, never a path.
	if !validStackName(stackName) {
		s.logger.Printf("[WARN] [web] Tier-2 file restore rejected: invalid stack_name %q from %s", stackName, r.RemoteAddr)
		http.Redirect(w, r, "/backups?flash_error=%C3%89rv%C3%A9nytelen+alkalmaz%C3%A1sn%C3%A9v", http.StatusFound)
		return
	}
	if s.backupMgr == nil {
		http.Redirect(w, r, "/backups?flash_error=Ment%C3%A9s+nincs+be%C3%A1ll%C3%ADtva", http.StatusFound)
		return
	}
	// Part B (same async shape as backupRestoreHandler): fast-path refuse, then background goroutine.
	if s.backupMgr.IsRunning() {
		http.Redirect(w, r, "/backups?flash_error="+url.QueryEscape("Egy mentési/visszaállítási művelet már fut."), http.StatusFound)
		return
	}
	s.logger.Printf("[WARN] [web] Tier-2 file restore requested (async): stack=%s from %s", stackName, r.RemoteAddr)
	s.backupMgr.BeginRestoreOp("tier2-restore", stackName)
	go func() {
		n, err := s.backupMgr.RestoreTier2Files(stackName)
		if err != nil {
			s.logger.Printf("[ERROR] [web] Tier-2 file restore failed (async): stack=%s: %v", stackName, err)
			s.backupMgr.EndRestoreOp(false, "Fájl-visszaállítás sikertelen: "+err.Error())
			return
		}
		msg := "Nincs hiányzó fájl — minden fájl megvan a helyén."
		if n > 0 {
			msg = fmt.Sprintf("%s: %d fájl visszaállítva a másodlagos másolatból.", stackName, n)
		}
		s.logger.Printf("[INFO] [web] Tier-2 file restore completed (async): stack=%s (%d files)", stackName, n)
		s.backupMgr.EndRestoreOp(true, msg)
	}()
	http.Redirect(w, r, "/backups?flash="+url.QueryEscape("Fájl-visszaállítás elindult — az állapot itt frissül."), http.StatusFound)
}

// settingsBaseData is the shared identity block used by every settings-family subpage
// (D1 split: /settings, /settings/notifications, /settings/security, /storage).
func (s *Server) settingsBaseData(page, title string) map[string]interface{} {
	data := s.baseData(page, title)
	data["CustomerID"] = s.cfg.Customer.ID
	data["CustomerDomain"] = s.cfg.Customer.Domain
	return data
}

// systemPageData builds the Rendszer subpage: read-only configuration, version/update,
// controller + server restart.
func (s *Server) systemPageData() map[string]interface{} {
	data := s.settingsBaseData("settings", "Beállítások")
	data["GitRepoURL"] = s.cfg.Git.RepoURL
	data["GitSyncInterval"] = s.cfg.Git.SyncInterval
	data["BackupEnabled"] = s.cfg.Backup.Enabled
	data["DBDumpSchedule"] = s.cfg.Backup.DBDumpSchedule
	data["ResticSchedule"] = s.cfg.Backup.ResticSchedule
	data["MonitoringEnabled"] = s.cfg.Monitoring.Enabled
	data["HealthchecksBase"] = s.cfg.Monitoring.HealthchecksBase
	data["HubEnabled"] = s.cfg.Hub.Enabled

	// Self-update status
	data["SelfUpdateEnabled"] = s.cfg.SelfUpdate.Enabled
	if s.updater != nil {
		status := s.updater.GetStatus()
		data["UpdateRunning"] = status.Running
		if status.LastCheck != nil {
			data["UpdateAvailable"] = status.LastCheck.UpdateAvailable
			data["LatestVersion"] = status.LastCheck.LatestVersion
			data["LastCheckTime"] = status.LastCheck.CheckedAt
			data["LastCheckError"] = status.LastCheck.Error
		}
		if status.LastState != nil {
			data["LastUpdateState"] = status.LastState
		}
		data["AutoUpdateEnabled"] = s.cfg.SelfUpdate.AutoUpdate
		data["AutoUpdateTime"] = s.cfg.SelfUpdate.AutoUpdateTime
		// Phase 2 managed updates: the operator-enforced minimum version (FLOOR) the box auto-updates
		// to. Empty = none set by the operator.
		data["ControllerFloor"] = s.updater.GetFloor()
	}
	return data
}

// storagePageData builds the Tárhely page: physical drive registry, NAS shares, and the
// data the unified agent-enriched drive view needs.
func (s *Server) storagePageData() map[string]interface{} {
	data := s.settingsBaseData("storage", "Tárhely")

	// Storage paths with display data
	storagePaths := s.settings.GetStoragePaths()
	connectedCount := 0
	for _, sp := range storagePaths {
		if !sp.Disconnected && !sp.Decommissioned {
			connectedCount++
		}
	}
	var storageViews []StoragePathView
	for _, sp := range storagePaths {
		// NAS network shares are rendered in their OWN section (NetworkStoragePaths) with the agent's
		// per-share health — never in the physical-drive list (which offers eject/decommission/wipe).
		if sp.IsNetwork() {
			continue
		}
		view := StoragePathView{
			StoragePath:   sp,
			StoppedApps:   sp.StoppedStacks,
			HasOtherPaths: connectedCount > 1,
			IsEnrolled:    strings.HasPrefix(sp.Path, "/mnt/felhom-drives/"),
		}
		if sp.Disconnected {
			// Skip I/O calls on disconnected drives — they'd hang or fail
			view.IsMounted = false
		} else if sp.Decommissioned {
			view.IsMounted = false
			view.MigratedToLabel = s.settings.GetStorageLabel(sp.MigratedTo)
		} else {
			view.IsMounted = system.IsMountPoint(sp.Path)
			view.AppDetails = s.appDetailsForPath(sp.Path)
			view.FSInfo = system.GetFSInfo(sp.Path)
			view.AppCount = len(view.AppDetails)
			if di := system.GetDiskUsage(sp.Path); di != nil {
				view.DiskInfo = di
			}
			// Detect USB for safe disconnect button
			if view.FSInfo != nil && view.FSInfo.Device != "" {
				view.IsUSB = system.IsUSBDevice(view.FSInfo.Device)
			}
		}
		storageViews = append(storageViews, view)
	}
	data["StoragePaths"] = storageViews
	return data
}

// networkStoragePageData builds the Tárhely → Hálózati tárhely (NAS) subpage: the NAS shares
// with the agent's per-share health (ok/idle/unreachable/unknown). Split from the physical
// drive page — the two storage classes were confusingly interleaved on one page.
func (s *Server) networkStoragePageData() map[string]interface{} {
	data := s.settingsBaseData("storage-network", "Hálózati tárhely")
	data["NetworkStoragePaths"] = s.networkStorageItems(context.Background())
	return data
}

// notificationsPageData builds the Értesítések subpage: notification prefs + app-email.
func (s *Server) notificationsPageData() map[string]interface{} {
	data := s.settingsBaseData("settings-notifications", "Értesítések")
	data["HubEnabled"] = s.cfg.Hub.Enabled
	data["NotificationPrefs"] = s.settings.GetNotificationPrefs()

	// App-email (SMTP relay) — global toggle. Only meaningful when a hub is configured (the relay
	// path runs through the hub); the template hides the control otherwise.
	appEmail := s.settings.GetAppEmail()
	data["AppEmailEnabled"] = appEmail.Enabled
	data["AppEmailFromName"] = appEmail.FromName
	data["AppEmailAvailable"] = s.cfg.Hub.URL != "" && s.cfg.MailRelay.HardEnabled()
	return data
}

// securityPageData builds the Biztonság és hozzáférés subpage: password, geo-restriction,
// emergency/recovery info.
func (s *Server) securityPageData() map[string]interface{} {
	data := s.settingsBaseData("settings-security", "Biztonság és hozzáférés")

	// Recovery info for emergency section
	data["RetrievalPassword"] = s.settings.GetRetrievalPassword()
	data["HubURL"] = s.cfg.Hub.URL
	data["SupportEmail"] = "support@felhom.eu"
	data["SupportURL"] = "https://felhom.eu/kapcsolat"

	// Geo-restriction data
	data["CFConfigured"] = s.cfg.Infrastructure.CFAPIToken != ""
	geo := s.settings.GetGeoRestriction()
	if geo != nil {
		data["GeoEnabled"] = geo.Enabled
		data["GeoAllowedCountries"] = geo.AllowedCountries
		data["GeoAppOverrides"] = geo.AppOverrides
		data["GeoLastSync"] = geo.LastSync
		data["GeoLastError"] = geo.LastSyncError
	} else {
		data["GeoEnabled"] = false
		data["GeoAllowedCountries"] = []string{"HU"}
		data["GeoAppOverrides"] = map[string]interface{}{}
	}
	// Deployed apps for per-app override selector
	var deployedApps []map[string]string
	for _, stack := range s.stackMgr.GetStacks() {
		if !stack.Deployed || s.cfg.IsProtectedStack(stack.Name) {
			continue
		}
		deployedApps = append(deployedApps, map[string]string{
			"Name":    stack.Name,
			"Display": stack.Meta.DisplayName,
		})
	}
	data["DeployedApps"] = deployedApps
	return data
}

func (s *Server) settingsHandler(w http.ResponseWriter, r *http.Request) {
	s.executeTemplate(w, r, "settings_system", s.systemPageData())
}

// storagePageHandler serves the Tárhely main-nav page (D1). Storage action flashes land here.
func (s *Server) storagePageHandler(w http.ResponseWriter, r *http.Request) {
	data := s.storagePageData()
	if msg := r.URL.Query().Get("storage_msg"); msg == "success" {
		data["StorageSuccess"] = r.URL.Query().Get("storage_detail")
	}
	s.executeTemplate(w, r, "storage", data)
}

// storageNetworkPageHandler serves the Tárhely → Hálózati tárhely (NAS) subpage.
func (s *Server) storageNetworkPageHandler(w http.ResponseWriter, r *http.Request) {
	s.executeTemplate(w, r, "storage_network", s.networkStoragePageData())
}

// settingsNotificationsPageHandler serves GET /settings/notifications (the POST on the same
// path is the save handler — dispatch is split in the router).
func (s *Server) settingsNotificationsPageHandler(w http.ResponseWriter, r *http.Request) {
	s.executeTemplate(w, r, "settings_notifications", s.notificationsPageData())
}

// settingsSecurityPageHandler serves GET /settings/security.
func (s *Server) settingsSecurityPageHandler(w http.ResponseWriter, r *http.Request) {
	s.executeTemplate(w, r, "settings_security", s.securityPageData())
}

func (s *Server) settingsPasswordHandler(w http.ResponseWriter, r *http.Request) {
	_ = r.ParseForm()
	currentPassword := r.FormValue("current_password")
	newPassword := r.FormValue("new_password")
	confirmPassword := r.FormValue("confirm_password")

	if s.isDebug() {
		s.logger.Printf("[DEBUG] [web] settingsPasswordHandler: password change attempt from %s", r.RemoteAddr)
	}

	data := s.securityPageData()

	// Validate current password
	effectiveHash := s.effectivePasswordHash()
	if err := bcrypt.CompareHashAndPassword([]byte(effectiveHash), []byte(currentPassword)); err != nil {
		if s.isDebug() {
			s.logger.Printf("[DEBUG] [web] settingsPasswordHandler: current password mismatch from %s", r.RemoteAddr)
		}
		data["PasswordError"] = "Hibás jelenlegi jelszó"
		s.executeTemplate(w, r, "settings_security", data)
		return
	}

	// Validate new password length
	if len(newPassword) < 8 {
		data["PasswordError"] = "A jelszónak legalább 8 karakter hosszúnak kell lennie"
		s.executeTemplate(w, r, "settings_security", data)
		return
	}

	// Validate passwords match
	if newPassword != confirmPassword {
		data["PasswordError"] = "A két jelszó nem egyezik"
		s.executeTemplate(w, r, "settings_security", data)
		return
	}

	// Generate bcrypt hash
	hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), 10)
	if err != nil {
		s.logger.Printf("[ERROR] [web] Failed to hash new password: %v", err)
		data["PasswordError"] = "Belső hiba a jelszó mentésekor"
		s.executeTemplate(w, r, "settings_security", data)
		return
	}

	// Save to settings.json
	if err := s.settings.SetPasswordHash(string(hash)); err != nil {
		s.logger.Printf("[ERROR] [web] Failed to save password to settings.json: %v", err)
		data["PasswordError"] = "Belső hiba a jelszó mentésekor"
		s.executeTemplate(w, r, "settings_security", data)
		return
	}

	s.logger.Printf("[INFO] [web] Password changed via settings page from %s", r.RemoteAddr)

	// Invalidate all sessions (force re-login)
	s.invalidateAllSessions()

	// Redirect to login with flash message
	flash := url.QueryEscape("Jelszó sikeresen módosítva. Kérjük, jelentkezzen be az új jelszóval.")
	http.Redirect(w, r, "/login?flash="+flash, http.StatusFound)
}

func (s *Server) settingsNotificationsHandler(w http.ResponseWriter, r *http.Request) {
	_ = r.ParseForm()

	if s.isDebug() {
		s.logger.Printf("[DEBUG] [web] settingsNotificationsHandler: updating notification prefs from %s", r.RemoteAddr)
	}

	email := strings.TrimSpace(r.FormValue("notification_email"))
	cooldownStr := r.FormValue("cooldown_hours")
	cooldownHours := 6
	if cooldownStr != "" {
		if n, err := fmt.Sscanf(cooldownStr, "%d", &cooldownHours); n != 1 || err != nil {
			cooldownHours = 6
		}
	}
	if cooldownHours < 1 {
		cooldownHours = 1
	}
	if cooldownHours > 168 {
		cooldownHours = 168
	}

	// Collect enabled events from checkboxes
	var enabledEvents []string
	// Single-event checkboxes
	for _, evt := range []string{
		"backup_failed", "db_dump_failed", "backup_integrity_failed",
		"crossdrive_failed", "storage_disconnected",
		"node_down", "health_critical",
		"storage_reconnected", "health_recovered",
	} {
		if r.FormValue("event_"+evt) == "on" {
			enabledEvents = append(enabledEvents, evt)
		}
	}
	// Compound toggles: one checkbox → two event types
	if r.FormValue("event_disk_alerts") == "on" {
		enabledEvents = append(enabledEvents, "disk_warning", "disk_critical")
	}
	if r.FormValue("event_expected_missed") == "on" {
		enabledEvents = append(enabledEvents, "expected_backup_missed", "expected_dbdump_missed")
	}

	prefs := &settings.NotificationPrefs{
		Email:         email,
		EnabledEvents: enabledEvents,
		CooldownHours: cooldownHours,
	}

	if err := s.settings.SetNotificationPrefs(prefs); err != nil {
		s.logger.Printf("[ERROR] [web] Failed to save notification prefs: %v", err)
		data := s.notificationsPageData()
		data["NotificationError"] = "Hiba a beállítások mentésekor"
		s.executeTemplate(w, r, "settings_notifications", data)
		return
	}

	s.logger.Printf("[INFO] [web] Notification preferences updated: email=%s, events=%v", email, enabledEvents)

	// Sync preferences to hub
	data := s.notificationsPageData()
	if s.notifier != nil && s.notifier.IsEnabled() {
		if err := s.notifier.SyncPreferences(email, enabledEvents, cooldownHours); err != nil {
			s.logger.Printf("[WARN] [web] Failed to sync preferences to hub: %v", err)
			data["NotificationSuccess"] = fmt.Sprintf("Értesítési beállítások mentve (helyi). A központi szinkronizálás sikertelen: %v", err)
		} else {
			data["NotificationSuccess"] = "Értesítési beállítások mentve."
		}
	} else {
		data["NotificationSuccess"] = "Értesítési beállítások mentve."
	}
	s.executeTemplate(w, r, "settings_notifications", data)
}

// settingsAppEmailHandler saves the global app-email toggle and starts/stops the on-box
// SMTP shim to match (no controller restart needed).
func (s *Server) settingsAppEmailHandler(w http.ResponseWriter, r *http.Request) {
	_ = r.ParseForm()
	enabled := r.FormValue("app_email_enabled") == "on" || r.FormValue("app_email_enabled") == "true"
	fromName := strings.TrimSpace(r.FormValue("app_email_from_name"))

	data := s.notificationsPageData()
	if err := s.settings.SetAppEmail(enabled, fromName); err != nil {
		s.logger.Printf("[ERROR] [web] Failed to save app-email toggle: %v", err)
		data["AppEmailError"] = "Hiba az alkalmazás-email beállítás mentésekor"
		s.executeTemplate(w, r, "settings_notifications", data)
		return
	}
	// Reconcile the shim's running state with the new toggle.
	if s.mailShim != nil {
		if err := s.mailShim.Apply(enabled); err != nil {
			s.logger.Printf("[ERROR] [web] app-email shim could not be %s: %v", map[bool]string{true: "started", false: "stopped"}[enabled], err)
			data = s.notificationsPageData()
			data["AppEmailError"] = "A beállítás elmentve, de az email-szolgáltatás indítása nem sikerült."
			s.executeTemplate(w, r, "settings_notifications", data)
			return
		}
	}
	s.logger.Printf("[INFO] [web] App-email globally %s (from_name=%q)", map[bool]string{true: "enabled", false: "disabled"}[enabled], fromName)
	data = s.notificationsPageData()
	if enabled {
		data["AppEmailSuccess"] = "Alkalmazás-email bekapcsolva. Kapcsold be az egyes alkalmazásoknál is, ahol email-küldést szeretnél."
	} else {
		data["AppEmailSuccess"] = "Alkalmazás-email kikapcsolva."
	}
	s.executeTemplate(w, r, "settings_notifications", data)
}

func (s *Server) settingsNotificationsTestHandler(w http.ResponseWriter, r *http.Request) {
	data := s.notificationsPageData()

	if s.notifier == nil {
		data["NotificationError"] = "Az értesítések nincsenek bekapcsolva"
		s.executeTemplate(w, r, "settings_notifications", data)
		return
	}

	err := s.notifier.SendTest()
	if err != nil {
		s.logger.Printf("[ERROR] [web] Test notification failed: %v", err)
		data["NotificationError"] = fmt.Sprintf("Teszt email küldése sikertelen: %v", err)
		s.executeTemplate(w, r, "settings_notifications", data)
		return
	}

	data["NotificationSuccess"] = "Teszt email elküldve."
	s.executeTemplate(w, r, "settings_notifications", data)
}

// --- Storage path management handlers ---

func (s *Server) countAppsUsingPath(storagePath string) int {
	count := 0
	for _, stack := range s.stackMgr.GetStacks() {
		if !stack.Deployed {
			continue
		}
		if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil {
			if appCfg.Env["HDD_PATH"] == storagePath {
				count++
			}
		}
	}
	return count
}

func (s *Server) appsUsingPath(storagePath string) []string {
	return appsUsingPathIn(s.stackMgr.GetStacks(), s.stackMgr.LoadAppConfigByName, storagePath)
}

// missingStorageLabel reports whether a deployed app's HDD_PATH resolves to an UNAVAILABLE registry
// path (decommissioned, disconnected, or no longer registered) and returns its human label. An app
// with no HDD_PATH (SSD-resident) is never "missing".
func (s *Server) missingStorageLabel(hddPath string) (string, bool) {
	if hddPath == "" {
		return "", false
	}
	for _, sp := range s.settings.GetStoragePaths() {
		if sp.Path == hddPath {
			// A NAS network path is NEVER "missing": its availability is the agent's per-share liveness
			// (surfaced as a recoverable warning by networkStorageWarnings), NOT the drive missing/stop
			// cascade. An `unreachable` NAS must not look like a removed drive.
			if sp.IsNetwork() {
				return "", false
			}
			if sp.Decommissioned || sp.Disconnected {
				return s.settings.GetStorageLabel(hddPath), true
			}
			return "", false // present + available
		}
	}
	return s.settings.GetStorageLabel(hddPath), true // not in registry → its drive is gone
}

// networkStorageWarnings returns stack-name → share label for every deployed app whose HDD_PATH is a NAS
// network path the agent currently reports `unreachable`. This is a RECOVERABLE warning ("hálózati
// tárhely nem elérhető"), explicitly NOT the drive missing/stop-cascade — the app keeps running and the
// badge clears when the NAS returns. Best-effort: an agent error or no network paths → no warnings.
func (s *Server) networkStorageWarnings(list []stacks.Stack) map[string]string {
	out := map[string]string{}
	if s.settings == nil || s.stackMgr == nil {
		return out
	}
	netPaths := map[string]settings.StoragePath{}
	for _, sp := range s.settings.GetStoragePaths() {
		if sp.IsNetwork() {
			netPaths[sp.Path] = sp
		}
	}
	if len(netPaths) == 0 {
		return out
	}
	agent, err := s.agentClient()
	if err != nil {
		return out
	}
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	mounts, err := agent.ListNetStorage(ctx)
	if err != nil {
		s.logger.Printf("[WARN] [web] network storage health unavailable for warnings: %v", err)
		return out
	}
	unreachable := map[string]string{} // path → label
	for _, m := range mounts {
		if !m.Unreachable() { // only `unreachable` is degraded; `idle`/`ok` are benign
			continue
		}
		p := settings.NetworkMountRoot + "/" + m.Name
		if sp, ok := netPaths[p]; ok {
			lbl := sp.Label
			if lbl == "" {
				lbl = m.Name
			}
			unreachable[p] = lbl
		}
	}
	if len(unreachable) == 0 {
		return out
	}
	for _, st := range list {
		if !st.Deployed {
			continue
		}
		if cfg := s.stackMgr.LoadAppConfigByName(st.Name); cfg != nil {
			if lbl, bad := unreachable[cfg.Env["HDD_PATH"]]; bad {
				out[st.Name] = lbl
			}
		}
	}
	return out
}

// missingStorageMap returns stack-name → storage label for every deployed app whose data drive is
// currently unavailable (drives the "Hiányzó tárhely" dashboard/stacks/app-card indicator).
func (s *Server) missingStorageMap(list []stacks.Stack) map[string]string {
	out := map[string]string{}
	for _, st := range list {
		if !st.Deployed {
			continue
		}
		if cfg := s.stackMgr.LoadAppConfigByName(st.Name); cfg != nil {
			if label, missing := s.missingStorageLabel(cfg.Env["HDD_PATH"]); missing {
				out[st.Name] = label
			}
		}
	}
	return out
}

// appsUsingPathIn is the pure core of appsUsingPath (testable without a live stacks.Manager): the
// deployed apps whose data dir (app.yaml HDD_PATH) is exactly storagePath, by display name. This is
// the "name the apps that break" list for the type-to-confirm wipe/eject UI.
func appsUsingPathIn(allStacks []stacks.Stack, loadCfg func(string) *stacks.AppConfig, storagePath string) []string {
	var names []string
	for _, stack := range allStacks {
		if !stack.Deployed {
			continue
		}
		if appCfg := loadCfg(stack.Name); appCfg != nil {
			if appCfg.Env["HDD_PATH"] == storagePath {
				names = append(names, stack.Meta.DisplayName)
			}
		}
	}
	return names
}

func (s *Server) appDetailsForPath(storagePath string) []StorageAppDetail {
	var details []StorageAppDetail
	for _, stack := range s.stackMgr.GetStacks() {
		if !stack.Deployed {
			continue
		}
		appCfg := s.stackMgr.LoadAppConfigByName(stack.Name)
		if appCfg == nil {
			continue
		}
		hddPath := appCfg.Env["HDD_PATH"]
		if hddPath != storagePath {
			continue
		}
		detail := StorageAppDetail{
			Name:  stack.Meta.DisplayName,
			Stack: stack.Meta.Slug,
		}
		// Try to get data size from the storage subdirectory
		appDataDir := backup.AppDataDir(storagePath, stack.Name)
		if fi, err := os.Stat(appDataDir); err == nil && fi.IsDir() {
			detail.SizeHuman = dirSizeHuman(appDataDir)
		}
		details = append(details, detail)
	}
	return details
}

// dirSizeHuman returns a human-readable size for a directory.
func dirSizeHuman(path string) string {
	var total int64
	filepath.Walk(path, func(_ string, info os.FileInfo, err error) error {
		if err != nil || info.IsDir() {
			return nil
		}
		total += info.Size()
		return nil
	})
	const (
		KB = 1024
		MB = KB * 1024
		GB = MB * 1024
	)
	switch {
	case total >= GB:
		return fmt.Sprintf("%.1f GB", float64(total)/float64(GB))
	case total >= MB:
		return fmt.Sprintf("%.1f MB", float64(total)/float64(MB))
	case total >= KB:
		return fmt.Sprintf("%.1f KB", float64(total)/float64(KB))
	default:
		return fmt.Sprintf("%d B", total)
	}
}

func formatFreeSpace(gb float64) string {
	if gb >= 1000 {
		return fmt.Sprintf("%.1f TB", gb/1024)
	}
	return fmt.Sprintf("%.1f GB", gb)
}

func (s *Server) settingsStorageAddHandler(w http.ResponseWriter, r *http.Request) {
	_ = r.ParseForm()

	path := filepath.Clean(r.FormValue("storage_path"))
	label := strings.TrimSpace(r.FormValue("storage_label"))
	isDefault := r.FormValue("storage_default") == "true"

	if s.isDebug() {
		s.logger.Printf("[DEBUG] [web] settingsStorageAddHandler: path=%s label=%q default=%v from %s", path, label, isDefault, r.RemoteAddr)
	}

	if label == "" {
		label = settings.InferStorageLabel(path)
	}

	data := s.storagePageData()

	// 1. Exists and is directory
	fi, err := os.Stat(path)
	if err != nil || !fi.IsDir() {
		data["StorageError"] = "Az útvonal nem létezik vagy nem mappa."
		s.executeTemplate(w, r, "storage", data)
		return
	}

	// 2. Is mount point
	if !system.IsMountPoint(path) {
		data["StorageError"] = "Ez az útvonal nem külön csatlakoztatott meghajtó. Adatok az SSD-re kerülnének!"
		s.executeTemplate(w, r, "storage", data)
		return
	}

	// 3. Writable
	if !system.IsWritable(path) {
		data["StorageError"] = "Az útvonal nem írható."
		s.executeTemplate(w, r, "storage", data)
		return
	}

	// 4. No overlap with existing paths
	for _, existing := range s.settings.GetStoragePaths() {
		if system.PathsOverlap(path, existing.Path) {
			data["StorageError"] = fmt.Sprintf("Az útvonal átfedi a már regisztrált %s útvonalat.", existing.Path)
			s.executeTemplate(w, r, "storage", data)
			return
		}
	}

	// 5. Soft warning if not under /mnt/
	if !strings.HasPrefix(path, "/mnt/") {
		s.logger.Printf("[WARN] [web] Storage path %s is not under /mnt/ — unusual but allowed", path)
	}

	sp := settings.StoragePath{
		Path:        path,
		Label:       label,
		IsDefault:   isDefault,
		Schedulable: true,
		AddedAt:     time.Now().UTC().Format(time.RFC3339),
	}

	if err := s.settings.AddStoragePath(sp); err != nil {
		s.logger.Printf("[ERROR] [web] Failed to add storage path: %v", err)
		data["StorageError"] = "Hiba a mentés során."
		s.executeTemplate(w, r, "storage", data)
		return
	}

	s.logger.Printf("[INFO] [web] Storage path added: %s (%s)", path, label)
	go s.SyncFileBrowserMounts()
	http.Redirect(w, r, "/storage?storage_msg=success&storage_detail="+url.QueryEscape("Adattároló sikeresen hozzáadva: "+path), http.StatusFound)
}

func (s *Server) settingsStorageRemoveHandler(w http.ResponseWriter, r *http.Request) {
	_ = r.ParseForm()
	path := r.FormValue("storage_path")

	if s.isDebug() {
		s.logger.Printf("[DEBUG] [web] settingsStorageRemoveHandler: path=%s from %s", path, r.RemoteAddr)
	}

	data := s.storagePageData()

	// Check: apps using this path
	apps := s.appsUsingPath(path)
	if len(apps) > 0 {
		data["StorageError"] = fmt.Sprintf("Nem törölhető: az alábbi alkalmazások használják: %s", strings.Join(apps, ", "))
		s.executeTemplate(w, r, "storage", data)
		return
	}

	// Check: cannot remove default
	for _, sp := range s.settings.GetStoragePaths() {
		if sp.Path == path && sp.IsDefault {
			data["StorageError"] = "Az alapértelmezett adattároló nem törölhető."
			s.executeTemplate(w, r, "storage", data)
			return
		}
	}

	// Check: last path
	if len(s.settings.GetStoragePaths()) <= 1 {
		data["StorageError"] = "Az utolsó adattároló nem törölhető."
		s.executeTemplate(w, r, "storage", data)
		return
	}

	if err := s.settings.RemoveStoragePath(path); err != nil {
		data["StorageError"] = "Hiba a törlés során."
		s.executeTemplate(w, r, "storage", data)
		return
	}

	s.logger.Printf("[INFO] [web] Storage path removed: %s", path)
	// Sync FileBrowser mounts after storage path removal
	go s.SyncFileBrowserMounts()
	http.Redirect(w, r, "/storage?storage_msg=success&storage_detail="+url.QueryEscape("Adattároló eltávolítva: "+path), http.StatusFound)
}

func (s *Server) settingsStorageDefaultHandler(w http.ResponseWriter, r *http.Request) {
	_ = r.ParseForm()
	path := r.FormValue("storage_path")

	if s.isDebug() {
		s.logger.Printf("[DEBUG] [web] settingsStorageDefaultHandler: path=%s from %s", path, r.RemoteAddr)
	}

	if err := s.settings.SetDefaultStoragePath(path); err != nil {
		s.logger.Printf("[ERROR] [web] Failed to set default storage path: %v", err)
		http.Redirect(w, r, "/storage", http.StatusFound)
		return
	}
	s.logger.Printf("[INFO] [web] Default storage path set to %s", path)
	http.Redirect(w, r, "/storage?storage_msg=success&storage_detail="+url.QueryEscape("Alapértelmezett adattároló beállítva: "+path), http.StatusFound)
}

func (s *Server) settingsStorageSchedulableHandler(w http.ResponseWriter, r *http.Request) {
	_ = r.ParseForm()
	path := r.FormValue("storage_path")
	schedulable := r.FormValue("schedulable") == "true"

	if s.isDebug() {
		s.logger.Printf("[DEBUG] [web] settingsStorageSchedulableHandler: path=%s schedulable=%v from %s", path, schedulable, r.RemoteAddr)
	}

	if err := s.settings.SetSchedulable(path, schedulable); err != nil {
		s.logger.Printf("[ERROR] [web] Failed to update schedulable: %v", err)
		http.Redirect(w, r, "/storage", http.StatusFound)
		return
	}
	s.logger.Printf("[INFO] [web] Storage schedulable updated: %s → %v", path, schedulable)
	http.Redirect(w, r, "/storage?storage_msg=success&storage_detail="+url.QueryEscape("Adattároló állapot módosítva: "+path), http.StatusFound)
}

func (s *Server) settingsStorageLabelHandler(w http.ResponseWriter, r *http.Request) {
	_ = r.ParseForm()
	path := r.FormValue("storage_path")
	label := strings.TrimSpace(r.FormValue("storage_label"))

	if s.isDebug() {
		s.logger.Printf("[DEBUG] [web] settingsStorageLabelHandler: path=%s label=%q from %s", path, label, r.RemoteAddr)
	}

	if label == "" || len(label) > 50 {
		data := s.storagePageData()
		data["StorageError"] = "A megnevezés nem lehet üres és legfeljebb 50 karakter."
		s.executeTemplate(w, r, "storage", data)
		return
	}

	if err := s.settings.SetStorageLabel(path, label); err != nil {
		s.logger.Printf("[ERROR] [web] Failed to set storage label: %v", err)
		data := s.storagePageData()
		data["StorageError"] = "Hiba a megnevezés mentésekor."
		s.executeTemplate(w, r, "storage", data)
		return
	}

	s.logger.Printf("[INFO] [web] Storage label updated: %s → %q", path, label)
	http.Redirect(w, r, "/storage?storage_msg=success&storage_detail="+url.QueryEscape("Megnevezés módosítva: "+label), http.StatusFound)
}

// SyncFileBrowserMounts regenerates FileBrowser's docker-compose.yml and config.yaml
// with volume mounts and sources for all registered storage paths, then recreates the container.
func (s *Server) SyncFileBrowserMounts() {
	s.syncFileBrowserMounts(false)
}

// SyncFileBrowserMountsReset is like SyncFileBrowserMounts but resets the FileBrowser
// database when sources change. Use only after restore — normal operations should use
// SyncFileBrowserMounts to preserve user accounts, permissions, and share links.
func (s *Server) SyncFileBrowserMountsReset() {
	s.syncFileBrowserMounts(true)
}

// skipFileBrowserPath reports whether a registered storage path should be skipped this FileBrowser
// sync pass: an EXTERNAL drive path (under StableParentDir) that is not currently a live mountpoint is
// detached, so its userdata skeleton must not be created (would land on the rootfs) and it must not be
// mounted into FileBrowser until it returns. System/local paths (not under StableParentDir) are never
// skipped. Pure + isMount-injected for testability.
func skipFileBrowserPath(path string, isMount func(string) bool) bool {
	return strings.HasPrefix(path, StableParentDir+"/") && !isMount(path)
}

func (s *Server) syncFileBrowserMounts(resetDBOnChange bool) {
	// Prevent concurrent syncs — multiple callers can race on the same files (H5 fix).
	s.fileBrowserMu.Lock()
	defer s.fileBrowserMu.Unlock()

	stackDir := "/opt/docker/stacks/filebrowser"
	composePath := stackDir + "/docker-compose.yml"

	// Check if FileBrowser stack exists
	if _, err := os.Stat(composePath); os.IsNotExist(err) {
		s.logger.Printf("[WARN] [web] FileBrowser stack not found at %s — skipping mount sync", composePath)
		return
	}

	// Get all active storage paths
	paths := s.settings.GetStoragePaths()

	// Use domain from controller config
	domain := s.cfg.Customer.Domain
	if domain == "" {
		s.logger.Printf("[WARN] [web] Cannot sync FileBrowser mounts — customer domain not configured")
		return
	}

	// Build volume mount lines. SCOPE to the drive's `userdata/` subtree (v0.66.0): the customer
	// browses ONLY userdata — app internals (appdata/) and the recovery units + Tier 2 copies
	// (backups/) are NOT mounted into FileBrowser. userdata is owned group 1000 mode 2775 (setgid),
	// and FileBrowser runs as uid 1000 → it can create folders + upload files (the old appdata mount
	// was guest-root 0755 → permission-denied). Pre-create the full skeleton with the convention.
	var storageMounts []string
	for _, sp := range paths {
		mountName := filepath.Base(sp.Path) // "/mnt/hdd_1" → "hdd_1"
		// Drive-absent gate: an external drive path that isn't currently a live mountpoint is detached —
		// don't create its userdata skeleton (would write onto the rootfs) and don't mount it into
		// FileBrowser this pass. It returns on the next sync after reconnect. Matches planDriveGates'
		// external-only rule (system paths, not under StableParentDir, are never skipped).
		if skipFileBrowserPath(sp.Path, system.IsMountPoint) {
			s.logger.Printf("[INFO] [web] FileBrowser: drive %s not mounted — skipping userdata skeleton", sp.Path)
			continue
		}
		if err := appbackup.EnsureUserdataSkeleton(sp.Path); err != nil {
			s.logger.Printf("[WARN] [web] FileBrowser: could not ensure userdata skeleton on %s: %v", sp.Path, err)
		}
		userdataSrc := appbackup.UserdataDir(sp.Path)
		line := fmt.Sprintf("      - %s:/srv/%s", userdataSrc, mountName)
		storageMounts = append(storageMounts, line)
	}

	// Generate and write config.yaml (sources + sidebar entries per drive)
	configPath := stackDir + "/config.yaml"
	fbConfig := generateFileBrowserConfig(paths)

	// Capture the current on-disk content BEFORE any writes, so we can detect whether this sync
	// actually changes anything (F2). The integrations' ReapplyConfigForTarget edits config.yaml
	// after we write it, so the recreate decision is made AFTER the writes against the final files.
	oldConfig, _ := os.ReadFile(configPath)
	oldCompose, _ := os.ReadFile(composePath)

	// Detect if sources changed — if so, the database must be reset so
	// FileBrowser picks up the new source list (user prefs cache old sources).
	sourcesChanged := string(oldConfig) != fbConfig

	if err := os.WriteFile(configPath, []byte(fbConfig), 0644); err != nil {
		s.logger.Printf("[ERROR] [web] Failed to write FileBrowser config: %v", err)
		return
	}

	// Re-apply active integrations into config.yaml (before container restart)
	if im := s.integrationMgr.Load(); im != nil {
		im.ReapplyConfigForTarget("filebrowser")
	}

	// Generate and write compose (includes config.yaml mount)
	compose := generateFileBrowserCompose(domain, storageMounts)
	if err := os.WriteFile(composePath, []byte(compose), 0644); err != nil {
		s.logger.Printf("[ERROR] [web] Failed to write FileBrowser compose: %v", err)
		return
	}

	// Read back the FINAL content (post-integrations) to decide whether a recreate is warranted (F2):
	// a controller restart or a no-op storage sync must NOT bounce the customer's file UI when nothing
	// actually changed. The recreate only happens when config.yaml or the compose file truly differ.
	finalConfig, _ := os.ReadFile(configPath)
	finalCompose, _ := os.ReadFile(composePath)
	changed := fbNeedsRecreate(oldConfig, finalConfig, oldCompose, finalCompose)

	// If sources changed and caller requested a DB reset (restore flow),
	// nuke the data volume so FileBrowser re-reads config.yaml from scratch.
	// Normal operations skip this to preserve user accounts, permissions, and share links.
	if sourcesChanged && resetDBOnChange {
		s.logger.Printf("[INFO] [web] FileBrowser sources changed — resetting database (restore mode)")
		resetCtx, resetCancel := context.WithTimeout(context.Background(), 30*time.Second)
		defer resetCancel()
		stop := exec.CommandContext(resetCtx, "docker", "compose", "down", "-v")
		stop.Dir = stackDir
		if out, err := stop.CombinedOutput(); err != nil {
			s.logger.Printf("[WARN] [web] FileBrowser down -v: %s — %v", strings.TrimSpace(string(out)), err)
		}
		changed = true // a DB reset removed the container — it must be recreated
	}

	// Bring FileBrowser up. H16: 60s timeout to prevent hanging indefinitely. Only force-recreate when
	// something actually changed; otherwise a plain `up -d` just ensures it's running without a bounce.
	ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
	defer cancel()
	args := []string{"compose", "up", "-d", "--remove-orphans"}
	if changed {
		args = []string{"compose", "up", "-d", "--force-recreate", "--remove-orphans"}
	}
	cmd := exec.CommandContext(ctx, "docker", args...)
	cmd.Dir = stackDir
	if out, err := cmd.CombinedOutput(); err != nil {
		s.logger.Printf("[ERROR] [web] Failed to bring up FileBrowser: %s — %v", string(out), err)
	} else if changed {
		s.logger.Printf("[INFO] [web] FileBrowser mounts synced (recreated) — %d storage path(s), config updated", len(paths))
	} else {
		s.logger.Printf("[INFO] [web] FileBrowser sync — no config/compose change, ensured running without recreate (%d storage path(s))", len(paths))
	}
}

// fbNeedsRecreate reports whether the FileBrowser container must be force-recreated: true when either
// the config.yaml or the compose file content changed between the pre-sync and post-sync state. On the
// first-ever run the old files are empty → differs from the freshly generated content → true (creates
// it). Pure, so syncFileBrowserMounts' recreate decision is unit-testable without shelling to docker.
func fbNeedsRecreate(oldConfig, newConfig, oldCompose, newCompose []byte) bool {
	return !bytes.Equal(oldConfig, newConfig) || !bytes.Equal(oldCompose, newCompose)
}

// generateFileBrowserCompose returns a FileBrowser docker-compose.yml string with the given domain
// and storage volume-mount lines. Delegates to internal/infra (the single source of truth — so the
// pinned image and the base-infra bring-up path can never diverge).
func generateFileBrowserCompose(domain string, storageMounts []string) string {
	return infra.RenderFileBrowserCompose(domain, storageMounts)
}

// generateFileBrowserConfig returns a FileBrowser Quantum config.yaml with a separate source per
// registered storage path. Delegates to internal/infra (single source of truth).
func generateFileBrowserConfig(paths []settings.StoragePath) string {
	return infra.RenderFileBrowserConfig(paths)
}
