package settings

import (
	"encoding/json"
	"fmt"
	"log"
	"os"
	"path/filepath"
	"strings"
	"sync"
	"time"

	"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
)

// Settings holds customer-modifiable overrides and cached state.
// Persisted as a single JSON file (settings.json) in the data directory.
type Settings struct {
	mu    sync.RWMutex `json:"-"`
	path  string       `json:"-"`
	log   *log.Logger  `json:"-"`
	debug bool         `json:"-"`

	// LoadWarning is set (non-empty, Hungarian) when Load recovered from a corrupt settings.json —
	// surfaced to the dashboard as a persistent banner. Not persisted.
	LoadWarning string `json:"-"`

	// Auth
	PasswordHash string `json:"password_hash,omitempty"` // bcrypt hash, overrides controller.yaml

	// Notification preferences (Phase 2 — define struct now, leave empty)
	Notifications *NotificationPrefs `json:"notifications,omitempty"`

	// Cached state
	DBValidations map[string]DBValidationCache `json:"db_validations,omitempty"`

	// Per-app backup preferences
	AppBackup map[string]AppBackupPrefs `json:"app_backup,omitempty"`

	// Storage paths registry
	StoragePaths []StoragePath `json:"storage_paths,omitempty"`

	// Cross-drive restic repo password (auto-generated on first use)
	CrossDriveResticPassword string `json:"cross_drive_restic_password,omitempty"`

	// Last-seen guest boot-id (intermediary-mount model): persisted so the controller can detect a guest
	// reboot across its own restart (it restarts with the guest) and deterministically recreate
	// drive-backed apps once the agent re-propagates the drive.
	LastGuestBootID string `json:"last_guest_boot_id,omitempty"`

	// Hub verification state
	HubVerified   bool   `json:"hub_verified,omitempty"`
	HubVerifiedAt string `json:"hub_verified_at,omitempty"` // RFC3339
	HubLastCheck  string `json:"hub_last_check,omitempty"`  // RFC3339

	// AppliedConfigVersion is the hub config_version this controller has last pulled + applied
	// (v0.26.0 pull-based config-refresh). 0 = none recorded yet → the first report ACK records the
	// baseline without restarting. A change vs. the ACK triggers a re-pull + self-restart.
	AppliedConfigVersion int `json:"applied_config_version,omitempty"`

	// Recovery credentials (saved from setup wizard input)
	RetrievalPassword string `json:"retrieval_password,omitempty"`

	// Pending events (queued for next Hub push)
	PendingEvents []PendingEvent `json:"pending_events,omitempty"`

	// Geo-restriction settings (Cloudflare WAF rules)
	GeoRestriction *GeoRestriction `json:"geo_restriction,omitempty"`

	// App-to-app integration state (e.g., "onlyoffice:filebrowser" → state)
	Integrations map[string]IntegrationState `json:"integrations,omitempty"`

	// AppEmail is the global app-email (SMTP relay) toggle. When on, deployed apps with an
	// smtp_mapping can send mail via the in-controller shim → hub → Resend. Relay-only:
	// no BYO host/port/user/pass (that escape hatch is deferred).
	AppEmail *AppEmail `json:"app_email,omitempty"`

	// Offbox is the off-box (NAS) restic-SFTP backup target (Part B). One per box. No secrets here —
	// the repo password + SSH key are 0600 files in the data dir.
	Offbox *OffboxTarget `json:"offbox,omitempty"`
}

// AppEmail holds the global app-email toggle and an optional household display name.
type AppEmail struct {
	Enabled  bool   `json:"enabled"`
	FromName string `json:"from_name,omitempty"` // optional household display name for the From line
}

// IntegrationState holds the state of a provider:target integration pair.
type IntegrationState struct {
	Enabled   bool   `json:"enabled"`
	EnabledAt string `json:"enabled_at,omitempty"` // RFC3339
	Status    string `json:"status,omitempty"`     // "active", "error", "disabled", "provider_stopped", "target_unavailable"
	LastError string `json:"last_error,omitempty"`
}

// AppBackupPrefs holds per-app backup toggle state.
type AppBackupPrefs struct {
	// Existing: includes app data in nightly restic (same drive)
	Enabled bool `json:"enabled"`

	// Cross-drive backup to secondary storage
	CrossDrive *CrossDriveBackup `json:"cross_drive,omitempty"`

	// Offbox: include this app's recovery unit + DB dumps in the off-box (NAS) restic-SFTP backup
	// (Part B — the "1 off-site" leg of 3-2-1, distinct from the local cross-drive copy and PBS whole-CT).
	Offbox bool `json:"offbox,omitempty"`
}

// OffboxTarget configures the single off-box (NAS) backup destination: an encrypted restic repo reached
// over SFTP (Part B). It holds NO secrets — the repo password + SSH private key live in 0600 files in the
// controller data dir (off-box of the secrets rides DR via the PBS whole-CT snapshot of the rootfs); the
// known-host key is pinned out-of-band. Runtime status is persisted for the UI.
type OffboxTarget struct {
	Enabled  bool   `json:"enabled"`
	Host     string `json:"host"`
	Port     int    `json:"port"`      // default 22
	User     string `json:"user"`
	RepoPath string `json:"repo_path"` // absolute path on the NAS, e.g. /volume1/felhom-backup/repo
	Schedule string `json:"schedule"`  // "daily" | "manual"

	// Runtime status (written by the off-box runner; never holds a secret).
	LastRun       string `json:"last_run,omitempty"`    // RFC3339
	LastStatus    string `json:"last_status,omitempty"` // "ok" | "error" | "running"
	LastError     string `json:"last_error,omitempty"`
	LastDuration  string `json:"last_duration,omitempty"`
	RepoSizeHuman string `json:"repo_size_human,omitempty"`
	SnapshotCount int    `json:"snapshot_count,omitempty"`
	// LastWarning is a customer-visible notice set on an otherwise-OK run when SOME toggled apps had
	// no discoverable recovery unit (partial run). Empty on a fully-successful or failed run.
	LastWarning string `json:"last_warning,omitempty"`
	// EscrowState (fork-4) gates offsite RUNS on the repo password being escrowed under R: ""|"pending"
	// |"escrowed". Enabling offsite stages the password to the agent and sets "pending"; no offsite run
	// proceeds until an operator confirms the escrow ceremony ("escrowed") — so no un-recoverable
	// offsite ciphertext can exist. It is NOT a secret (a state label); the password never lives here.
	EscrowState string `json:"escrow_state,omitempty"`
}

// CrossDriveBackup configures per-app backup to a secondary drive.
type CrossDriveBackup struct {
	Enabled         bool   `json:"enabled"`
	Method          string `json:"method"`           // "rsync" or "restic"
	DestinationPath string `json:"destination_path"` // e.g., "/mnt/hdd_1"
	Schedule        string `json:"schedule"`         // "daily", "weekly", "manual"

	// Runtime state (updated by backup runner, persisted for display)
	LastRun       string `json:"last_run,omitempty"`    // RFC3339
	LastStatus    string `json:"last_status,omitempty"` // "ok", "error", "running"
	LastError     string `json:"last_error,omitempty"`
	LastDuration  string `json:"last_duration,omitempty"`   // "2m34s"
	LastSizeHuman string `json:"last_size_human,omitempty"` // "1.2 GB"

	// Customer preference (set from the per-app Tier-2 config panel; PRESERVED across the runner's
	// status writes). UserDisabled turns Tier 2 off for this app; PreferredTarget pins a chosen
	// destination drive (a registered storage Path) instead of the auto-pick ("" = auto).
	UserDisabled    bool   `json:"user_disabled,omitempty"`
	PreferredTarget string `json:"preferred_target,omitempty"`
}

// Storage path kinds. A DRIVE is a physical disk with the full enroll/eject/decommission/migrate/wipe
// lifecycle (agent /disks). A NETWORK share is a NAS (Part A2) proxied to the agent /netstorage — a
// DISTINCT class with NO drive lifecycle (no eject/decommission/migrate/wipe/SMART; remove is the only
// lifecycle action). An empty Kind means "drive" (back-compat with already-persisted paths).
const (
	StorageKindDrive   = "drive"
	StorageKindNetwork = "network"
)

// NetworkMountRoot is the in-guest path under which the agent propagates NAS shares (mirrors the agent's
// /mnt/felhom-drives bind root). A registered network path is NetworkMountRoot + "/" + <share name>.
const NetworkMountRoot = "/mnt/felhom-drives"

// StoragePath represents a registered external storage location.
type StoragePath struct {
	Path             string   `json:"path"`                        // e.g., "/mnt/hdd_1" (drive) or "/mnt/felhom-drives/<name>" (network)
	Label            string   `json:"label,omitempty"`             // e.g., "Külső HDD 1TB"
	IsDefault        bool     `json:"is_default,omitempty"`        // new apps use this by default
	Schedulable      bool     `json:"schedulable"`                 // whether new apps can be deployed here
	AddedAt          string   `json:"added_at"`                    // RFC3339
	Disconnected     bool     `json:"disconnected,omitempty"`      // true when drive detected as disconnected
	DisconnectedAt   string   `json:"disconnected_at,omitempty"`   // RFC3339 timestamp of disconnect detection
	StoppedStacks    []string `json:"stopped_stacks,omitempty"`    // stacks auto-stopped on disconnect
	Decommissioned   bool     `json:"decommissioned,omitempty"`    // true when drive data migrated to another
	DecommissionedAt string   `json:"decommissioned_at,omitempty"` // RFC3339 timestamp
	MigratedTo       string   `json:"migrated_to,omitempty"`       // path of target drive

	// Kind discriminates a physical drive ("" / "drive") from a NAS network share ("network"). A network
	// share is bulk-media only and carries the fields below; the drive lifecycle does NOT apply to it.
	Kind string `json:"kind,omitempty"`
	// Network-storage descriptors (Kind=="network" only; mirror the agent A1 add request). NO password
	// is stored — the SMB credential is passed through to the agent at add-time and never persisted here.
	Protocol  string `json:"protocol,omitempty"` // nfs | smb
	Server    string `json:"server,omitempty"`
	Export    string `json:"export,omitempty"`
	MappedUID int    `json:"mapped_uid,omitempty"`
	MappedGID int    `json:"mapped_gid,omitempty"`
}

// IsNetwork reports whether this is a NAS network-storage path (vs a physical drive). The drive
// lifecycle (eject/decommission/migrate/wipe/SMART) must NEVER be applied to a network path.
func (p StoragePath) IsNetwork() bool { return p.Kind == StorageKindNetwork }

// NotificationPrefs holds customer notification preferences.
type NotificationPrefs struct {
	Email         string   `json:"email,omitempty"`
	EnabledEvents []string `json:"enabled_events,omitempty"`
	CooldownHours int      `json:"cooldown_hours,omitempty"` // default: 6
}

// DefaultEnabledEvents are the events enabled by default for new customers.
var DefaultEnabledEvents = []string{
	"backup_failed",
	"db_dump_failed",
	"disk_warning",
	"disk_critical",
	"storage_disconnected",
	"node_down",
	"health_critical",
	"expected_backup_missed",
	"expected_dbdump_missed",
}

// PendingEvent is an event queued for the next Hub push cycle.
type PendingEvent struct {
	EventType string `json:"event_type"`
	Severity  string `json:"severity"`
	Message   string `json:"message"`
	Details   string `json:"details"`    // JSON string
	CreatedAt string `json:"created_at"` // RFC3339
}

// GeoRestriction holds global and per-app geo-restriction settings.
type GeoRestriction struct {
	Enabled          bool                      `json:"enabled"`
	AllowedCountries []string                  `json:"allowed_countries"`
	AppOverrides     map[string]AppGeoOverride `json:"app_overrides,omitempty"`

	// Sync state (updated by geo sync manager)
	LastSync      string `json:"last_sync,omitempty"` // RFC3339
	LastSyncError string `json:"last_sync_error,omitempty"`
	ZoneID        string `json:"zone_id,omitempty"`    // cached Cloudflare zone ID
	RulesetID     string `json:"ruleset_id,omitempty"` // cached Cloudflare ruleset ID
}

// AppGeoOverride holds per-app country override.
type AppGeoOverride struct {
	AllowedCountries []string `json:"allowed_countries"`
}

// DBValidationCache holds cached DB dump validation results.
type DBValidationCache struct {
	ValidatedAt string `json:"validated_at"` // RFC3339
	TableCount  int    `json:"table_count"`
	HasHeader   bool   `json:"has_header"`
	Error       string `json:"error,omitempty"`
	// M18: Size + ModTime let ListDumpFiles skip the expensive line-by-line re-validation on every
	// ~5-min scheduler cycle when the dump file is unchanged. A cache entry is a HIT only when both the
	// file size and (RFC3339, second-precision) modtime match the on-disk file.
	Size    int64  `json:"size,omitempty"`
	ModTime string `json:"mod_time,omitempty"` // RFC3339 (UTC)
}

// SetDebug enables or disables debug logging for settings operations.
func (s *Settings) SetDebug(debug bool) {
	s.debug = debug
}

// Load reads settings from the given file path.
// Returns empty Settings if the file doesn't exist (not an error).
func Load(path string, logger *log.Logger) (*Settings, error) {
	s := &Settings{
		path: path,
		log:  logger,
	}

	data, err := os.ReadFile(path)
	if err != nil {
		if os.IsNotExist(err) {
			logger.Printf("[INFO] [settings] No settings.json found, using defaults")
			return s, nil
		}
		return nil, fmt.Errorf("reading settings file: %w", err)
	}

	if err := json.Unmarshal(data, s); err != nil {
		// CORRUPT primary — never crash-loop. Recover from the last-known-good .bak; failing that,
		// preserve the corrupt file for forensics and start on safe defaults (recoverable: an empty
		// PasswordHash falls back to controller.yaml; the storage registry re-discovers on startup).
		logger.Printf("[ERROR] [settings] primary settings corrupt (%v) — attempting recovery from .bak", err)
		if bak, berr := os.ReadFile(path + ".bak"); berr == nil {
			s2 := &Settings{path: path, log: logger}
			if json.Unmarshal(bak, s2) == nil {
				logger.Printf("[WARN] [settings] recovered settings from .bak; re-promoting to primary")
				_ = os.WriteFile(path, bak, 0644) // best-effort promote
				s2.LoadWarning = "settings.json volt sérült — visszaállítva biztonsági másolatból"
				s2.migrateResticToRsync()
				return s2, nil
			}
		}
		corrupt := fmt.Sprintf("%s.corrupt-%d", path, time.Now().Unix())
		_ = os.Rename(path, corrupt)
		logger.Printf("[ERROR] [settings] settings unrecoverable — preserved as %s; starting with safe defaults", corrupt)
		return &Settings{path: path, log: logger, LoadWarning: "settings.json sérült és helyreállíthatatlan — alapértelmezett beállítások"}, nil
	}

	logger.Printf("[INFO] [settings] Loaded settings from %s", path)
	if s.debug {
		s.log.Printf("[DEBUG] [settings] loaded: storage_paths=%d integrations=%d pending_events=%d",
			len(s.StoragePaths), len(s.Integrations), len(s.PendingEvents))
	}
	s.migrateResticToRsync()
	return s, nil
}

// migrateResticToRsync converts any cross-drive backup configs using restic to rsync.
// Called once during Load() before the mutex is exposed.
func (s *Settings) migrateResticToRsync() {
	changed := false
	for name, prefs := range s.AppBackup {
		if prefs.CrossDrive != nil && prefs.CrossDrive.Method == "restic" {
			prefs.CrossDrive.Method = "rsync"
			s.AppBackup[name] = prefs
			if s.log != nil {
				s.log.Printf("[INFO] [settings] Migrated cross-drive backup for %s from restic to rsync", name)
			}
			changed = true
		}
	}
	if changed {
		if err := s.save(); err != nil && s.log != nil {
			s.log.Printf("[ERROR] [settings] Failed to save restic→rsync migration: %v", err)
		}
	}
}

// Save writes settings to disk atomically (write to .tmp, rename).
// Caller must hold the write lock or call this from a method that does.
func (s *Settings) save() error {
	data, err := json.MarshalIndent(s, "", "  ")
	if err != nil {
		if s.log != nil {
			s.log.Printf("[ERROR] [settings] Failed to save: %v", err)
		}
		return fmt.Errorf("marshaling settings: %w", err)
	}

	tmpPath := s.path + ".tmp"
	if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil {
		if s.log != nil {
			s.log.Printf("[ERROR] [settings] Failed to save: %v", err)
		}
		return fmt.Errorf("creating settings dir: %w", err)
	}

	if err := os.WriteFile(tmpPath, data, 0644); err != nil {
		os.Remove(tmpPath) // clean up partial file
		if s.log != nil {
			s.log.Printf("[ERROR] [settings] Failed to save: %v", err)
		}
		return fmt.Errorf("writing tmp settings: %w", err)
	}

	if err := os.Rename(tmpPath, s.path); err != nil {
		os.Remove(tmpPath)
		if s.log != nil {
			s.log.Printf("[ERROR] [settings] Failed to save: %v", err)
		}
		return fmt.Errorf("renaming settings file: %w", err)
	}

	// last-known-good: written AFTER the primary rename succeeds, so .bak only ever holds settings that
	// parsed + saved cleanly. Best-effort — a failed .bak must NOT fail the save.
	if err := os.WriteFile(s.path+".bak", data, 0644); err != nil && s.log != nil {
		s.log.Printf("[WARN] [settings] could not write .bak: %v", err)
	}

	if s.debug {
		s.log.Printf("[DEBUG] [settings] saved to %s (%d bytes)", s.path, len(data))
	}
	if s.log != nil {
		s.log.Printf("[INFO] [settings] Settings saved")
	}
	return nil
}

// GetPasswordHash returns the stored password hash (thread-safe).
func (s *Settings) GetPasswordHash() string {
	s.mu.RLock()
	defer s.mu.RUnlock()
	return s.PasswordHash
}

// SetPasswordHash updates the password hash and saves to disk.
func (s *Settings) SetPasswordHash(hash string) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.PasswordHash = hash
	return s.save()
}

// GetDBValidations returns a copy of the cached DB validations.
func (s *Settings) GetDBValidations() map[string]DBValidationCache {
	s.mu.RLock()
	defer s.mu.RUnlock()
	if s.DBValidations == nil {
		return nil
	}
	result := make(map[string]DBValidationCache, len(s.DBValidations))
	for k, v := range s.DBValidations {
		result[k] = v
	}
	return result
}

// SetDBValidation saves a validation result for a dump file and persists to disk.
func (s *Settings) SetDBValidation(filename string, cache DBValidationCache) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.DBValidations == nil {
		s.DBValidations = make(map[string]DBValidationCache)
	}
	s.DBValidations[filename] = cache
	return s.save()
}

// GetNotificationPrefs returns a copy of the notification preferences.
func (s *Settings) GetNotificationPrefs() *NotificationPrefs {
	s.mu.RLock()
	defer s.mu.RUnlock()
	if s.Notifications == nil {
		events := make([]string, len(DefaultEnabledEvents))
		copy(events, DefaultEnabledEvents)
		return &NotificationPrefs{
			EnabledEvents: events,
			CooldownHours: 6,
		}
	}
	prefs := *s.Notifications
	if prefs.CooldownHours == 0 {
		prefs.CooldownHours = 6
	}
	if prefs.EnabledEvents == nil {
		prefs.EnabledEvents = DefaultEnabledEvents
	}
	// Return a copy of the slice
	events := make([]string, len(prefs.EnabledEvents))
	copy(events, prefs.EnabledEvents)
	prefs.EnabledEvents = events
	return &prefs
}

// SetNotificationPrefs updates notification preferences and saves to disk.
// H17: Deep-copies prefs so caller mutations after the call don't affect stored state.
func (s *Settings) SetNotificationPrefs(prefs *NotificationPrefs) error {
	if prefs == nil {
		return fmt.Errorf("notification preferences cannot be nil")
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	cp := *prefs
	if len(prefs.EnabledEvents) > 0 {
		cp.EnabledEvents = make([]string, len(prefs.EnabledEvents))
		for i, e := range prefs.EnabledEvents {
			cp.EnabledEvents[i] = e
		}
	}
	s.Notifications = &cp
	return s.save()
}

// GetOffboxTarget returns a copy of the off-box target config (nil if unconfigured).
func (s *Settings) GetOffboxTarget() *OffboxTarget {
	s.mu.RLock()
	defer s.mu.RUnlock()
	if s.Offbox == nil {
		return nil
	}
	cp := *s.Offbox
	return &cp
}

// SetOffboxTarget saves (or clears, on nil) the off-box target config.
func (s *Settings) SetOffboxTarget(t *OffboxTarget) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.Offbox = t
	return s.save()
}

// UpdateOffboxStatus mutates the off-box target's runtime status in-place (no-op if unconfigured).
func (s *Settings) UpdateOffboxStatus(fn func(*OffboxTarget)) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.Offbox == nil {
		return nil
	}
	fn(s.Offbox)
	return s.save()
}

// IsAppOffbox reports whether a stack is toggled for off-box backup.
func (s *Settings) IsAppOffbox(stackName string) bool {
	s.mu.RLock()
	defer s.mu.RUnlock()
	if s.AppBackup == nil {
		return false
	}
	return s.AppBackup[stackName].Offbox
}

// SetAppOffbox toggles a stack's off-box backup inclusion.
func (s *Settings) SetAppOffbox(stackName string, on bool) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.AppBackup == nil {
		s.AppBackup = make(map[string]AppBackupPrefs)
	}
	existing := s.AppBackup[stackName]
	existing.Offbox = on
	s.AppBackup[stackName] = existing
	return s.save()
}

// GetOffboxApps returns the stack names toggled for off-box backup.
func (s *Settings) GetOffboxApps() []string {
	s.mu.RLock()
	defer s.mu.RUnlock()
	var out []string
	for name, p := range s.AppBackup {
		if p.Offbox {
			out = append(out, name)
		}
	}
	return out
}

// GetCrossDriveConfig returns the cross-drive backup config for a stack (nil if not set).
func (s *Settings) GetCrossDriveConfig(stackName string) *CrossDriveBackup {
	s.mu.RLock()
	defer s.mu.RUnlock()
	if s.AppBackup == nil {
		return nil
	}
	prefs, ok := s.AppBackup[stackName]
	if !ok || prefs.CrossDrive == nil {
		return nil
	}
	cp := *prefs.CrossDrive
	return &cp
}

// SetCrossDriveConfig saves (or clears) the cross-drive backup config for a stack.
func (s *Settings) SetCrossDriveConfig(stackName string, cfg *CrossDriveBackup) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.AppBackup == nil {
		s.AppBackup = make(map[string]AppBackupPrefs)
	}
	existing := s.AppBackup[stackName]
	existing.CrossDrive = cfg
	s.AppBackup[stackName] = existing
	return s.save()
}

// UpdateCrossDriveStatus updates runtime status fields for a cross-drive backup in-place.
// fn receives a pointer to the CrossDriveBackup and may mutate it.
// If no cross-drive config exists for the stack, does nothing and returns nil.
func (s *Settings) UpdateCrossDriveStatus(stackName string, fn func(*CrossDriveBackup)) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.AppBackup == nil {
		s.AppBackup = make(map[string]AppBackupPrefs)
	}
	existing := s.AppBackup[stackName]
	if existing.CrossDrive == nil {
		return nil // don't create config from thin air — just skip status update
	}
	fn(existing.CrossDrive)
	s.AppBackup[stackName] = existing
	return s.save()
}

// SetTier2Preference records the customer's Tier-2 choice (from the per-app config panel) WITHOUT
// disturbing the runner's status fields: it merges into the existing config if one is present, else
// seeds a minimal config carrying just the preference. The Tier-2 runner reads UserDisabled (skip)
// and PreferredTarget (pin a destination) and preserves both on every status write.
func (s *Settings) SetTier2Preference(stackName string, disabled bool, preferredTarget string) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.AppBackup == nil {
		s.AppBackup = make(map[string]AppBackupPrefs)
	}
	existing := s.AppBackup[stackName]
	if existing.CrossDrive == nil {
		existing.CrossDrive = &CrossDriveBackup{Method: "rsync", Schedule: "daily"}
	}
	existing.CrossDrive.UserDisabled = disabled
	existing.CrossDrive.PreferredTarget = preferredTarget
	s.AppBackup[stackName] = existing
	return s.save()
}

// GetAllCrossDriveConfigs returns all apps with a cross-drive config (enabled or not).
func (s *Settings) GetAllCrossDriveConfigs() map[string]*CrossDriveBackup {
	s.mu.RLock()
	defer s.mu.RUnlock()
	result := make(map[string]*CrossDriveBackup)
	for name, prefs := range s.AppBackup {
		if prefs.CrossDrive != nil {
			cp := *prefs.CrossDrive
			result[name] = &cp
		}
	}
	return result
}

// NOTE: GetCrossDriveResticPassword, SetCrossDriveResticPassword, and
// GetOrCreateCrossDrivePassword were removed in the Tier 2 restic deprecation.
// The CrossDriveResticPassword field is kept in the struct for backward-compat
// JSON loading but is no longer used.

// --- Storage Paths ---

// GetStoragePaths returns a copy of all registered storage paths.
func (s *Settings) GetStoragePaths() []StoragePath {
	s.mu.RLock()
	defer s.mu.RUnlock()
	if len(s.StoragePaths) == 0 {
		return nil
	}
	result := make([]StoragePath, len(s.StoragePaths))
	copy(result, s.StoragePaths)
	return result
}

// GetDefaultStoragePath returns the default storage path string, or "".
func (s *Settings) GetDefaultStoragePath() string {
	s.mu.RLock()
	defer s.mu.RUnlock()
	for _, sp := range s.StoragePaths {
		if sp.IsDefault {
			return sp.Path
		}
	}
	return ""
}

// GetStorageLabel returns the label for a storage path, or the base name if not found.
func (s *Settings) GetStorageLabel(path string) string {
	s.mu.RLock()
	defer s.mu.RUnlock()
	for _, sp := range s.StoragePaths {
		if sp.Path == path && sp.Label != "" {
			return sp.Label
		}
	}
	return filepath.Base(path)
}

// GetSchedulableStoragePaths returns paths available for new deployments.
func (s *Settings) GetSchedulableStoragePaths() []StoragePath {
	s.mu.RLock()
	defer s.mu.RUnlock()
	var result []StoragePath
	for _, sp := range s.StoragePaths {
		if sp.Schedulable && !sp.Decommissioned {
			result = append(result, sp)
		}
	}
	return result
}

// AddStoragePath registers a new storage path. Validation is done by caller.
func (s *Settings) AddStoragePath(sp StoragePath) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.debug {
		s.log.Printf("[DEBUG] [settings] AddStoragePath path=%q label=%q default=%v", sp.Path, sp.Label, sp.IsDefault)
	}
	for _, existing := range s.StoragePaths {
		if existing.Path == sp.Path {
			return fmt.Errorf("storage path %q already registered", sp.Path)
		}
	}
	if sp.IsDefault {
		for i := range s.StoragePaths {
			s.StoragePaths[i].IsDefault = false
		}
	}
	s.StoragePaths = append(s.StoragePaths, sp)
	if s.log != nil {
		s.log.Printf("[INFO] [settings] Added storage path: %s", sp.Path)
	}
	return s.save()
}

// RemoveStoragePath removes a path by its path string.
func (s *Settings) RemoveStoragePath(path string) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.debug {
		s.log.Printf("[DEBUG] [settings] RemoveStoragePath path=%q", path)
	}
	var kept []StoragePath
	for _, sp := range s.StoragePaths {
		if sp.Path != path {
			kept = append(kept, sp)
		}
	}
	s.StoragePaths = kept
	if s.log != nil {
		s.log.Printf("[INFO] [settings] Removed storage path: %s", path)
	}
	return s.save()
}

// RepointStoragePath changes a registered path's Path string in place (intermediary-mount migration:
// /mnt/<name> → /mnt/felhom-drives/<name>), preserving all other fields (label, default, schedulable,
// disconnect/decommission state). No-op (nil) if oldPath isn't registered or already equals newPath.
// Errors if newPath collides with a different existing entry.
func (s *Settings) RepointStoragePath(oldPath, newPath string) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	if oldPath == newPath {
		return nil
	}
	idx := -1
	for i := range s.StoragePaths {
		if s.StoragePaths[i].Path == newPath {
			return fmt.Errorf("repoint target %q already registered", newPath)
		}
		if s.StoragePaths[i].Path == oldPath {
			idx = i
		}
	}
	if idx < 0 {
		return nil // nothing to repoint
	}
	s.StoragePaths[idx].Path = newPath
	if s.log != nil {
		s.log.Printf("[INFO] [settings] Repointed storage path: %s → %s", oldPath, newPath)
	}
	return s.save()
}

// SetDefaultStoragePath changes which path is the default.
func (s *Settings) SetDefaultStoragePath(path string) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	found := false
	for i := range s.StoragePaths {
		if s.StoragePaths[i].Path == path {
			s.StoragePaths[i].IsDefault = true
			found = true
		} else {
			s.StoragePaths[i].IsDefault = false
		}
	}
	if !found {
		return fmt.Errorf("storage path %q not found", path)
	}
	return s.save()
}

// SetSchedulable enables/disables a path for new deployments.
func (s *Settings) SetSchedulable(path string, schedulable bool) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	for i := range s.StoragePaths {
		if s.StoragePaths[i].Path == path {
			s.StoragePaths[i].Schedulable = schedulable
			return s.save()
		}
	}
	return fmt.Errorf("storage path %q not found", path)
}

// SetStorageLabel updates the label for a storage path.
func (s *Settings) SetStorageLabel(path, label string) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	for i := range s.StoragePaths {
		if s.StoragePaths[i].Path == path {
			s.StoragePaths[i].Label = label
			return s.save()
		}
	}
	return fmt.Errorf("storage path %q not found", path)
}

// AutoDiscoverStoragePaths scans for HDD_PATH values and registers any that are not
// already in the registry. It is ADDITIVE: pre-existing entries are never removed,
// modified, or reactivated.
//   - discoveredPaths are pre-scanned HDD_PATH values from deployed apps' app.yaml.
//   - fallbackHDDPath is the legacy controller.yaml paths.hdd_path (may be empty).
//
// Invariants:
//   - A path already present in the registry IN ANY STATE (including a Decommissioned
//     soft-marked entry) is SKIPPED — never re-added and never re-activated.
//   - A manually-added path is never removed or modified.
//   - IsDefault is never flipped on an existing entry. A newly-discovered path becomes
//     default ONLY if the registry currently has no default at all (and then only the
//     first such new path).
func (s *Settings) AutoDiscoverStoragePaths(discoveredPaths []string, fallbackHDDPath string, logger *log.Logger) {
	s.mu.Lock()
	defer s.mu.Unlock()

	if s.debug {
		s.log.Printf("[DEBUG] [settings] AutoDiscoverStoragePaths discovered=%v fallback=%q existing=%d", discoveredPaths, fallbackHDDPath, len(s.StoragePaths))
	}

	// Index existing paths (in ANY state) and whether a default already exists.
	existing := make(map[string]bool, len(s.StoragePaths))
	hasDefault := false
	for i := range s.StoragePaths {
		existing[filepath.Clean(s.StoragePaths[i].Path)] = true
		if s.StoragePaths[i].IsDefault {
			hasDefault = true
		}
	}

	// Build the de-duplicated, cleaned candidate list (discovered first, then fallback).
	seen := make(map[string]bool)
	var ordered []string
	for _, p := range discoveredPaths {
		cleaned := filepath.Clean(p)
		if cleaned != "" && cleaned != "." && !seen[cleaned] {
			seen[cleaned] = true
			ordered = append(ordered, cleaned)
		}
	}
	if fallbackHDDPath != "" {
		cleaned := filepath.Clean(fallbackHDDPath)
		if cleaned != "" && cleaned != "." && !seen[cleaned] {
			seen[cleaned] = true
			ordered = append(ordered, cleaned)
		}
	}

	added := 0
	for _, path := range ordered {
		if existing[path] {
			continue // already registered in some state — never re-add or reactivate
		}
		sp := StoragePath{
			Path:        path,
			Label:       InferStorageLabel(path),
			IsDefault:   !hasDefault, // first newly-added path defaults only if none exists yet
			Schedulable: true,
			AddedAt:     time.Now().UTC().Format(time.RFC3339),
		}
		if sp.IsDefault {
			hasDefault = true // don't promote a second new path
		}
		s.StoragePaths = append(s.StoragePaths, sp)
		existing[path] = true
		added++
	}

	if added == 0 {
		return // nothing new to register
	}

	if err := s.save(); err != nil {
		logger.Printf("[ERROR] [settings] Failed to save auto-discovered storage paths: %v", err)
		return
	}
	logger.Printf("[INFO] [settings] Auto-discovered %d new storage path(s)", added)
	for _, sp := range s.StoragePaths {
		logger.Printf("[INFO] [settings]   %s (%s) default=%v decommissioned=%v", sp.Path, sp.Label, sp.IsDefault, sp.Decommissioned)
	}
}

// InferStorageLabel generates a human-readable label for a storage path.
func InferStorageLabel(path string) string {
	base := filepath.Base(path)
	// The internal system volume's data path ends in the felhom-data namespace dir
	// (e.g. /mnt/sys_drive/felhom-data) — Model-A user drives register their MOUNT ROOT
	// (e.g. /mnt/felhom-usb), never .../felhom-data, so this can't mislabel a user drive.
	if base == appbackup.FelhomDataDir {
		return "Belső SSD (rendszer)"
	}
	if strings.HasPrefix(base, "hdd") || strings.HasPrefix(base, "ssd") || strings.HasPrefix(base, "usb") {
		return fmt.Sprintf("Külső tárhely (%s)", base)
	}
	return fmt.Sprintf("Tárhely (%s)", base)
}

// SetDisconnected marks a storage path as disconnected (or connected) and records which stacks were stopped.
func (s *Settings) SetDisconnected(path string, disconnected bool, stoppedStacks []string) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.debug {
		s.log.Printf("[DEBUG] [settings] SetDisconnected path=%q disconnected=%v stopped_stacks=%d", path, disconnected, len(stoppedStacks))
	}
	if s.log != nil {
		s.log.Printf("[INFO] [settings] Storage path %s disconnected=%v", path, disconnected)
	}
	for i := range s.StoragePaths {
		if s.StoragePaths[i].Path == path {
			s.StoragePaths[i].Disconnected = disconnected
			if disconnected {
				s.StoragePaths[i].DisconnectedAt = time.Now().UTC().Format(time.RFC3339)
				s.StoragePaths[i].StoppedStacks = stoppedStacks
			} else {
				s.StoragePaths[i].DisconnectedAt = ""
				// Preserve StoppedStacks on reconnect so the UI can offer restart
				if stoppedStacks != nil {
					s.StoragePaths[i].StoppedStacks = stoppedStacks
				}
			}
			return s.save()
		}
	}
	return fmt.Errorf("storage path %q not found", path)
}

// ClearDisconnected marks a path as connected and clears all disconnect-related fields.
func (s *Settings) ClearDisconnected(path string) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	for i := range s.StoragePaths {
		if s.StoragePaths[i].Path == path {
			s.StoragePaths[i].Disconnected = false
			s.StoragePaths[i].DisconnectedAt = ""
			s.StoragePaths[i].StoppedStacks = nil
			return s.save()
		}
	}
	return fmt.Errorf("storage path %q not found", path)
}

// IsDisconnected returns whether a storage path is marked as disconnected.
func (s *Settings) IsDisconnected(path string) bool {
	s.mu.RLock()
	defer s.mu.RUnlock()
	for _, sp := range s.StoragePaths {
		if sp.Path == path {
			return sp.Disconnected
		}
	}
	return false
}

// GetDisconnectedPaths returns a copy of all storage paths that are marked disconnected.
func (s *Settings) GetDisconnectedPaths() []StoragePath {
	s.mu.RLock()
	defer s.mu.RUnlock()
	var result []StoragePath
	for _, sp := range s.StoragePaths {
		if sp.Disconnected {
			result = append(result, sp)
		}
	}
	return result
}

// GetConnectedPaths returns a copy of all storage paths that are NOT disconnected and NOT decommissioned.
func (s *Settings) GetConnectedPaths() []StoragePath {
	s.mu.RLock()
	defer s.mu.RUnlock()
	var result []StoragePath
	for _, sp := range s.StoragePaths {
		if !sp.Disconnected && !sp.Decommissioned {
			result = append(result, sp)
		}
	}
	return result
}

// IsStoragePathKnown returns whether a path belongs to any registered storage path
// (connected, disconnected, or decommissioned). A path removed entirely from
// storage_paths is NOT known.
func (s *Settings) IsStoragePathKnown(path string) bool {
	s.mu.RLock()
	defer s.mu.RUnlock()
	for _, sp := range s.StoragePaths {
		if path == sp.Path || strings.HasPrefix(path, sp.Path+"/") {
			return true
		}
	}
	return false
}

// IsNetworkStoragePath reports whether `path` belongs to a registered NAS network-storage path
// (Kind=="network"). The drive lifecycle (eject/decommission/migrate/wipe) must refuse such a path —
// a NAS has no device lifecycle. Matches the exact path or a child under it.
func (s *Settings) IsNetworkStoragePath(path string) bool {
	s.mu.RLock()
	defer s.mu.RUnlock()
	for _, sp := range s.StoragePaths {
		if path == sp.Path || strings.HasPrefix(path, sp.Path+"/") {
			return sp.IsNetwork()
		}
	}
	return false
}

// IsStoragePathSchedulable returns whether a path belongs to a registered,
// schedulable (active) storage path. Returns false if the path is unknown,
// disconnected, decommissioned, or inactive.
func (s *Settings) IsStoragePathSchedulable(path string) bool {
	s.mu.RLock()
	defer s.mu.RUnlock()
	for _, sp := range s.StoragePaths {
		if path == sp.Path || strings.HasPrefix(path, sp.Path+"/") {
			return sp.Schedulable && !sp.Disconnected && !sp.Decommissioned
		}
	}
	return false
}

// GetStoppedStacks returns the list of stacks that were auto-stopped for a storage path.
func (s *Settings) GetStoppedStacks(path string) []string {
	s.mu.RLock()
	defer s.mu.RUnlock()
	for _, sp := range s.StoragePaths {
		if sp.Path == path {
			if len(sp.StoppedStacks) == 0 {
				return nil
			}
			result := make([]string, len(sp.StoppedStacks))
			copy(result, sp.StoppedStacks)
			return result
		}
	}
	return nil
}

// ClearStoppedStacks removes the stopped stacks list for a storage path (e.g., after restart).
func (s *Settings) ClearStoppedStacks(path string) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	for i := range s.StoragePaths {
		if s.StoragePaths[i].Path == path {
			s.StoragePaths[i].StoppedStacks = nil
			return s.save()
		}
	}
	return fmt.Errorf("storage path %q not found", path)
}

// SetDecommissioned marks a storage path as decommissioned with migration target.
// Clears IsDefault and Schedulable.
func (s *Settings) SetDecommissioned(path, migratedTo string) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.debug {
		s.log.Printf("[DEBUG] [settings] SetDecommissioned path=%q migrated_to=%q", path, migratedTo)
	}
	if s.log != nil {
		s.log.Printf("[INFO] [settings] Storage path %s decommissioned (migrated_to=%s)", path, migratedTo)
	}
	for i := range s.StoragePaths {
		if s.StoragePaths[i].Path == path {
			s.StoragePaths[i].Decommissioned = true
			s.StoragePaths[i].DecommissionedAt = time.Now().UTC().Format(time.RFC3339)
			s.StoragePaths[i].MigratedTo = migratedTo
			s.StoragePaths[i].IsDefault = false
			s.StoragePaths[i].Schedulable = false
			return s.save()
		}
	}
	return fmt.Errorf("storage path %q not found", path)
}

// ClearDecommissioned removes the decommissioned state from a storage path.
func (s *Settings) ClearDecommissioned(path string) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	for i := range s.StoragePaths {
		if s.StoragePaths[i].Path == path {
			s.StoragePaths[i].Decommissioned = false
			s.StoragePaths[i].DecommissionedAt = ""
			s.StoragePaths[i].MigratedTo = ""
			return s.save()
		}
	}
	return fmt.Errorf("storage path %q not found", path)
}

// IsDecommissioned returns whether a storage path is marked as decommissioned.
func (s *Settings) IsDecommissioned(path string) bool {
	s.mu.RLock()
	defer s.mu.RUnlock()
	for _, sp := range s.StoragePaths {
		if sp.Path == path {
			return sp.Decommissioned
		}
	}
	return false
}

// GetDecommissionedPaths returns a copy of all decommissioned storage paths.
func (s *Settings) GetDecommissionedPaths() []StoragePath {
	s.mu.RLock()
	defer s.mu.RUnlock()
	var result []StoragePath
	for _, sp := range s.StoragePaths {
		if sp.Decommissioned {
			result = append(result, sp)
		}
	}
	return result
}

// --- Hub Verification ---

// GetHubVerified returns the hub verification state.
func (s *Settings) GetHubVerified() (verified bool, verifiedAt string) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	return s.HubVerified, s.HubVerifiedAt
}

// SetHubVerified updates the hub verification state and saves to disk.
func (s *Settings) SetHubVerified(verified bool, at time.Time) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.HubVerified = verified
	s.HubVerifiedAt = at.UTC().Format(time.RFC3339)
	s.HubLastCheck = at.UTC().Format(time.RFC3339)
	return s.save()
}

// GetAppliedConfigVersion returns the last-applied hub config_version (0 = none recorded yet).
func (s *Settings) GetAppliedConfigVersion() int {
	s.mu.RLock()
	defer s.mu.RUnlock()
	return s.AppliedConfigVersion
}

// SetAppliedConfigVersion persists the config_version this controller has pulled + applied. Recorded
// BEFORE a config-refresh self-restart so the restarted process sees it applied and does not loop.
func (s *Settings) SetAppliedConfigVersion(v int) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.AppliedConfigVersion = v
	return s.save()
}

// SetHubLastCheck updates the last Hub check timestamp without changing verification status.
// GetLastGuestBootID returns the persisted last-seen guest boot-id ("" if never recorded).
func (s *Settings) GetLastGuestBootID() string {
	s.mu.RLock()
	defer s.mu.RUnlock()
	return s.LastGuestBootID
}

// SetLastGuestBootID persists the current guest boot-id (after a deterministic boot-recreate pass).
func (s *Settings) SetLastGuestBootID(id string) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.LastGuestBootID = id
	return s.save()
}

func (s *Settings) SetHubLastCheck(at time.Time) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.HubLastCheck = at.UTC().Format(time.RFC3339)
	return s.save()
}

// IsLimitedMode returns true if the controller should operate in limited mode
// (new deployments blocked). This happens when:
// - Never verified AND >7 days since controller started, OR
// - Hub explicitly set customer as blocked (HubVerified=false after a successful check)
func (s *Settings) IsLimitedMode() bool {
	s.mu.RLock()
	defer s.mu.RUnlock()

	if s.HubVerified {
		return false
	}

	// If we have a last check timestamp and it says not verified, limited mode
	if s.HubLastCheck != "" {
		return true
	}

	// Never checked yet — check if grace period (7 days) expired
	if s.HubVerifiedAt == "" {
		// No verification timestamp at all — not yet in limited mode (grace period from startup)
		return false
	}
	t, err := time.Parse(time.RFC3339, s.HubVerifiedAt)
	if err != nil {
		return false
	}
	return time.Since(t) > 7*24*time.Hour
}

// --- Retrieval Password ---

// GetRetrievalPassword returns the stored retrieval password (thread-safe).
func (s *Settings) GetRetrievalPassword() string {
	s.mu.RLock()
	defer s.mu.RUnlock()
	return s.RetrievalPassword
}

// SetRetrievalPassword updates the retrieval password and saves to disk.
func (s *Settings) SetRetrievalPassword(password string) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.RetrievalPassword = password
	return s.save()
}

// --- Pending Events ---

// AddPendingEvent queues an event for the next Hub push cycle.
func (s *Settings) AddPendingEvent(event PendingEvent) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.debug {
		s.log.Printf("[DEBUG] [settings] AddPendingEvent type=%q severity=%q", event.EventType, event.Severity)
	}
	if s.log != nil {
		s.log.Printf("[INFO] [settings] Added pending event: %s", event.EventType)
	}
	s.PendingEvents = append(s.PendingEvents, event)
	return s.save()
}

// DrainPendingEvents returns and clears all pending events (thread-safe).
func (s *Settings) DrainPendingEvents() []PendingEvent {
	s.mu.Lock()
	defer s.mu.Unlock()
	if len(s.PendingEvents) == 0 {
		return nil
	}
	if s.debug {
		s.log.Printf("[DEBUG] [settings] DrainPendingEvents count=%d", len(s.PendingEvents))
	}
	events := make([]PendingEvent, len(s.PendingEvents))
	copy(events, s.PendingEvents)
	s.PendingEvents = nil
	if err := s.save(); err != nil {
		s.log.Printf("[ERROR] [settings] Failed to save after draining pending events: %v — restoring events", err)
		s.PendingEvents = events
		return nil
	}
	return events
}

// --- Geo-Restriction ---

// GetGeoRestriction returns a deep copy of the geo-restriction settings.
func (s *Settings) GetGeoRestriction() *GeoRestriction {
	s.mu.RLock()
	defer s.mu.RUnlock()
	if s.GeoRestriction == nil {
		return nil
	}
	geo := *s.GeoRestriction
	if len(s.GeoRestriction.AllowedCountries) > 0 {
		geo.AllowedCountries = make([]string, len(s.GeoRestriction.AllowedCountries))
		copy(geo.AllowedCountries, s.GeoRestriction.AllowedCountries)
	}
	if len(s.GeoRestriction.AppOverrides) > 0 {
		geo.AppOverrides = make(map[string]AppGeoOverride, len(s.GeoRestriction.AppOverrides))
		for k, v := range s.GeoRestriction.AppOverrides {
			ov := AppGeoOverride{AllowedCountries: make([]string, len(v.AllowedCountries))}
			copy(ov.AllowedCountries, v.AllowedCountries)
			geo.AppOverrides[k] = ov
		}
	}
	return &geo
}

// SetGeoRestriction replaces the entire geo-restriction config and saves to disk.
func (s *Settings) SetGeoRestriction(geo *GeoRestriction) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.debug {
		if geo == nil {
			s.log.Printf("[DEBUG] [settings] SetGeoRestriction geo=nil (clearing)")
		} else {
			s.log.Printf("[DEBUG] [settings] SetGeoRestriction enabled=%v countries=%d", geo.Enabled, len(geo.AllowedCountries))
		}
	}
	if geo == nil {
		s.GeoRestriction = nil
		return s.save()
	}
	cp := *geo
	if len(geo.AllowedCountries) > 0 {
		cp.AllowedCountries = make([]string, len(geo.AllowedCountries))
		copy(cp.AllowedCountries, geo.AllowedCountries)
	}
	if len(geo.AppOverrides) > 0 {
		cp.AppOverrides = make(map[string]AppGeoOverride, len(geo.AppOverrides))
		for k, v := range geo.AppOverrides {
			ov := AppGeoOverride{AllowedCountries: make([]string, len(v.AllowedCountries))}
			copy(ov.AllowedCountries, v.AllowedCountries)
			cp.AppOverrides[k] = ov
		}
	}
	s.GeoRestriction = &cp
	return s.save()
}

// SetGeoAppOverride sets a per-app geo override. Creates the GeoRestriction if nil.
// Pass override=nil to remove the override (same as RemoveGeoAppOverride).
func (s *Settings) SetGeoAppOverride(appName string, override *AppGeoOverride) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	if override == nil {
		// nil override = remove (fall back to global)
		if s.GeoRestriction != nil && s.GeoRestriction.AppOverrides != nil {
			delete(s.GeoRestriction.AppOverrides, appName)
		}
		return s.save()
	}
	if s.GeoRestriction == nil {
		s.GeoRestriction = &GeoRestriction{AllowedCountries: []string{"HU"}}
	}
	if s.GeoRestriction.AppOverrides == nil {
		s.GeoRestriction.AppOverrides = make(map[string]AppGeoOverride)
	}
	ov := AppGeoOverride{AllowedCountries: make([]string, len(override.AllowedCountries))}
	copy(ov.AllowedCountries, override.AllowedCountries)
	s.GeoRestriction.AppOverrides[appName] = ov
	return s.save()
}

// RemoveGeoAppOverride removes a per-app override (app falls back to global).
func (s *Settings) RemoveGeoAppOverride(appName string) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.GeoRestriction == nil || s.GeoRestriction.AppOverrides == nil {
		return nil
	}
	delete(s.GeoRestriction.AppOverrides, appName)
	return s.save()
}

// SetGeoSyncState updates the geo sync status fields.
func (s *Settings) SetGeoSyncState(zoneID, rulesetID, syncError string) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.GeoRestriction == nil {
		return nil
	}
	s.GeoRestriction.LastSync = time.Now().UTC().Format(time.RFC3339)
	s.GeoRestriction.LastSyncError = syncError
	if zoneID != "" {
		s.GeoRestriction.ZoneID = zoneID
	}
	if rulesetID != "" {
		s.GeoRestriction.RulesetID = rulesetID
	}
	return s.save()
}

// --- App email (SMTP relay) ---

// GetAppEmail returns the global app-email toggle (a copy; never the live pointer).
func (s *Settings) GetAppEmail() AppEmail {
	s.mu.RLock()
	defer s.mu.RUnlock()
	if s.AppEmail == nil {
		return AppEmail{}
	}
	return *s.AppEmail
}

// AppEmailEnabled reports whether app-email is globally on.
func (s *Settings) AppEmailEnabled() bool {
	s.mu.RLock()
	defer s.mu.RUnlock()
	return s.AppEmail != nil && s.AppEmail.Enabled
}

// SetAppEmail updates the global app-email toggle and persists it.
func (s *Settings) SetAppEmail(enabled bool, fromName string) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.debug {
		s.log.Printf("[DEBUG] [settings] SetAppEmail enabled=%v from_name=%q", enabled, fromName)
	}
	s.AppEmail = &AppEmail{Enabled: enabled, FromName: strings.TrimSpace(fromName)}
	return s.save()
}

// --- App-to-app integrations ---

// GetIntegrationState returns the state for a specific integration key (e.g., "onlyoffice:filebrowser").
func (s *Settings) GetIntegrationState(key string) (IntegrationState, bool) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	if s.Integrations == nil {
		return IntegrationState{}, false
	}
	state, ok := s.Integrations[key]
	return state, ok
}

// SetIntegrationState updates (or creates) the state for a single integration key.
func (s *Settings) SetIntegrationState(key string, state IntegrationState) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.debug {
		s.log.Printf("[DEBUG] [settings] SetIntegrationState key=%q status=%q enabled=%v", key, state.Status, state.Enabled)
	}
	if s.Integrations == nil {
		s.Integrations = make(map[string]IntegrationState)
	}
	s.Integrations[key] = state
	return s.save()
}

// RemoveIntegrationState removes an integration key entirely.
func (s *Settings) RemoveIntegrationState(key string) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.Integrations != nil {
		delete(s.Integrations, key)
	}
	return s.save()
}

// GetIntegrationsForProvider returns all integration states where key starts with "provider:".
func (s *Settings) GetIntegrationsForProvider(provider string) map[string]IntegrationState {
	s.mu.RLock()
	defer s.mu.RUnlock()
	prefix := provider + ":"
	result := make(map[string]IntegrationState)
	for k, v := range s.Integrations {
		if strings.HasPrefix(k, prefix) {
			result[k] = v
		}
	}
	return result
}

// GetIntegrationsForTarget returns all integration states where key ends with ":target".
func (s *Settings) GetIntegrationsForTarget(target string) map[string]IntegrationState {
	s.mu.RLock()
	defer s.mu.RUnlock()
	suffix := ":" + target
	result := make(map[string]IntegrationState)
	for k, v := range s.Integrations {
		if strings.HasSuffix(k, suffix) {
			result[k] = v
		}
	}
	return result
}
