package web

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"net/url"
	"strconv"
	"strings"
	"time"

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

// Off-box (NAS) restic-SFTP backup handlers (Part B). Form POSTs that redirect to /backups with a flash.
// The SSH private key + known-host line are provided out-of-band by the operator (textareas) and written
// to 0600/0644 files by the backup Manager; they are NEVER echoed back, logged, or stored in settings.

// offboxRedirect sends the customer back to the Távoli mentés page with a flash (success or
// error) message (v0.124.0 IA split: the offbox controls live on /backups/remote; the
// restore-to-verify flow redirects to /backups/restore via offboxRedirectTo).
func offboxRedirect(w http.ResponseWriter, r *http.Request, msg string, isErr bool) {
	offboxRedirectTo(w, r, "/backups/remote", msg, isErr)
}

func offboxRedirectTo(w http.ResponseWriter, r *http.Request, page, msg string, isErr bool) {
	q := "flash"
	if isErr {
		q = "flash_error"
	}
	// R-48: `page` may already carry a query (the wizard is /backups/restore/app?name=<app>), so the
	// separator has to be chosen, not hardcoded — appending a second "?" produces a URL whose flash
	// silently lands inside the `name` value instead of as its own parameter.
	sep := "?"
	if strings.Contains(page, "?") {
		sep = "&"
	}
	http.Redirect(w, r, page+sep+q+"="+url.QueryEscape(msg), http.StatusFound)
}

// offboxConfigHandler saves the off-box target + (out-of-band) SSH key + known_hosts.
func (s *Server) offboxConfigHandler(w http.ResponseWriter, r *http.Request) {
	if s.backupMgr == nil {
		offboxRedirect(w, r, "A mentéskezelő nem elérhető.", true)
		return
	}
	_ = r.ParseForm()
	host := strings.TrimSpace(r.FormValue("host"))
	user := strings.TrimSpace(r.FormValue("user"))
	repoPath := strings.TrimSpace(r.FormValue("repo_path"))
	port, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("port")))
	if port == 0 {
		port = 22
	}
	sshKey := r.FormValue("ssh_key")
	knownHosts := r.FormValue("known_hosts")

	if host == "" || user == "" || repoPath == "" {
		offboxRedirect(w, r, "A cél címe, a felhasználó és a tárhely útvonala kötelező.", true)
		return
	}
	if !strings.HasPrefix(repoPath, "/") {
		offboxRedirect(w, r, "A tárhely útvonalának abszolútnak kell lennie (/-rel kezdődjön).", true)
		return
	}
	// Validate BEFORE persisting — host/user/repo flow into the ssh command restic runs; reject anything
	// that could inject an ssh option (leading '-') or a metacharacter (the security boundary).
	if err := backup.ValidateOffboxTarget(&settings.OffboxTarget{Host: host, User: user, RepoPath: repoPath, Port: port}); err != nil {
		offboxRedirect(w, r, "Érvénytelen beállítás: "+err.Error(), true)
		return
	}
	// First-time config requires the SSH key + a pinned known-host line (no blind TOFU).
	existing := s.backupMgr.OffboxConfigured()
	if !existing && (strings.TrimSpace(sshKey) == "" || strings.TrimSpace(knownHosts) == "") {
		offboxRedirect(w, r, "Az első beállításhoz az SSH privát kulcs és a célgép ismert-host sora is kötelező.", true)
		return
	}

	// Write secrets out-of-band (0600 key/pw, 0644 known_hosts); never logged.
	if err := s.backupMgr.WriteOffboxSecrets(sshKey, knownHosts); err != nil {
		s.logger.Printf("[ERROR] [web] offbox secrets: %v", err)
		offboxRedirect(w, r, "A hitelesítő adatok mentése sikertelen.", true)
		return
	}

	prev := s.settings.GetOffboxTarget()
	tgt := &settings.OffboxTarget{
		Enabled: r.FormValue("enabled") == "on" || r.FormValue("enabled") == "true",
		Host:    host, Port: port, User: user, RepoPath: repoPath,
		Schedule: "daily",
	}
	if prev != nil { // preserve runtime status fields across an edit
		tgt.LastRun, tgt.LastStatus, tgt.LastError = prev.LastRun, prev.LastStatus, prev.LastError
		// R-100: LastSuccess is runtime status like the rest — an edit to the host/path/schedule must
		// not erase the staleness anchor. Losing it here would silently reset the tier to "never
		// succeeded" on a routine settings save. Pinned by TestOffboxEdit_PreservesLastSuccess.
		tgt.LastSuccess = prev.LastSuccess
		tgt.LastDuration, tgt.RepoSizeHuman, tgt.SnapshotCount = prev.LastDuration, prev.RepoSizeHuman, prev.SnapshotCount
		tgt.LastWarning = prev.LastWarning
		tgt.EscrowState = prev.EscrowState
		tgt.RepoSizeBytes = prev.RepoSizeBytes
		tgt.EnlargedBlocked = prev.EnlargedBlocked
	}
	// fork-4: enabling offsite stages the repo password to the agent for the R-escrow ceremony and marks
	// it PENDING — no offsite RUN proceeds until escrow is confirmed (atomicity). Re-editing an already
	// escrowed target keeps it escrowed (WriteOffboxSecrets leaves the password unchanged). A stage-push
	// failure does NOT mark escrowed; it is surfaced (the run gate still protects data).
	stageErr := ""
	if tgt.Enabled {
		if tgt.EscrowState != "escrowed" {
			tgt.EscrowState = "pending"
		}
		if client, cerr := s.agentClient(); cerr != nil {
			stageErr = " — a kulcs letéti előkészítése nem sikerült (az ügynök nem elérhető); próbáld újra."
			s.logger.Printf("[WARN] [web] offbox escrow stage: agent client: %v", cerr)
		} else if err := s.backupMgr.PushOffboxPasswordForEscrow(r.Context(), client.StageEscrowSecret); err != nil {
			stageErr = " — a kulcs letéti előkészítése nem sikerült; próbáld újra."
			s.logger.Printf("[WARN] [web] offbox escrow stage: %v", err) // err carries no secret
		}
	}
	if err := s.settings.SetOffboxTarget(tgt); err != nil {
		offboxRedirect(w, r, "A beállítás mentése sikertelen.", true)
		return
	}
	s.logger.Printf("[INFO] [web] off-box target configured: %s@%s:%s (port %d, enabled=%v, escrow=%s)", user, host, repoPath, port, tgt.Enabled, tgt.EscrowState)
	s.reportTriggerNow() // v0.139.0: offsite enable/disable reaches the hub in seconds
	offboxRedirect(w, r, "A távoli mentési cél elmentve."+stageErr, stageErr != "")
}

// offboxConfirmEscrowHandler marks the offsite repo password as escrowed under R (fork-4).
// DEPRECATED FALLBACK (SLICE 3): the PRIMARY path is the hub-verified auto-confirm
// (report.EscrowAutoConfirmer — flips on a hash match in the report ACK, no operator involved). This
// manual endpoint stays for LEGACY blobs recorded before the hash existed (e.g. the demo's) — they have
// no restic_pw_sha256 and can never auto-confirm; the operator vouches by hand after a verified ceremony.
func (s *Server) offboxConfirmEscrowHandler(w http.ResponseWriter, r *http.Request) {
	if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() {
		offboxRedirect(w, r, "A távoli mentési cél nincs beállítva.", true)
		return
	}
	if err := s.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
		o.EscrowState = "escrowed"
		o.CeremonyCompletedAt = "" // v0.138.0: clear the awaiting-card stamp on confirm
	}); err != nil {
		offboxRedirect(w, r, "A beállítás mentése sikertelen.", true)
		return
	}
	s.logger.Printf("[INFO] [web] off-box escrow confirmed — offsite runs enabled")
	// Fork-4 hygiene: the staged copy on the agent has served its purpose — wipe it. Best-effort: a wipe
	// failure is logged LOUDLY but does not fail the confirm (the state flip is the primary effect; a
	// lingering file is a hygiene gap, not a correctness one — re-confirm retries the wipe).
	if err := s.wipeStagedEscrow(r.Context()); err != nil {
		s.logger.Printf("[ERROR] [web] escrow confirmed but the agent-staged secret was NOT wiped (re-confirm to retry): %v", err)
	}
	offboxRedirect(w, r, "A kulcs letétbe helyezése megerősítve — a távoli mentés mostantól futhat.", false)
}

// wipeStagedEscrow calls the injected seam (tests), else the agent's DELETE /escrow/stage-secret over the
// pinned local-API channel (agent >= v0.78.0).
func (s *Server) wipeStagedEscrow(ctx context.Context) error {
	if s.wipeStagedEscrowFn != nil {
		return s.wipeStagedEscrowFn(ctx)
	}
	client, err := s.agentClient()
	if err != nil {
		return err
	}
	wctx, cancel := context.WithTimeout(ctx, 15*time.Second)
	defer cancel()
	return client.WipeStagedEscrowSecret(wctx)
}

// offboxInjectPasswordHandler pre-places a RECOVERED repo password at the offbox password path (fork-4 DR
// seam) so a subsequent configure uses it and the existing offsite repo opens. Operator/DR only; the value
// is never logged. Body: {password, force?}.
func (s *Server) offboxInjectPasswordHandler(w http.ResponseWriter, r *http.Request) {
	if s.backupMgr == nil {
		offboxRedirect(w, r, "A mentéskezelő nem elérhető.", true)
		return
	}
	_ = r.ParseForm()
	pw := r.FormValue("password")
	force := r.FormValue("force") == "on" || r.FormValue("force") == "true"
	if strings.TrimSpace(pw) == "" {
		offboxRedirect(w, r, "A repo jelszó kötelező.", true)
		return
	}
	if err := s.backupMgr.InjectOffboxPassword(pw, force); err != nil {
		offboxRedirect(w, r, "A jelszó beállítása sikertelen: "+err.Error(), true)
		return
	}
	s.logger.Printf("[INFO] [web] off-box repo password injected (DR pre-place, force=%v)", force)
	offboxRedirect(w, r, "A helyreállított repo jelszó beállítva.", false)
}

// offboxToggleHandler flips an app's off-box inclusion.
func (s *Server) offboxToggleHandler(w http.ResponseWriter, r *http.Request) {
	_ = r.ParseForm()
	app := strings.TrimSpace(r.FormValue("app"))
	on := r.FormValue("enabled") == "on" || r.FormValue("enabled") == "true"
	if app == "" {
		offboxRedirect(w, r, "Hiányzó alkalmazás.", true)
		return
	}
	if err := s.settings.SetAppOffbox(app, on); err != nil {
		offboxRedirect(w, r, "A beállítás mentése sikertelen.", true)
		return
	}
	s.reportTriggerNow() // v0.139.0: per-app offsite toggle reaches the hub in seconds
	offboxRedirect(w, r, "A távoli mentés beállítása frissítve.", false)
}

// offboxRunHandler triggers an off-box backup now (async — it can run for minutes).
func (s *Server) offboxRunHandler(w http.ResponseWriter, r *http.Request) {
	if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() {
		offboxRedirect(w, r, "A távoli mentési cél nincs beállítva.", true)
		return
	}
	// fork-4 atomicity: refuse the run until the repo password is escrowed under R.
	if !s.backupMgr.OffboxRunnable() {
		offboxRedirect(w, r, "A távoli mentés a kulcs letétbe helyezésére vár.", true)
		return
	}
	// Offsite-repo continuity (v0.142.0): an ORPHANED repo can't be written — route the customer to the
	// orphan card's explanation/reset instead of attempting a doomed write.
	if s.backupMgr.OffboxOrphaned() {
		offboxRedirect(w, r, "A távoli tároló elárvult — előbb indíts új távoli mentést a kártyán látható módon.", true)
		return
	}
	go func() {
		ctx, cancel := context.WithTimeout(context.Background(), 3*time.Hour)
		defer cancel()
		// ...WithProgress: this is the MANUAL trigger, so the page gets live bytes/percent/current app
		// (4c). The nightly scheduler keeps calling RunOffboxBackup and stays silent.
		if err := s.backupMgr.RunOffboxBackupWithProgress(ctx); err != nil {
			s.logger.Printf("[WARN] [web] manual off-box backup failed: %v", err)
		}
	}()
	offboxRedirect(w, r, "A távoli mentés elindult — az állapot itt frissül.", false)
}

// offboxResetHandler is the CLAIMED confirmed orphaned-repo reset (Scenario C): move the old (recovery-
// code-recoverable) history aside — never delete — and init a fresh repo. Refuses unless orphaned AND
// explicitly confirmed (confirm=1, set by the reveal-then-confirm block on the orphan card).
func (s *Server) offboxResetHandler(w http.ResponseWriter, r *http.Request) {
	if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() {
		offboxRedirect(w, r, "A távoli mentési cél nincs beállítva.", true)
		return
	}
	if !s.backupMgr.OffboxOrphaned() {
		offboxRedirect(w, r, "Az offsite tároló nincs elárvult állapotban.", true)
		return
	}
	if r.FormValue("confirm") != "1" {
		offboxRedirect(w, r, "A visszaállításhoz megerősítés szükséges.", true)
		return
	}
	go func() {
		ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
		defer cancel()
		if err := s.backupMgr.ResetOrphanedRepo(ctx); err != nil {
			s.logger.Printf("[WARN] [web] offbox orphaned-repo reset failed: %v", err)
		}
	}()
	offboxRedirect(w, r, "Új távoli mentés indítása folyamatban — a régi előzmény félretéve (nem törölve).", false)
}

// offboxStatusHandler (Part C) is the poll source for the remote-backup run status — the page polls it
// after "Távoli mentés most" and flips to the terminal state without a manual reload. Session-auth'd.
func (s *Server) offboxStatusHandler(w http.ResponseWriter, r *http.Request) {
	t := s.settings.GetOffboxTarget()
	resp := map[string]any{"status": "", "snapshots": 0, "orphaned": false}
	if t != nil {
		resp["status"] = t.LastStatus
		resp["snapshots"] = t.SnapshotCount
		resp["last_run"] = t.LastRun
		resp["last_duration"] = t.LastDuration
		resp["repo_size_human"] = t.RepoSizeHuman
		resp["last_error"] = t.LastError
		resp["orphaned"] = t.RepoState == "orphaned"
	}
	// v0.147.0 (4c): live progress for a MANUAL run. Absent/inactive on the nightly path, so the
	// page simply keeps its previous „Fut…" behavior there.
	if s.backupMgr != nil {
		resp["progress"] = s.backupMgr.OffboxProgressSnapshot()
	}
	w.Header().Set("Content-Type", "application/json")
	_ = json.NewEncoder(w).Encode(resp)
}

// offboxRestoreHandler restores an app's off-box data to an on-data-drive scratch dir (§7, F-A1;
// non-destructive — does NOT overwrite live data). mode=unit (default) restores the recovery unit
// only; mode=full is size-gated and two-step (first POST computes the size + headroom and redirects
// with a reveal cue; the revealed confirm POSTs mode=full&confirm=1, re-checked at execution).
func (s *Server) offboxRestoreHandler(w http.ResponseWriter, r *http.Request) {
	if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() {
		offboxRedirectTo(w, r, "/backups/restore", "A távoli mentési cél nincs beállítva.", true)
		return
	}
	_ = r.ParseForm()
	app := strings.TrimSpace(r.FormValue("app"))
	if app == "" {
		offboxRedirectTo(w, r, "/backups/restore", "Hiányzó alkalmazás.", true)
		return
	}
	mode := strings.TrimSpace(r.FormValue("mode"))
	if mode == "" {
		mode = "unit"
	}
	// Step 1 of the full two-step: compute size + headroom BEFORE any restic restore; on a refusal
	// flash the Hungarian reason, else redirect with the reveal params (size shown before starting).
	if mode == "full" && r.FormValue("confirm") != "1" {
		pctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
		defer cancel()
		sizeHuman, err := s.backupMgr.OffboxRestorePrepareFull(pctx, app)
		if err != nil {
			offboxRedirectTo(w, r, restoreWizardPath(app), err.Error(), true)
			return
		}
		// R-48: the size-gate reveal now lands on the app's wizard (prepare-confirm step) rather than
		// on the list page. Same params, same meaning — only the surface that renders them changed.
		http.Redirect(w, r, restoreWizardPath(app)+"&full_prep="+url.QueryEscape(app)+"&full_size="+url.QueryEscape(sizeHuman), http.StatusFound)
		return
	}
	// Fast-path refuse a concurrent op, then run async on a BACKGROUND context (a proxy read-timeout on
	// r.Context() would CANCEL the SFTP restore mid-flight — the F4 lesson).
	if s.backupMgr.IsRunning() {
		offboxRedirectTo(w, r, restoreWizardPath(app), "Egy mentési/visszaállítási művelet már fut.", true)
		return
	}
	full := mode == "full"
	s.backupMgr.BeginRestoreOp("offbox-restore", app)
	go func() {
		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
		defer cancel()
		if err := s.backupMgr.RestoreOffboxScratch(ctx, app, full); err != nil {
			s.logger.Printf("[ERROR] [web] off-box restore %s (full=%v, async): %v", app, full, err)
			s.backupMgr.EndRestoreOp(false, "A visszaállítás sikertelen: "+err.Error())
			return
		}
		s.logger.Printf("[INFO] [web] off-box restore %s completed (full=%v, async)", app, full)
		// NAME THE RESULT (v0.147.0, 4a). The old message said the app had been restored "to a
		// verification folder on the drive" — which folder, on which drive, was invisible, so the
		// customer had no way to look at what they had just asked for. Resolve the real path and say
		// it. Fall back to the vague wording only if the path can no longer be resolved.
		where := s.backupMgr.OffsiteRestoreScratchPath(app)
		msg := "A(z) " + app + " visszaállítva ellenőrző mappába a meghajtón (a meglévő adatok változatlanok)."
		if where != "" {
			msg = "A(z) " + app + " visszaállítva ellenőrző mappába: " + where + " (a meglévő adatok változatlanok)."
		}
		s.backupMgr.EndRestoreOp(true, msg)
	}()
	offboxRedirectTo(w, r, restoreWizardPath(app), "A távoli visszaállítás elindult — az állapot itt frissül.", false)
}

// offboxReconstituteHandler is the TRUE offsite restore (R-43, v0.148.0): files overwritten to the
// snapshot's version + that same snapshot's database replayed + the app restarted, with a safety
// dump of the current database taken first.
//
// It is a separate button from the missing-only place, not a flag on it. The two do opposite things
// to an existing file, and the v0.147 flash („hiányzó fájljai helyreállítva") described a mechanism
// that could report success after merging zero files while the customer's photos stayed invisible.
// The flash here states the OUTCOME instead — file count, database, backup timestamp, restart —
// because that is the only part the customer can check against what they see in the app.
func (s *Server) offboxReconstituteHandler(w http.ResponseWriter, r *http.Request) {
	if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() {
		offboxRedirectTo(w, r, "/backups/restore", "A távoli mentési cél nincs beállítva.", true)
		return
	}
	_ = r.ParseForm()
	app := strings.TrimSpace(r.FormValue("app"))
	if app == "" {
		offboxRedirectTo(w, r, "/backups/restore", "Hiányzó alkalmazás.", true)
		return
	}
	// This one overwrites live files and replays a database — it must never happen on a stray click.
	if r.FormValue("confirm") != "1" {
		offboxRedirectTo(w, r, restoreWizardPath(app), "A teljes visszaállítás megerősítés nélkül nem hajtható végre.", true)
		return
	}
	if s.backupMgr.IsRunning() {
		offboxRedirectTo(w, r, restoreWizardPath(app), "Egy mentési/visszaállítási művelet már fut.", true)
		return
	}
	s.backupMgr.BeginRestoreOp("offbox-reconstitute", app)
	go func() {
		ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute)
		defer cancel()
		res, err := s.backupMgr.ReconstituteFromOffsite(ctx, app)
		if err != nil {
			s.logger.Printf("[ERROR] [web] off-box reconstitute %s (async): %v", app, err)
			s.backupMgr.EndRestoreOp(false, "A teljes visszaállítás sikertelen: "+err.Error())
			return
		}
		s.logger.Printf("[INFO] [web] off-box reconstitute %s completed (async): files=%d dbs=%d snapshot=%s",
			app, res.FilesPlaced, res.DBsReplayed, res.SnapshotID)
		s.backupMgr.EndRestoreOp(true, reconstituteOutcomeMsg(app, res))
	}()
	offboxRedirectTo(w, r, restoreWizardPath(app), "A teljes visszaállítás elindult — az állapot itt frissül.", false)
}

// reconstituteOutcomeMsg builds the OUTCOME flash for a completed reconstitution. Pure, so the
// wording is unit-testable — this string is the customer's only evidence that the operation did
// what its label promised, and the zero-file and no-database cases must each read truthfully rather
// than borrowing the confident sentence that belongs to the full case.
func reconstituteOutcomeMsg(app string, res backup.OffsiteReconstituteResult) string {
	when := ""
	if !res.DumpsAt.IsZero() {
		when = " (mentés: " + res.DumpsAt.In(getTimezone()).Format("2006-01-02 15:04") + ")"
	}
	if res.DBsReplayed == 0 {
		// A no-database app: saying "és az adatbázis" here would be a lie, and this is precisely the
		// class of sentence the DIAG found being printed over a no-op.
		return fmt.Sprintf("A(z) %s: %d fájl visszaállítva%s — az alkalmazás újraindult. Ennek az alkalmazásnak nincs adatbázisa.", app, res.FilesPlaced, when)
	}
	return fmt.Sprintf("A(z) %s: %d fájl és az adatbázis visszaállítva%s — az alkalmazás újraindult.", app, res.FilesPlaced, when)
}

// offboxVerifyCopyDeleteHandler removes ONE verification copy (v0.147.0, 4a).
//
// The only delete this slice adds, so it is deliberately narrow: it names a STACK, never a path — the
// customer cannot hand us a path to remove. The Manager resolves that name inside a
// `backups/offsite-restore` root it computed itself and refuses anything that lands outside (see
// DeleteOffsiteRestoreCopy). The template double-confirms before POSTing.
//
// It refuses while a backup/restore op is running: the copy being deleted could be the one currently
// being written.
func (s *Server) offboxVerifyCopyDeleteHandler(w http.ResponseWriter, r *http.Request) {
	if s.backupMgr == nil {
		offboxRedirectTo(w, r, "/backups/restore", "A mentéskezelő nem érhető el.", true)
		return
	}
	_ = r.ParseForm()
	stack := strings.TrimSpace(r.FormValue("stack"))
	if stack == "" {
		offboxRedirectTo(w, r, "/backups/restore", "Hiányzó ellenőrző másolat.", true)
		return
	}
	if r.FormValue("confirm") != "1" {
		offboxRedirectTo(w, r, "/backups/restore", "A törlés megerősítés nélkül nem hajtható végre.", true)
		return
	}
	if s.backupMgr.IsRunning() {
		offboxRedirectTo(w, r, "/backups/restore", "Egy mentési/visszaállítási művelet fut — a törlés most nem biztonságos.", true)
		return
	}
	if err := s.backupMgr.DeleteOffsiteRestoreCopy(stack); err != nil {
		s.logger.Printf("[WARN] [web] verification-copy delete %s: %v", stack, err)
		offboxRedirectTo(w, r, "/backups/restore", "A másolat törlése nem sikerült: "+err.Error(), true)
		return
	}
	s.logger.Printf("[INFO] [web] verification copy deleted: %s", stack)
	offboxRedirectTo(w, r, "/backups/restore", "Az ellenőrző másolat törölve. A tényleges adataid változatlanok.", false)
}

// offboxPlaceHandler places a COMPLETED full-restore scratch into the app's live locations via a
// missing-only merge (§7.3). Never overwrites existing files. Async on a background context.
func (s *Server) offboxPlaceHandler(w http.ResponseWriter, r *http.Request) {
	if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() {
		offboxRedirectTo(w, r, "/backups/restore", "A távoli mentési cél nincs beállítva.", true)
		return
	}
	_ = r.ParseForm()
	app := strings.TrimSpace(r.FormValue("app"))
	if app == "" {
		offboxRedirectTo(w, r, "/backups/restore", "Hiányzó alkalmazás.", true)
		return
	}
	if s.backupMgr.IsRunning() {
		offboxRedirectTo(w, r, restoreWizardPath(app), "Egy mentési/visszaállítási művelet már fut.", true)
		return
	}
	s.backupMgr.BeginRestoreOp("offbox-place", app)
	go func() {
		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
		defer cancel()
		if err := s.backupMgr.PlaceOffsiteRestore(ctx, app); err != nil {
			s.logger.Printf("[ERROR] [web] off-box place %s (async): %v", app, err)
			s.backupMgr.EndRestoreOp(false, "A helyreállítás sikertelen: "+err.Error())
			return
		}
		s.logger.Printf("[INFO] [web] off-box place %s completed (async)", app)
		s.backupMgr.EndRestoreOp(true, "A(z) "+app+" hiányzó fájljai helyreállítva az élő adatok közé.")
	}()
	offboxRedirectTo(w, r, restoreWizardPath(app), "A helyreállítás elindult — az állapot itt frissül.", false)
}

// --- R-7b: „Megosztások" restore ------------------------------------------------------------------
//
// A SIBLING of the per-app restore pair above, not a special case of it: the shares source has no
// recovery unit and no per-app toggle, so it gets its own two-step flow (restore to scratch, then a
// deliberate place-to-live). The display name is always „Megosztások" — the reserved `_shares` key
// never reaches a customer-facing surface.

// sharesRestoreHandler restores the latest shares snapshot into an on-data-drive scratch dir
// (POST /backup/shares/restore). Non-destructive: nothing live is touched until the place action.
func (s *Server) sharesRestoreHandler(w http.ResponseWriter, r *http.Request) {
	if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() {
		offboxRedirectTo(w, r, "/backups/restore", "A távoli mentési cél nincs beállítva.", true)
		return
	}
	if s.backupMgr.IsRunning() {
		offboxRedirectTo(w, r, "/backups/restore", "Egy mentési/visszaállítási művelet már fut.", true)
		return
	}
	s.backupMgr.BeginRestoreOp("shares-restore", backup.SharesDisplayName)
	go func() {
		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
		defer cancel()
		if err := s.backupMgr.RestoreSharesScratch(ctx); err != nil {
			s.logger.Printf("[ERROR] [web] shares restore (async): %v", err)
			s.backupMgr.EndRestoreOp(false, "A megosztások visszaállítása sikertelen: "+err.Error())
			return
		}
		s.logger.Printf("[INFO] [web] shares restore completed (async)")
		s.backupMgr.EndRestoreOp(true, "A megosztások visszaállítása elkészült — most helyreállíthatod az élő adatok közé.")
	}()
	offboxRedirectTo(w, r, "/backups/restore", "A megosztások visszaállítása elindult — az állapot itt frissül.", false)
}

// sharesPlaceHandler merges a completed shares scratch into the live share folders, re-adds the
// missing definitions and restores the household credential (POST /backup/shares/place).
func (s *Server) sharesPlaceHandler(w http.ResponseWriter, r *http.Request) {
	if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() {
		offboxRedirectTo(w, r, "/backups/restore", "A távoli mentési cél nincs beállítva.", true)
		return
	}
	if s.backupMgr.IsRunning() {
		offboxRedirectTo(w, r, "/backups/restore", "Egy mentési/visszaállítási művelet már fut.", true)
		return
	}
	s.backupMgr.BeginRestoreOp("shares-place", backup.SharesDisplayName)
	go func() {
		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
		defer cancel()
		res, err := s.backupMgr.PlaceSharesRestore(ctx)
		if err != nil {
			s.logger.Printf("[ERROR] [web] shares place (async): %v", err)
			s.backupMgr.EndRestoreOp(false, "A megosztások helyreállítása sikertelen: "+err.Error())
			return
		}
		s.logger.Printf("[INFO] [web] shares place completed (async): %d file(s), %d definition(s)",
			res.FilesRestored, len(res.DefinitionsAdded))
		s.backupMgr.EndRestoreOp(true, res.FlashMessage())
	}()
	offboxRedirectTo(w, r, "/backups/restore", "A megosztások helyreállítása elindult — az állapot itt frissül.", false)
}
