package web

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"os"
	"path"
	"path/filepath"
	"regexp"
	"strings"
	"time"

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

// Guided storage provisioning (rebuilt on the agent-delegated disk model). The controller is a thin
// orchestrator over the agent's authoritative disk endpoints (format/assign/eject, all data-bearing-
// gated on the agent) + the local StoragePath registry. It holds NO destructive authority: a data-
// bearing format is REFUSED by the agent, and the only thing the controller does with that refusal is
// surface the exact `felhom-opsign` command — there is no force-format path here.

// diskAgent is the subset of *agentapi.Client the orchestration needs (an interface so it's testable
// without a live agent). *agentapi.Client satisfies it.
type diskAgent interface {
	Disks(ctx context.Context) (agentapi.DisksResponse, error)
	ListCandidates(ctx context.Context) (agentapi.CandidatesResult, error)
	FormatDisk(ctx context.Context, device, fstype string, confirmed bool, durableID string) (agentapi.FormatResult, error)
	AssignDisk(ctx context.Context, uuid, where, fstype, options string) error
	EjectDisk(ctx context.Context, where string) (agentapi.EjectResult, error)
	Decommission(ctx context.Context, where string) (agentapi.DecommissionResult, error)
	GuestAttach(ctx context.Context, where string) error
	GuestReboot(ctx context.Context) error
}

// mountNameRe is the safe `/mnt/<name>` component (DNS-ish: letters, digits, _ , -).
var mountNameRe = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,40}$`)

// validFSTypes are the filesystems the init flow offers (the agent re-validates).
var validFSTypes = map[string]bool{"ext4": true, "xfs": true}

// mountWhere builds + validates the mount target from a user-supplied name → "/mnt/<name>".
func mountWhere(name string) (string, error) {
	name = strings.TrimSpace(name)
	if !mountNameRe.MatchString(name) {
		return "", fmt.Errorf("érvénytelen csatlakoztatási név (csak betűk, számok, _ és - engedélyezett)")
	}
	return "/mnt/" + name, nil
}

// fsUUIDForDevice re-lists the agent's disks and returns the fs UUID of the storage backed by `device`
// (the only way the de-privileged controller learns the UUID it must pass to assign). "" if not found.
func fsUUIDForDevice(disks agentapi.DisksResponse, device string) string {
	for _, d := range disks.Disks {
		if d.BackingDevice == device {
			return d.FSUUID()
		}
	}
	return ""
}

// resolveEnrollUUID resolves a device's filesystem UUID for enrollment. A RAW candidate (Impl-2b) is
// NOT in the /disks list (it has no PVE storage / isn't enrolled), so fsUUIDForDevice can't see it —
// fall back to the raw-device scan (/disks/candidates), which reports each free disk's durable_id
// (uuid:<fs-uuid>). Matches the whole disk OR its FS-bearing node (mount_source, e.g. /dev/sdd1).
// Returns "" only if the device truly has no resolvable fs UUID.
func resolveEnrollUUID(ctx context.Context, agent diskAgent, device string) string {
	if disks, err := agent.Disks(ctx); err == nil {
		if u := fsUUIDForDevice(disks, device); u != "" {
			return u
		}
	}
	cands, err := agent.ListCandidates(ctx)
	if err != nil {
		return ""
	}
	for _, c := range append(append([]agentapi.DiskCandidate{}, cands.Initialize...), cands.Attach...) {
		if c.Device == device || c.MountSource == device {
			if rest, ok := strings.CutPrefix(c.DurableID, "uuid:"); ok {
				return rest
			}
		}
	}
	return ""
}

// storageInitResult is the outcome of an init attempt (JSON-rendered to the wizard).
type storageInitResult struct {
	Registered bool   `json:"registered"`
	Where      string `json:"where,omitempty"`
	// NeedsConfirmation (USER-DATA data-bearing): the customer must confirm the wipe (type-to-confirm),
	// then the wizard re-submits with confirmed=true. NOT an operator signature.
	NeedsConfirmation bool   `json:"needs_confirmation,omitempty"`
	Role              string `json:"role,omitempty"`
	DurableID         string `json:"durable_id,omitempty"`
	// Refusal (system/backup data-bearing): the operator must sign offline. No bypass.
	Refused bool   `json:"refused,omitempty"`
	Reason  string `json:"reason,omitempty"`
	Opsign  string `json:"opsign,omitempty"`
}

// runStorageInit is the testable core of the init flow: format → (confirm/refuse?) → resolve new
// UUID → assign → register. A USER-DATA data-bearing device requires the customer's confirmation
// (NeedsConfirmation); a SYSTEM/BACKUP device requires an operator signature (Refused+Opsign). In
// either refusal it performs NO further (destructive or mount) action.
func (s *Server) runStorageInit(ctx context.Context, agent diskAgent, device, fstype, where, label string, setDefault, confirmed bool, durableID string) (storageInitResult, error) {
	if !validFSTypes[fstype] {
		return storageInitResult{}, fmt.Errorf("nem támogatott fájlrendszer: %q (ext4 vagy xfs)", fstype)
	}
	// 1. Format — the AGENT inspects the device and tiers it by role. A data-bearing user-data device
	// is allowed only with the customer's confirmation bound to its durable id; system/backup needs
	// an operator signature.
	fr, err := agent.FormatDisk(ctx, device, fstype, confirmed, durableID)
	if errors.Is(err, agentapi.ErrNeedsConfirmation) {
		// USER-DATA: surface the type-to-confirm requirement + the durable id to confirm against.
		return storageInitResult{NeedsConfirmation: true, Role: fr.Role, DurableID: fr.DurableID, Reason: fr.Reason}, nil
	}
	if errors.Is(err, agentapi.ErrFormatRefused) {
		res := storageInitResult{Refused: true, Reason: fr.Reason}
		if fr.PendingOp != nil {
			res.Opsign = fr.PendingOp.OpsignCommand()
		}
		return res, nil // STOP — no bypass; the UI surfaces Opsign.
	}
	if err != nil {
		return storageInitResult{}, fmt.Errorf("formázás sikertelen: %w", err)
	}
	if !fr.Formatted {
		return storageInitResult{}, fmt.Errorf("az eszköz nem lett megformázva (%s)", fr.Reason)
	}
	// 2. Resolve the NEW fs UUID. A freshly-formatted RAW device isn't in /disks (not enrolled yet), so
	// resolve via the raw-device scan too (Impl-2b) — the device now appears there with its new durable_id.
	uuid := resolveEnrollUUID(ctx, agent, device)
	if uuid == "" {
		return storageInitResult{}, fmt.Errorf("formázás kész, de az új fájlrendszer-azonosító nem feloldható — frissítsen és használja a Csatolás funkciót")
	}
	// 3. Mount (benign assign) at the raw /mnt/<name>. 4. Bind felhom-data under the shared parent FIRST
	// (intermediary model) so the drive's STABLE path is live in the guest, THEN register + skeleton there
	// (the controller can only see/write the drive at the stable path post-attach). 5. Register the stable
	// path — that is what apps' HDD_PATH / FileBrowser / monitoring use.
	if err := agent.AssignDisk(ctx, uuid, where, fstype, ""); err != nil {
		return storageInitResult{}, fmt.Errorf("csatlakoztatás sikertelen: %w", err)
	}
	s.attachIntoGuest(ctx, agent, where)
	stable := stablePathForName(path.Base(where))
	if err := s.registerStoragePath(stable, label, setDefault); err != nil {
		return storageInitResult{}, err
	}
	return storageInitResult{Registered: true, Where: stable}, nil
}

// attachIntoGuest passes an enrolled drive INTO the guest (slice 10 P2) so the controller + apps can
// use it. Best-effort: the StoragePath registration is the durable intent, so a transient attach
// failure is logged (not fatal) — P3 self-heal reconcile will complete it on the next tick.
func (s *Server) attachIntoGuest(ctx context.Context, agent diskAgent, where string) {
	if err := agent.GuestAttach(ctx, where); err != nil {
		s.logger.Printf("[WARN] [web] enroll: guest-attach %s failed (registered; will be retried): %v", where, err)
		return
	}
	s.logger.Printf("[INFO] [web] enroll: drive bound into guest: %s", where)
}

// runStorageAttach mounts an existing-filesystem device (non-destructive — never touches the gate)
// and registers it. The UUID is resolved server-side from the device.
func (s *Server) runStorageAttach(ctx context.Context, agent diskAgent, device, fstype, where, label string, setDefault bool) (storageInitResult, error) {
	// Resolve the fs UUID. Works for a legacy re-attach (drive in /disks) AND a raw candidate (Impl-2b:
	// not in /disks — resolved via the raw-device scan's durable_id).
	uuid := resolveEnrollUUID(ctx, agent, device)
	if uuid == "" {
		return storageInitResult{}, fmt.Errorf("a kiválasztott meghajtóhoz nem található fájlrendszer-azonosító (csak fájlrendszerrel rendelkező meghajtó csatolható)")
	}
	if err := agent.AssignDisk(ctx, uuid, where, fstype, ""); err != nil {
		return storageInitResult{}, fmt.Errorf("csatlakoztatás sikertelen: %w", err)
	}
	s.attachIntoGuest(ctx, agent, where)
	stable := stablePathForName(path.Base(where))
	if err := s.registerStoragePath(stable, label, setDefault); err != nil {
		return storageInitResult{}, err
	}
	return storageInitResult{Registered: true, Where: stable}, nil
}

// reEnrollClearMarker un-retires a re-plugged decommissioned drive (Change 4): clears the soft marker
// and restores Schedulable so its apps' "missing storage" indicator clears. Returns true if it acted.
func (s *Server) reEnrollClearMarker(where string) (bool, error) {
	if !s.settings.IsDecommissioned(where) {
		return false, nil
	}
	if err := s.settings.ClearDecommissioned(where); err != nil {
		return false, fmt.Errorf("leszerelés visszavonása sikertelen: %w", err)
	}
	if err := s.settings.SetSchedulable(where, true); err != nil {
		return false, fmt.Errorf("ütemezhetőség visszaállítása sikertelen: %w", err)
	}
	s.logger.Printf("[INFO] [web] re-enrolled decommissioned drive — marker cleared: %s", where)
	return true, nil
}

// registerStoragePath records a freshly-mounted path in the StoragePath registry (schedulable by
// default) and refreshes the FileBrowser mounts so it's usable immediately.
func (s *Server) registerStoragePath(where, label string, setDefault bool) error {
	if strings.TrimSpace(label) == "" {
		label = settings.InferStorageLabel(where)
	}
	// v0.66.0: create the full userdata skeleton with the shared-storage convention (2775 setgid,
	// gid 1000) the moment a drive is registered — system drive AND additional drives. Idempotent;
	// best-effort (a perms hiccup shouldn't block registration).
	if err := appbackup.EnsureUserdataSkeleton(where); err != nil {
		s.logger.Printf("[WARN] [web] userdata skeleton on %s: %v", where, err)
	}
	// Change 4: re-enrolling a previously-DECOMMISSIONED drive must un-retire it. AddStoragePath
	// dedups a re-register into a no-op, so without this the soft marker would persist forever and the
	// apps' "missing storage" indicator would never clear.
	if cleared, err := s.reEnrollClearMarker(where); err != nil {
		return err
	} else if cleared {
		if s.stackMgr != nil {
			go s.SyncFileBrowserMounts()
		}
		return nil
	}
	sp := settings.StoragePath{
		Path:        where,
		Label:       label,
		IsDefault:   setDefault,
		Schedulable: true,
		AddedAt:     time.Now().UTC().Format(time.RFC3339),
	}
	if err := s.settings.AddStoragePath(sp); err != nil {
		return fmt.Errorf("regisztráció sikertelen: %w", err)
	}
	go s.SyncFileBrowserMounts()
	return nil
}

// refuseNetworkLifecycle blocks a drive-lifecycle action (eject/decommission/migrate/wipe) on a NAS
// network-storage path. A network share is a DISTINCT class with no device lifecycle — its only
// lifecycle action is /api/netstorage/remove. Returns true (and writes the refusal) when the path is a
// network path; the caller must then return. This is the server-side Kind-gate that backs the UI gating.
func (s *Server) refuseNetworkLifecycle(w http.ResponseWriter, where string) bool {
	if s.settings != nil && s.settings.IsNetworkStoragePath(where) {
		writeDiskJSON(w, http.StatusBadRequest, false,
			"ez hálózati tárhely (NAS) — a meghajtó-műveletek (leválasztás/leszerelés/áthelyezés/törlés) nem alkalmazhatók rá; használd az „Eltávolítás” gombot", nil)
		return true
	}
	return false
}

// ---- HTTP handlers (behind RequireAuth + CsrfProtect) -----------------------------------------

// storageWizardPageHandler renders the init/attach wizard page (the disk list + actions are driven
// client-side from GET /api/disks; the form posts to /api/storage/{init,attach}).
func (s *Server) storageWizardPageHandler(w http.ResponseWriter, r *http.Request, tmpl string) {
	title := "Új meghajtó inicializálása"
	if tmpl == "storage_attach" {
		title = "Meglévő meghajtó csatolása"
	}
	data := s.baseData(tmpl, title)
	s.render(w, tmpl, data)
}

// ServeStorageAPI dispatches /api/storage/* (guided init/attach/eject orchestration).
func (s *Server) ServeStorageAPI(w http.ResponseWriter, r *http.Request) {
	switch {
	case r.URL.Path == "/api/storage/init" && r.Method == http.MethodPost:
		s.handleStorageInit(w, r)
	case r.URL.Path == "/api/storage/attach" && r.Method == http.MethodPost:
		s.handleStorageAttach(w, r)
	case r.URL.Path == "/api/storage/eject" && r.Method == http.MethodPost:
		s.handleStorageEject(w, r)
	case r.URL.Path == "/api/storage/wipe" && r.Method == http.MethodPost:
		s.handleStorageWipe(w, r)
	case r.URL.Path == "/api/storage/impact" && r.Method == http.MethodGet:
		s.handleStorageImpact(w, r)
	case r.URL.Path == "/api/storage/register" && r.Method == http.MethodPost:
		s.handleStorageRegister(w, r)
	case r.URL.Path == "/api/storage/migrate" && r.Method == http.MethodPost:
		s.handleStorageMigrate(w, r)
	case r.URL.Path == "/api/storage/migrate-app" && r.Method == http.MethodPost:
		s.handleStorageMigrateApp(w, r)
	case r.URL.Path == "/api/storage/migrate/status" && r.Method == http.MethodGet:
		s.handleStorageMigrateStatus(w, r)
	case r.URL.Path == "/api/storage/decommission" && r.Method == http.MethodPost:
		s.handleStorageDecommission(w, r)
	// NAS network storage (Part A2) — distinct from the drive lifecycle above (proxy to agent /netstorage/*).
	case r.URL.Path == "/api/storage/netstorage/add" && r.Method == http.MethodPost:
		s.handleNetStorageAdd(w, r)
	case r.URL.Path == "/api/storage/netstorage" && r.Method == http.MethodGet:
		s.handleNetStorageList(w, r)
	case r.URL.Path == "/api/storage/netstorage/remove" && r.Method == http.MethodPost:
		s.handleNetStorageRemove(w, r)
	case r.URL.Path == "/api/storage/disconnect" && r.Method == http.MethodPost:
		s.handleStorageDisconnect(w, r)
	case r.URL.Path == "/api/storage/reconnect" && r.Method == http.MethodPost:
		s.handleStorageReconnect(w, r)
	case r.URL.Path == "/api/storage/restart-apps" && r.Method == http.MethodPost:
		s.handleStorageRestartApps(w, r)
	default:
		http.NotFound(w, r)
	}
}

// handleStorageMigrate starts a whole-namespace migration (all apps + non-app content) off the source
// drive onto the chosen target. Async: VALIDATE runs synchronously (a refusal is returned here and
// changes nothing); the copy/redeploy/cleanup run in the background and the UI polls migrate/status.
func (s *Server) handleStorageMigrate(w http.ResponseWriter, r *http.Request) {
	var req struct {
		Source string `json:"source"`
		Target string `json:"target"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
		return
	}
	if s.refuseNetworkLifecycle(w, strings.TrimSpace(req.Source)) || s.refuseNetworkLifecycle(w, strings.TrimSpace(req.Target)) {
		return
	}
	id, err := s.stackMgr.MigrateAll(r.Context(), strings.TrimSpace(req.Source), strings.TrimSpace(req.Target))
	if err != nil {
		writeDiskJSON(w, http.StatusConflict, false, err.Error(), nil)
		return
	}
	writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"started": true, "id": id})
}

// handleStorageMigrateApp starts a single-app migration onto the chosen target.
func (s *Server) handleStorageMigrateApp(w http.ResponseWriter, r *http.Request) {
	var req struct {
		App    string `json:"app"`
		Target string `json:"target"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
		return
	}
	id, err := s.stackMgr.MigrateApp(r.Context(), strings.TrimSpace(req.App), strings.TrimSpace(req.Target))
	if err != nil {
		writeDiskJSON(w, http.StatusConflict, false, err.Error(), nil)
		return
	}
	writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"started": true, "id": id})
}

// handleStorageMigrateStatus returns the live migration job (nil/idle when none is running) for the
// progress panel poll.
func (s *Server) handleStorageMigrateStatus(w http.ResponseWriter, r *http.Request) {
	writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"job": s.stackMgr.MigrationStatus()})
}

// handleStorageDecommission is the SELF-SERVE drive decommission (B2b). Exactly two choices, no partial
// (Change 2): mode="migrate" moves ALL apps to a target then decommissions the now-empty source (the
// done-hook finalizes); mode="anyway" decommissions immediately, stopping the apps (keeping their
// HDD_PATH so they show "missing storage"). Non-destructive — the drive's data is never formatted.
func (s *Server) handleStorageDecommission(w http.ResponseWriter, r *http.Request) {
	var req struct {
		Where     string `json:"where"`
		Mode      string `json:"mode"`       // "migrate" | "anyway"
		Target    string `json:"target"`     // required for mode=migrate
		MountName string `json:"mount_name"` // type-to-confirm for mode=anyway
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
		return
	}
	req.Where = path.Clean(strings.TrimSpace(req.Where))
	if req.Where == "" || req.Where == "." || !strings.HasPrefix(req.Where, "/mnt/") {
		writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen csatlakoztatási pont", nil)
		return
	}
	if s.refuseNetworkLifecycle(w, req.Where) {
		return
	}

	switch req.Mode {
	case "migrate":
		if strings.TrimSpace(req.Target) == "" {
			writeDiskJSON(w, http.StatusBadRequest, false, "céltároló kötelező az áthelyezéshez", nil)
			return
		}
		// Start the migration; the done-hook (onMigrationDone) soft-marks + agent-decommissions the
		// source once every app has moved and come up on the target. A VALIDATE refusal returns here.
		id, err := s.stackMgr.MigrateAllAndDecommission(r.Context(), req.Where, strings.TrimSpace(req.Target))
		if err != nil {
			writeDiskJSON(w, http.StatusConflict, false, err.Error(), nil)
			return
		}
		writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"started": true, "id": id, "mode": "migrate"})

	case "anyway":
		// Type-to-confirm: the typed name must match the mount basename exactly (mirrors wipe).
		if strings.TrimSpace(req.MountName) != path.Base(req.Where) {
			writeDiskJSON(w, http.StatusBadRequest, false, "a beírt név nem egyezik a csatlakoztatási névvel", nil)
			return
		}
		// Stop the apps that live on this drive — but KEEP their HDD_PATH so the dashboard can name the
		// drive in the "missing storage" indicator until the customer re-enrolls or migrates.
		var stopped []string
		for _, st := range s.stackMgr.GetStacks() {
			if !st.Deployed {
				continue
			}
			if cfg := s.stackMgr.LoadAppConfigByName(st.Name); cfg != nil && cfg.Env["HDD_PATH"] == req.Where {
				if err := s.stackMgr.StopStack(st.Name); err != nil {
					s.logger.Printf("[WARN] [web] decommission: stop %s failed: %v", st.Name, err)
				}
				stopped = append(stopped, st.Meta.DisplayName)
			}
		}
		if err := s.finalizeDecommission(r.Context(), req.Where, ""); err != nil {
			writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
			return
		}
		writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"decommissioned": true, "where": req.Where, "stopped_apps": stopped})

	default:
		writeDiskJSON(w, http.StatusBadRequest, false, "ismeretlen mód (migrate vagy anyway)", nil)
	}
}

// finalizeDecommission resolves the agent client then soft-marks + decommissions (see *With).
func (s *Server) finalizeDecommission(ctx context.Context, where, migratedTo string) error {
	agent, err := s.agentClient()
	if err != nil {
		return err
	}
	return s.finalizeDecommissionWith(ctx, agent, where, migratedTo)
}

// finalizeDecommissionWith soft-marks the registry path (keeping the entry — blocks A1 resurrection)
// and tells the agent to decommission the drive (intent + unmount; never formats). migratedTo is the
// target path for a migrate-then-decommission, or "" for decommission-anyway. The agent is injected so
// the orchestration is testable without a live agent.
func (s *Server) finalizeDecommissionWith(ctx context.Context, agent diskAgent, where, migratedTo string) error {
	// M1: never leave zero default. If `where` is the default, promote another usable drive; if none
	// exists, BLOCK before any side-effect (compute against the registry BEFORE SetDecommissioned, which
	// flips IsDefault/Schedulable on `where`).
	promote, mustBlock := defaultPromotionTarget(s.settings.GetStoragePaths(), where, migratedTo)
	if mustBlock {
		return fmt.Errorf("ez az egyetlen használható tárhely — a leszerelés megtagadva (előbb adj hozzá vagy állíts be másik alapértelmezett meghajtót)")
	}
	if err := s.settings.SetDecommissioned(where, migratedTo); err != nil {
		return fmt.Errorf("nyilvántartás frissítése sikertelen: %w", err)
	}
	if promote != "" {
		if derr := s.settings.SetDefaultStoragePath(promote); derr != nil {
			s.logger.Printf("[WARN] [web] default reassignment to %s failed: %v", promote, derr)
		} else {
			s.logger.Printf("[INFO] [web] default drive reassigned %s → %s (M1)", where, promote)
		}
	}
	// Registered path is the STABLE /mnt/felhom-drives/<name>; the agent decommissions the raw mount.
	if _, err := agent.Decommission(ctx, agentWhere(where)); err != nil {
		return fmt.Errorf("a meghajtó leszerelése sikertelen: %w", err)
	}
	if s.stackMgr != nil {
		go s.SyncFileBrowserMounts()
	}
	s.logger.Printf("[INFO] [web] storage decommissioned: %s (migrated_to=%q)", where, migratedTo)
	return nil
}

// onMigrationDone is the migration completion hook: when a decommission-initiated migration succeeds,
// the source drive is now empty (all apps flipped to the target), so soft-mark + agent-decommission it.
func (s *Server) OnMigrationDone(j *stacks.MigrationJob) {
	if j == nil || !j.DecommissionOnDone {
		return
	}
	if err := s.finalizeDecommission(context.Background(), j.Source, j.Target); err != nil {
		s.logger.Printf("[ERROR] [web] post-migration decommission of %s failed: %v", j.Source, err)
		return
	}
	s.logger.Printf("[INFO] [web] source %s decommissioned after migration to %s", j.Source, j.Target)
}

type storageProvReq struct {
	Device     string `json:"device"`
	FSType     string `json:"fstype"`
	MountName  string `json:"mount_name"`
	Label      string `json:"label"`
	SetDefault bool   `json:"set_default"`
	// Confirmed + DurableID: the customer's type-to-confirm authorization for a USER-DATA data-bearing
	// wipe (the durable id the agent returned on the prior NeedsConfirmation response).
	Confirmed bool   `json:"confirmed"`
	DurableID string `json:"durable_id"`
}

func (s *Server) handleStorageInit(w http.ResponseWriter, r *http.Request) {
	var req storageProvReq
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
		return
	}
	where, err := mountWhere(req.MountName)
	if err != nil {
		writeDiskJSON(w, http.StatusBadRequest, false, err.Error(), nil)
		return
	}
	if req.Device == "" {
		writeDiskJSON(w, http.StatusBadRequest, false, "eszköz kötelező", nil)
		return
	}
	agent, err := s.agentClient()
	if err != nil {
		writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
		return
	}
	res, err := s.runStorageInit(r.Context(), agent, req.Device, req.FSType, where, req.Label, req.SetDefault, req.Confirmed, req.DurableID)
	if err != nil {
		writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
		return
	}
	if res.NeedsConfirmation {
		writeDiskJSON(w, http.StatusConflict, false, "ügyfél-megerősítés szükséges", res)
		return
	}
	if res.Refused {
		writeDiskJSON(w, http.StatusConflict, false, "operátori aláírás szükséges", res)
		return
	}
	writeDiskJSON(w, http.StatusOK, true, "", res)
}

// storageImpactReq / handleStorageImpact return the deployed apps whose data lives on a given mount —
// the "name the apps that break" requirement for the type-to-confirm wipe/eject UI.
func (s *Server) handleStorageImpact(w http.ResponseWriter, r *http.Request) {
	where := path.Clean(strings.TrimSpace(r.URL.Query().Get("where")))
	if where == "" || where == "." || !strings.HasPrefix(where, "/mnt/") {
		writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen csatlakoztatási pont", nil)
		return
	}
	apps := s.appsUsingPath(where)
	if apps == nil {
		apps = []string{}
	}
	// P4 (4B): a user-data drive is ALSO backup-target-eligible — it may hold cross-drive backup copies
	// of OTHER drives' app data. A wipe destroys those copies too, so name them in the confirmation.
	// (The copies are redundant — the originals live on the source drive — so the wipe stays customer-
	// confirmable, NOT operator-signature; the warning just makes the loss explicit.)
	backupCopies := backupCopiesOnPath(where)
	if backupCopies == nil {
		backupCopies = []string{}
	}
	writeDiskJSON(w, http.StatusOK, true, "", map[string]any{
		"where": where, "apps": apps, "backup_copies": backupCopies,
	})
}

// backupCopiesOnPath lists the apps whose CROSS-DRIVE (secondary) backup copies are stored on the
// drive mounted at `where` (slice 10 P4) — the backups/secondary/<app> dirs. A wipe of this drive
// removes these copies. Best-effort filesystem scan; empty until the cross-drive backup ENGINE (a
// follow-on slice) actually writes here. Shared/aggregate dirs (restic repo, _infra) are not apps
// and are skipped. Model A: `where` is the in-guest drive mount, which IS the felhom-data namespace
// root, so backups/ sits directly under it (no felhom-data segment — avoids the double-nest).
func backupCopiesOnPath(where string) []string {
	secondary := filepath.Join(appbackup.NamespaceRoot(where, true), "backups", "secondary")
	entries, err := os.ReadDir(secondary)
	if err != nil {
		return nil // no secondary backups here (or the path isn't readable) — nothing to warn about
	}
	var apps []string
	for _, e := range entries {
		if !e.IsDir() {
			continue
		}
		name := e.Name()
		if name == "restic" || name == "_infra" { // shared repo / infra, not a per-app copy
			continue
		}
		apps = append(apps, name)
	}
	return apps
}

// handleStorageWipe is the customer-confirmed wipe of a USER-DATA drive: it unmounts (eject —
// deregisters + frees the device) then formats with the customer's confirmation bound to the device's
// durable id. The agent re-classifies the role and re-resolves the durable id itself — a system/backup
// device is refused by the agent regardless of what the controller sends. The mount name must be typed
// to match (type-to-confirm) — enforced both client-side (disabled button) and here (server-side).
func (s *Server) handleStorageWipe(w http.ResponseWriter, r *http.Request) {
	var req struct {
		Device    string `json:"device"`
		Where     string `json:"where"`
		MountName string `json:"mount_name"` // the typed confirmation (must equal the basename of Where)
		FSType    string `json:"fstype"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
		return
	}
	if req.Device == "" {
		writeDiskJSON(w, http.StatusBadRequest, false, "eszköz kötelező", nil)
		return
	}
	req.Where = path.Clean(strings.TrimSpace(req.Where))
	if req.Where == "" || req.Where == "." || !strings.HasPrefix(req.Where, "/mnt/") {
		writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen csatlakoztatási pont", nil)
		return
	}
	if s.refuseNetworkLifecycle(w, req.Where) {
		return
	}
	// Server-side type-to-confirm: the typed name must match the mount's basename exactly.
	if strings.TrimSpace(req.MountName) != path.Base(req.Where) {
		writeDiskJSON(w, http.StatusBadRequest, false, "a beírt név nem egyezik a csatlakoztatási névvel", nil)
		return
	}
	fstype := req.FSType
	if !validFSTypes[fstype] {
		fstype = "ext4"
	}
	agent, err := s.agentClient()
	if err != nil {
		writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
		return
	}
	// 1. Unmount (benign — frees the device so mkfs can run) + deregister the StoragePath. req.Where is
	// the STABLE registered path; the agent operates on the raw mount (agentWhere), the registry stores
	// the stable path (RemoveStoragePath gets req.Where).
	if _, eerr := agent.EjectDisk(r.Context(), agentWhere(req.Where)); eerr != nil {
		s.logger.Printf("[WARN] [web] wipe: eject %s failed (continuing to format): %v", req.Where, eerr)
	} else if rerr := s.settings.RemoveStoragePath(req.Where); rerr != nil {
		s.logger.Printf("[WARN] [web] wipe: deregister %s failed: %v", req.Where, rerr)
	} else {
		go s.SyncFileBrowserMounts()
	}
	// 2. Two-step customer-confirmed format: learn the agent's durable id (NeedsConfirmation), then
	// re-submit confirmed:true bound to it. The agent re-resolves + matches the durable id and
	// re-classifies the role — a protected device is refused here even though we send confirmed:true.
	probe, perr := agent.FormatDisk(r.Context(), req.Device, fstype, false, "")
	if errors.Is(perr, agentapi.ErrFormatRefused) {
		writeDiskJSON(w, http.StatusConflict, false, "a meghajtó védett (rendszer/biztonsági mentés) — törlés csak operátori aláírással", probe)
		return
	}
	if !errors.Is(perr, agentapi.ErrNeedsConfirmation) {
		if perr != nil {
			writeDiskJSON(w, http.StatusBadGateway, false, "törlés sikertelen: "+perr.Error(), nil)
			return
		}
		// Already blank (no confirmation needed) — the format the agent just ran is the wipe.
		writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"device": req.Device, "wiped": true})
		return
	}
	fr, ferr := agent.FormatDisk(r.Context(), req.Device, fstype, true, probe.DurableID)
	if ferr != nil {
		writeDiskJSON(w, http.StatusBadGateway, false, "törlés sikertelen: "+ferr.Error(), nil)
		return
	}
	writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"device": req.Device, "wiped": fr.Formatted, "durable_id": fr.DurableID})
}

// HandleServerReboot reboots the whole guest (server) as a deliberate maintenance action — the
// standalone "Kiszolgáló újraindítása" affordance, a sibling to the controller-only restart
// (/api/selfrestart). The agent reboots detached + returns 202; this controller restarts with the
// guest, so the caller's response may be cut short — the UI handles that and reloads after the
// restart window. (It reuses the agent GuestReboot primitive that previously backed the now-retired
// drive-activation banner; in the intermediary-mount model a drive binds live, so no reboot is needed
// to activate storage — this button is purely a full-server restart.)
func (s *Server) HandleServerReboot(w http.ResponseWriter, r *http.Request) {
	agent, err := s.agentClient()
	if err != nil {
		writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
		return
	}
	s.serverReboot(w, r, agent)
}

// serverReboot is the testable core of HandleServerReboot: invoke the agent's guest reboot and return
// the 202 envelope (or the agent error). Split out so it can be exercised with a fake diskAgent.
func (s *Server) serverReboot(w http.ResponseWriter, r *http.Request, agent diskAgent) {
	if err := agent.GuestReboot(r.Context()); err != nil {
		s.logger.Printf("[ERROR] [web] server reboot (guest restart) failed: %v", err)
		writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
		return
	}
	s.logger.Printf("[WARN] [web] full server (guest) restart requested by operator")
	writeDiskJSON(w, http.StatusAccepted, true, "", map[string]any{"rebooting": true})
}

// handleStorageRegister records an ALREADY-mounted, unregistered user-data drive into the StoragePath
// registry — no format, no eject. It is the natural primary action for a mounted-but-unregistered data
// drive (e.g. felhom-usb): the customer's intent is to USE the existing data, not wipe it. It reuses
// registerStoragePath (the manual-add path) — AddStoragePath dedupes, so a double-register is a clean
// error, not a duplicate.
func (s *Server) handleStorageRegister(w http.ResponseWriter, r *http.Request) {
	var req struct {
		Where      string `json:"where"`
		Label      string `json:"label"`
		SetDefault bool   `json:"set_default"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
		return
	}
	req.Where = path.Clean(strings.TrimSpace(req.Where))
	if req.Where == "" || req.Where == "." || !strings.HasPrefix(req.Where, "/mnt/") {
		writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen csatlakoztatási pont", nil)
		return
	}
	// req.Where is the RAW /mnt/<name> host mount the agent reports; in the intermediary model the drive
	// is actually live in the guest at the STABLE path /mnt/felhom-drives/<name>, so REGISTER the stable
	// path (matching runStorageInit/runStorageAttach). Registering the raw path made the controller watch
	// an empty rootfs placeholder → "Rendszermeghajtón" + stuck "activation pending" banner
	// (DIAGNOSE-drive-bind-after-reprovision-2026-06-23.md). attachIntoGuest still uses the RAW path —
	// the agent operates on raw.
	stable := stablePathForName(path.Base(req.Where))
	if err := s.registerStoragePath(stable, req.Label, req.SetDefault); err != nil {
		s.logger.Printf("[WARN] [web] storage register %s (stable %s) failed: %v", req.Where, stable, err)
		writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
		return
	}
	s.logger.Printf("[INFO] [web] storage path registered (existing mount): %s → %s", req.Where, stable)
	// Pass the drive into the guest too (slice 10 P2) — registering a host-only mount otherwise leaves
	// it guest-invisible (the exact gap that produced the "nem elérhető" banner). The agent operates on
	// the RAW path, so attach with req.Where (NOT the stable path).
	if agent, aerr := s.agentClient(); aerr == nil {
		s.attachIntoGuest(r.Context(), agent, req.Where)
	}
	writeDiskJSON(w, http.StatusOK, true, "", map[string]any{"registered": true, "where": stable, "raw": req.Where})
}

func (s *Server) handleStorageAttach(w http.ResponseWriter, r *http.Request) {
	var req storageProvReq
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
		return
	}
	where, err := mountWhere(req.MountName)
	if err != nil {
		writeDiskJSON(w, http.StatusBadRequest, false, err.Error(), nil)
		return
	}
	if req.Device == "" {
		writeDiskJSON(w, http.StatusBadRequest, false, "eszköz kötelező", nil)
		return
	}
	agent, err := s.agentClient()
	if err != nil {
		writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
		return
	}
	res, err := s.runStorageAttach(r.Context(), agent, req.Device, req.FSType, where, req.Label, req.SetDefault)
	if err != nil {
		writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
		return
	}
	writeDiskJSON(w, http.StatusOK, true, "", res)
}

// handleStorageEject unmounts a host mount (benign, data preserved) and DEREGISTERS its StoragePath.
// It surfaces the agent's dependent-guest warning. (Distinct from the signature-gated decommission.)
func (s *Server) handleStorageEject(w http.ResponseWriter, r *http.Request) {
	var req struct {
		Where string `json:"where"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen kérés", nil)
		return
	}
	req.Where = path.Clean(strings.TrimSpace(req.Where))
	if req.Where == "" || req.Where == "." || !strings.HasPrefix(req.Where, "/mnt/") {
		writeDiskJSON(w, http.StatusBadRequest, false, "érvénytelen csatlakoztatási pont", nil)
		return
	}
	if s.refuseNetworkLifecycle(w, req.Where) {
		return
	}
	agent, err := s.agentClient()
	if err != nil {
		writeDiskJSON(w, http.StatusServiceUnavailable, false, err.Error(), nil)
		return
	}
	// The registered path is the STABLE /mnt/felhom-drives/<name>; the agent operates on the raw mount.
	res, err := agent.EjectDisk(r.Context(), agentWhere(req.Where))
	if err != nil {
		writeDiskJSON(w, http.StatusBadGateway, false, err.Error(), nil)
		return
	}
	// Deregister the path (best-effort — the unmount already succeeded).
	if rerr := s.settings.RemoveStoragePath(req.Where); rerr != nil {
		s.logger.Printf("[WARN] [web] eject: unmounted %s but deregister failed: %v", req.Where, rerr)
	} else {
		go s.SyncFileBrowserMounts()
	}
	writeDiskJSON(w, http.StatusOK, true, "", res)
}
