package localapi

import (
	"context"
	"net/http"
	"strconv"
	"strings"
	"time"

	"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
	"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
)

// Disk management (slice 8C, doc 03 §6). The controller's disk-management UX stays in the
// controller; EXECUTION is the agent's. The security centerpiece: the agent decides
// data-bearing-ness by INSPECTING THE ACTUAL DEVICE (agent-internal evidence), never from the
// caller's claim — a compromised controller asserting "this drive is blank" cannot wipe a
// data-bearing drive. Benign ops (list/assign/eject/format-blank) execute self-serve; a
// data-bearing format is classified destructive → the gate refuses it `pending_signature` (the
// operator-signed completion is slice 10).

// DiskOps is the privileged host-storage surface the disk endpoints need. Satisfied by
// *storage.SudoHostOps. Optional — the endpoints report "not configured" when absent.
type DiskOps interface {
	EnsureMount(ctx context.Context, spec storage.MountSpec) error
	Unmount(ctx context.Context, where string) error
	Format(ctx context.Context, device, fstype string) error
	InspectDevice(ctx context.Context, device string) (storage.DeviceProbe, error)
}

// StorageGate authorizes a DESTRUCTIVE storage op (a data-bearing wipe/format) through the
// reversibility gate, TIERED by the agent's authoritative device-role verdict. Satisfied by an
// adapter over reconcile.Gate in main.go.
//   - user-data  → customer-confirmable: allowed iff the request carries the customer's confirmation
//     bound to the device's durable id (no operator signature).
//   - system/backup → operator-signature only (unsigned → pending_signature; Confirmed is ignored).
type StorageGate interface {
	AuthorizeWipe(req WipeRequest) WipeDecision
}

// WipeRequest is the inspection-derived input to the wipe gate. Role + DeviceDurableID are
// AGENT-INTERNAL (the agent classified the role and re-resolved the durable id); Confirmed +
// ConfirmDurableID are the caller's claim, honored ONLY for user-data and ONLY on a durable-id match.
type WipeRequest struct {
	Role             string // "system" | "backup" | "user-data" (agent-classified)
	DeviceDurableID  string // agent-re-resolved durable id of the device ("" if unresolvable)
	Confirmed        bool
	ConfirmDurableID string
}

// WipeDecision is the gate's verdict.
type WipeDecision struct {
	Allowed           bool
	Tier              string // "customer_confirmable" | "destructive"
	Reason            string // machine reason (audit/UI)
	NeedsConfirmation bool   // user-data, not-yet-confirmed → ask the customer (NOT a signature)
}

// GuestLister lists the host's guests (to map a mount to the guests that depend on it for the
// eject warning). Satisfied by *proxmox.Client.
type GuestLister interface {
	ListLXC(ctx context.Context) ([]proxmox.Guest, error)
}

// GuestAttacher binds an enrolled user-data drive's felhom-data namespace into a guest as an RW bind
// mount (slice 10 P2, Model A) and reboots the guest to activate persisted-but-inactive binds (the
// host-side live inject is blocked on unprivileged guests). Satisfied by *GuestBinder.
type GuestAttacher interface {
	AttachBind(ctx context.Context, vmid int, mountKey, where string) error
	RebootGuest(ctx context.Context, vmid int) error
}

// IntentRecorder persists drive enroll/eject INTENT (slice 10 P3 self-heal), keyed by durable-id, so
// the watchdog reconciles only enrolled drives and respects an official eject. Satisfied by
// *storage.IntentStore. Optional — when nil, the local API records no intent (self-heal is ungated).
type IntentRecorder interface {
	SetEnrolled(durableID string) error
	SetEjected(durableID string) error
}

// ---- handlers ---------------------------------------------------------------------------

// DiskInfo is one host drive with its data-bearing flag (for the UI).
type DiskInfo struct {
	Name          string `json:"name"`           // PVE storage id
	Type          string `json:"type"`           // local-dir | usb | lvmthin | …
	State         string `json:"state"`          // attached | disconnected
	BackingDevice string `json:"backing_device"` // /dev/sdb1, … ("" for network/lvm)
	MountPath     string `json:"mount_path"`
	Class         string `json:"class"`        // fast | slow | ""
	// Role is the agent's AUTHORITATIVE protection tier (system | backup | user-data), derived from
	// the agent's own storage view + host topology — never from the controller. The controller drives
	// the UI from it: system/backup get a lock badge and NO destructive controls; user-data is
	// customer-manageable. Defense in depth — the agent re-enforces role at wipe time regardless.
	Role          string `json:"role"`
	DataBearing   bool   `json:"data_bearing"` // agent device-inspection verdict (UI hint)
	DataReason    string `json:"data_reason,omitempty"`
	// Capacity (from the agent's storage view) — for the controller's capacity bar. 0 when unknown.
	TotalBytes   int64   `json:"total_bytes"`
	UsedBytes    int64   `json:"used_bytes"`
	UsedFraction float64 `json:"used_fraction"`
	// DurableID is the target's stable identity (e.g. "uuid:<fs-uuid>" for usb/local-dir). The
	// controller strips the "uuid:" prefix to get the fs UUID it passes to POST /disks/assign —
	// the only way the (de-privileged) controller can learn the mount key it cannot read itself.
	DurableID string `json:"durable_id,omitempty"`
}

// handleDisks lists the host's drives + data-bearing flags (read-only/benign).
func (s *Server) handleDisks(w http.ResponseWriter, r *http.Request, vmid int) {
	if s.disks == nil {
		writeErr(w, http.StatusServiceUnavailable, "disk management not configured on this host")
		return
	}
	targets, err := s.storage.Observe(r.Context())
	if err != nil {
		writeErr(w, http.StatusBadGateway, "could not read storage view")
		return
	}
	// Resolve the OS/system disks ONCE for this request — role classification is agent-authoritative
	// (the agent's own mount/topology read, never the caller's claim).
	sysDisks, sysKnown := storage.SystemDisks(s.hostReader())
	out := make([]DiskInfo, 0, len(targets))
	for _, t := range targets {
		di := DiskInfo{
			Name: t.Name, Type: t.Type, State: t.State,
			BackingDevice: t.BackingDevice, MountPath: t.MountPath, Class: t.ClassHint,
			DurableID:    t.DurableID,
			Role:         string(storage.RoleForStorage(t.Type, t.BackingDevice, sysDisks, sysKnown)),
			TotalBytes:   t.TotalBytes,
			UsedBytes:    t.UsedBytes,
			UsedFraction: t.UsedFraction,
		}
		// Inspect the backing device for the UI's data-bearing hint (the authoritative check
		// is re-run at format time on the actual device).
		if t.BackingDevice != "" {
			if probe, perr := s.disks.InspectDevice(r.Context(), t.BackingDevice); perr == nil {
				di.DataBearing = probe.DataBearing()
				di.DataReason = probe.Reason()
			} else {
				di.DataBearing = true // fail-safe
				di.DataReason = "could not inspect device"
			}
		}
		out = append(out, di)
	}
	writeOK(w, map[string]any{"vmid": vmid, "disks": out})
}

type assignRequest struct {
	VMID    int    `json:"vmid"`
	UUID    string `json:"uuid"`
	Where   string `json:"where"`
	FSType  string `json:"fstype"`
	Options string `json:"options"`
}

// handleDiskAssign attaches a drive as a host mount (benign, additive → EnsureMount). Self-serve.
func (s *Server) handleDiskAssign(w http.ResponseWriter, r *http.Request, vmid int) {
	if s.disks == nil {
		writeErr(w, http.StatusServiceUnavailable, "disk management not configured on this host")
		return
	}
	var req assignRequest
	if !decodeBody(w, r, &req) {
		return
	}
	if !s.scopedFromBody(w, req.VMID, vmid, r.URL.Path) {
		return
	}
	// EnsureMount validates uuid/where/fstype/options itself (storage/validate.go).
	if err := s.disks.EnsureMount(r.Context(), storage.MountSpec{
		Name: req.UUID, UUID: req.UUID, Where: req.Where, FSType: req.FSType, Options: req.Options,
	}); err != nil {
		s.logger.Error("local-api: disk assign", "vmid", vmid, "where", req.Where, "err", err)
		writeErr(w, http.StatusBadRequest, "assign failed: "+err.Error())
		return
	}
	writeOK(w, map[string]any{"vmid": vmid, "assigned": req.Where})
}

type ejectRequest struct {
	VMID  int    `json:"vmid"`
	Where string `json:"where"`
}

// handleDiskEject safe-unmounts a host mount (benign — data preserved, re-attachable) and returns
// the guests that depend on it so the controller can warn which apps lose that storage.
func (s *Server) handleDiskEject(w http.ResponseWriter, r *http.Request, vmid int) {
	if s.disks == nil {
		writeErr(w, http.StatusServiceUnavailable, "disk management not configured on this host")
		return
	}
	var req ejectRequest
	if !decodeBody(w, r, &req) {
		return
	}
	if !s.scopedFromBody(w, req.VMID, vmid, r.URL.Path) {
		return
	}
	if strings.TrimSpace(req.Where) == "" {
		writeErr(w, http.StatusBadRequest, "where (mountpoint) is required")
		return
	}
	// ROLE GATE (defense in depth): eject is permitted ONLY for a user-data mount. The agent
	// classifies the role of the storage at `where` from its OWN view — never the caller's claim —
	// and refuses system/backup. The UI hiding the button is NOT the control: a direct API call (or a
	// compromised controller) trying to unmount /var/lib/vz or the PBS mount is refused here. Fails
	// SAFE: an unresolvable mount → protected → refused (most-protected-on-ambiguity, like the wipe).
	if role := s.roleForMountPath(r.Context(), req.Where); role != storage.RoleUserData {
		s.logger.Warn("local-api: protected — eject refused by role",
			"vmid", vmid, "where", req.Where, "role", role)
		writeErr(w, http.StatusForbidden, "mount is system/backup-protected — eject refused (role: "+string(role)+")")
		return
	}
	dependents := s.dependentGuests(r.Context(), req.Where)
	// Record the EJECT intent BEFORE unmounting (the target still resolves to its durable-id) so the
	// self-heal watchdog leaves it alone — an OFFICIAL eject is the only thing that sets this (P3); an
	// out-of-band unmount records nothing and is healed.
	s.recordIntent(r.Context(), req.Where, "ejected")
	if err := s.disks.Unmount(r.Context(), req.Where); err != nil {
		s.logger.Error("local-api: disk eject", "vmid", vmid, "where", req.Where, "err", err)
		writeErr(w, http.StatusBadRequest, "eject failed: "+err.Error())
		return
	}
	writeOK(w, map[string]any{"vmid": vmid, "ejected": req.Where, "dependent_guests": dependents})
}

type guestAttachRequest struct {
	VMID  int    `json:"vmid"`
	Where string `json:"where"` // the host mount path of the enrolled drive (e.g. /mnt/felhom-usb)
}

// handleDiskGuestAttach binds an enrolled user-data drive's felhom-data namespace into THIS guest as
// an RW bind mount (slice 10 P2, Model A). Self-scoped (the vmid is the token's). Idempotent: if a
// mountpoint already binds `where`, it returns the existing slot without re-attaching. The drive must
// already be mounted on the host at `where` (the enroll flow's assign did that) — this only adds the
// guest passthrough. The customer's non-felhom data on the drive is NOT exposed (only felhom-data).
func (s *Server) handleDiskGuestAttach(w http.ResponseWriter, r *http.Request, vmid int) {
	if s.guestAttach == nil {
		writeErr(w, http.StatusServiceUnavailable, "guest passthrough not configured on this host")
		return
	}
	var req guestAttachRequest
	if !decodeBody(w, r, &req) {
		return
	}
	if !s.scopedFromBody(w, req.VMID, vmid, r.URL.Path) {
		return
	}
	where := strings.TrimSpace(req.Where)
	if !validGuestMountPath(where) {
		writeErr(w, http.StatusBadRequest, "where must be an absolute /mnt/<name> path (no traversal)")
		return
	}
	// Read the guest config for idempotency + free-slot selection.
	cfg, err := s.guests.GuestConfig(r.Context(), vmid)
	if err != nil {
		s.logger.Error("local-api: guest-attach guest config", "vmid", vmid, "err", err)
		writeErr(w, http.StatusBadGateway, "could not read guest config")
		return
	}
	mounts := cfg.MountPoints()
	// Idempotency: already bound at `where`? (a bind's mp= equals the guest path). Still (re)record the
	// ENROLLED intent — a drive bound at a prior boot must be known to self-heal even if never
	// re-attached this process lifetime.
	for key, spec := range mounts {
		if _, mp, _ := parseMount(spec); mp == where {
			s.recordIntent(r.Context(), where, "enrolled")
			s.logger.Info("local-api: guest-attach idempotent (already bound)", "vmid", vmid, "where", where, "slot", key)
			writeOK(w, map[string]any{"vmid": vmid, "attached": where, "slot": key, "already": true})
			return
		}
	}
	slot, ok := freeMountSlot(mounts)
	if !ok {
		writeErr(w, http.StatusConflict, "no free mountpoint slot on the guest")
		return
	}
	if err := s.guestAttach.AttachBind(r.Context(), vmid, slot, where); err != nil {
		s.logger.Error("local-api: guest-attach", "vmid", vmid, "where", where, "slot", slot, "err", err)
		writeErr(w, http.StatusBadGateway, "guest-attach failed: "+err.Error())
		return
	}
	// Record the drive as ENROLLED so the self-heal watchdog will reconcile it (P3).
	s.recordIntent(r.Context(), where, "enrolled")
	writeOK(w, map[string]any{"vmid": vmid, "attached": where, "slot": slot})
}

type guestRebootRequest struct {
	VMID int `json:"vmid"`
}

// handleGuestReboot reboots THIS guest (self-scoped) to activate persisted-but-inactive mountpoint
// binds (slice 10 P2 activation). It runs the reboot DETACHED and returns 202 immediately, so the
// calling controller gets a clean response before the reboot takes it (and the agent — host-side —
// survives the guest reboot). User-triggered ("Újraindítás most"); the controller batches all pending
// drives into one restart.
func (s *Server) handleGuestReboot(w http.ResponseWriter, r *http.Request, vmid int) {
	if s.guestAttach == nil {
		writeErr(w, http.StatusServiceUnavailable, "guest passthrough not configured on this host")
		return
	}
	if r.ContentLength != 0 {
		var req guestRebootRequest
		if !decodeBody(w, r, &req) {
			return
		}
		if !s.scopedFromBody(w, req.VMID, vmid, r.URL.Path) {
			return
		}
	}
	base := s.baseCtx
	if base == nil {
		base = context.Background()
	}
	go func() {
		// Detached: pct reboot blocks ~30s until the guest is back; don't tie it to the request ctx.
		rebootCtx, cancel := context.WithTimeout(base, 5*time.Minute)
		defer cancel()
		if err := s.guestAttach.RebootGuest(rebootCtx, vmid); err != nil {
			s.logger.Error("local-api: guest-reboot failed", "vmid", vmid, "err", err)
		}
	}()
	s.logger.Warn("local-api: guest reboot requested (activating pending drive binds)", "vmid", vmid)
	writeStatus(w, http.StatusAccepted, true, map[string]any{"vmid": vmid, "rebooting": true}, "")
}

// validGuestMountPath accepts an absolute /mnt/<name> path with no traversal (the enroll convention
// root). Mirrors the controller's mount-name discipline so a hostile `where` can't escape /mnt.
func validGuestMountPath(p string) bool {
	if !strings.HasPrefix(p, "/mnt/") || strings.Contains(p, "..") {
		return false
	}
	rest := strings.TrimPrefix(p, "/mnt/")
	if rest == "" || strings.ContainsAny(rest, "/ \t") {
		return false // exactly one path component under /mnt
	}
	for _, c := range rest {
		if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-' {
			continue
		}
		return false
	}
	return true
}

// freeMountSlot returns the lowest mpN (0..255) not present in the guest's current mountpoints. The
// bootstrap mount (mp9) and any existing data mounts are already in `mounts`, so they're skipped.
func freeMountSlot(mounts map[string]string) (string, bool) {
	for i := 0; i <= 255; i++ {
		key := "mp" + strconv.Itoa(i)
		if _, used := mounts[key]; !used {
			return key, true
		}
	}
	return "", false
}

type formatRequest struct {
	VMID   int    `json:"vmid"`
	Device string `json:"device"`
	FSType string `json:"fstype"`
	// Confirmed + DurableID authorize a USER-DATA data-bearing wipe by the customer's informed-
	// confirmation bound to the device's durable id. The agent RE-RESOLVES the device's durable id
	// and matches it against DurableID — a confirmation for one disk can't wipe another. Both are
	// INERT for system/backup devices (those stay operator-signature only — the role is the agent's,
	// never the caller's, so confirmed:true on a system device is refused).
	Confirmed bool   `json:"confirmed"`
	DurableID string `json:"durable_id"`
	// NOTE: any caller-supplied "blank"/"force" claim is still deliberately IGNORED — the agent
	// inspects the device itself (8C invariant).
}

// FormatResponse is POST /disks/format. On a data-bearing refusal (slice 10B) it SURFACES the
// bound op the operator must sign: the op class + the DURABLE device id (not the mutable path) +
// the fstype — so the operator can `felhom-opsign -op storage_wipe -durable-id <…>` offline, the
// hub queues it, and the agent's signed-jobs runner verifies + executes the wipe (re-resolving the
// durable id). This is the "records/reports the bound op intent so the operator sees what to sign".
type FormatResponse struct {
	VMID        int    `json:"vmid"`
	Device      string `json:"device"`
	Formatted   bool   `json:"formatted"`
	DataBearing bool   `json:"data_bearing"`
	Reason      string `json:"reason"`
	// Role is the agent's authoritative tier for the device (system | backup | user-data).
	Role string `json:"role,omitempty"`
	// NeedsConfirmation is set on a USER-DATA data-bearing refusal: the customer must re-submit with
	// confirmed:true + DurableID (below) after the controller's type-to-confirm UI. NOT an operator
	// signature — the customer authorizes the wipe of their own data drive.
	NeedsConfirmation bool   `json:"needs_confirmation,omitempty"`
	DurableID         string `json:"durable_id,omitempty"` // the durable id to confirm against (user-data)
	// PendingOp is set on a SYSTEM/BACKUP data-bearing refusal — the exact op the operator must sign.
	PendingOp *PendingOp `json:"pending_op,omitempty"`
}

// PendingOp is the bound destructive intent the operator must sign offline (slice 10B). Params bind
// to the DURABLE device id so the signed authorization can't be retargeted to another disk.
type PendingOp struct {
	Op        string `json:"op"`         // e.g. "storage_wipe"
	HostScope string `json:"host_scope"` // the agent's host id (anti-retarget target)
	DurableID string `json:"durable_id"` // byid:…|byuuid:… — the device's stable identity
	FSType    string `json:"fstype"`     // the filesystem to mkfs after the wipe
}

// handleDiskFormat is the security centerpiece. The agent INSPECTS the device; if it is
// data-bearing it is classified destructive and the gate refuses it `pending_signature` — the
// caller's claim is never trusted. Only a device the agent itself reads as blank is formatted.
func (s *Server) handleDiskFormat(w http.ResponseWriter, r *http.Request, vmid int) {
	if s.disks == nil || s.diskGate == nil {
		writeErr(w, http.StatusServiceUnavailable, "disk management not configured on this host")
		return
	}
	var req formatRequest
	if !decodeBody(w, r, &req) {
		return
	}
	if !s.scopedFromBody(w, req.VMID, vmid, r.URL.Path) {
		return
	}
	if err := storage.ValidateBlockDevice(req.Device); err != nil {
		writeErr(w, http.StatusBadRequest, err.Error())
		return
	}
	if err := storage.ValidateFSType(req.FSType); err != nil {
		writeErr(w, http.StatusBadRequest, err.Error())
		return
	}

	// AGENT-INTERNAL device inspection — NEVER the caller's claim.
	probe, err := s.disks.InspectDevice(r.Context(), req.Device)
	if err != nil {
		s.logger.Error("local-api: format device inspect", "device", req.Device, "err", err)
		// inspect error → fail-safe data-bearing (probe.DataBearing() is true on !Probed)
	}
	if !probe.DataBearing() {
		// Blank device → benign → mkfs (role is irrelevant; there is nothing to destroy).
		if err := s.disks.Format(r.Context(), req.Device, req.FSType); err != nil {
			s.logger.Error("local-api: format", "vmid", vmid, "device", req.Device, "err", err)
			writeErr(w, http.StatusBadGateway, "format failed: "+err.Error())
			return
		}
		writeOK(w, FormatResponse{VMID: vmid, Device: req.Device, Formatted: true, DataBearing: false, Reason: "blank device formatted " + req.FSType})
		return
	}

	// Data-bearing → TIER by the agent's authoritative role classification (its own inspection,
	// never the caller's claim). The agent also re-resolves the device's durable id; the customer's
	// confirmation must bind to it.
	role := s.deviceRole(r.Context(), req.Device)
	deviceDurable, derr := storage.DeviceDurableID(req.Device)
	if derr != nil {
		deviceDurable = "" // refusal still stands; binding/pending-op just lack the id
	}
	dec := s.diskGate.AuthorizeWipe(WipeRequest{
		Role: string(role), DeviceDurableID: deviceDurable,
		Confirmed: req.Confirmed, ConfirmDurableID: req.DurableID,
	})

	if dec.Allowed {
		// USER-DATA, customer-confirmed (durable-id-bound). The gate already AUDITED it. Wipe.
		if err := s.disks.Format(r.Context(), req.Device, req.FSType); err != nil {
			s.logger.Error("local-api: customer-confirmed format", "vmid", vmid, "device", req.Device, "err", err)
			writeErr(w, http.StatusBadGateway, "format failed: "+err.Error())
			return
		}
		s.logger.Warn("local-api: USER-DATA data-bearing format — CUSTOMER CONFIRMED (no operator signature)",
			"vmid", vmid, "device", req.Device, "durable_id", deviceDurable, "fstype", req.FSType, "why", probe.Reason())
		writeOK(w, FormatResponse{VMID: vmid, Device: req.Device, Formatted: true, DataBearing: true,
			Role: string(role), DurableID: deviceDurable, Reason: "customer-confirmed wipe (" + probe.Reason() + ")"})
		return
	}

	if dec.Tier == "customer_confirmable" {
		// USER-DATA refusal: either awaiting the customer's confirmation, or the confirmation didn't
		// bind to THIS device. Surface the durable id to confirm against — NOT an operator signature.
		msg := "device is data-bearing — customer confirmation required"
		if !dec.NeedsConfirmation {
			msg = "confirmation does not match this device — refused (" + dec.Reason + ")"
		}
		s.logger.Warn("local-api: user-data data-bearing format refused",
			"vmid", vmid, "device", req.Device, "durable_id", deviceDurable, "reason", dec.Reason, "why", probe.Reason())
		writeStatus(w, http.StatusForbidden, false,
			FormatResponse{VMID: vmid, Device: req.Device, Formatted: false, DataBearing: true, Role: string(role),
				NeedsConfirmation: dec.NeedsConfirmation, DurableID: deviceDurable, Reason: probe.Reason()}, msg)
		return
	}

	// SYSTEM / BACKUP refusal: operator signature required. Surface the bound op to sign (the durable
	// id binds the signed wipe to THIS exact physical disk). confirmed:true was ignored — by role.
	var pending *PendingOp
	if deviceDurable != "" {
		pending = &PendingOp{Op: "storage_wipe", HostScope: s.hostID, DurableID: deviceDurable, FSType: req.FSType}
		s.logger.Warn("local-api: protected (system/backup) data-bearing format refused — PENDING OPERATOR SIGNATURE",
			"vmid", vmid, "device", req.Device, "role", role, "durable_id", deviceDurable, "fstype", req.FSType,
			"why", probe.Reason(), "to_authorize", "felhom-opsign -op storage_wipe -host "+s.hostID+" -durable-id "+deviceDurable)
	} else {
		s.logger.Warn("local-api: protected data-bearing format refused (no durable id)",
			"vmid", vmid, "device", req.Device, "role", role, "why", probe.Reason())
	}
	writeStatus(w, http.StatusForbidden, false,
		FormatResponse{VMID: vmid, Device: req.Device, Formatted: false, DataBearing: true, Role: string(role), PendingOp: pending, Reason: probe.Reason()},
		"device is system/backup-protected — format requires an operator signature ("+dec.Reason+")")
}

// durableIDForMount resolves the durable-id of the storage mounted at `where` (from the agent's own
// storage view) — the key the intent store records enroll/eject against. "" if not resolvable.
func (s *Server) durableIDForMount(ctx context.Context, where string) string {
	targets, err := s.storage.Observe(ctx)
	if err != nil {
		return ""
	}
	for _, t := range targets {
		if t.MountPath == where {
			return t.DurableID
		}
	}
	return ""
}

// recordIntent records enroll/eject intent for the drive at `where`, best-effort (a nil store, an
// unresolved durable-id, or a write error is logged, never fatal — intent is a self-heal aid, not a
// gate on the user's action). `action` is "enrolled" or "ejected".
func (s *Server) recordIntent(ctx context.Context, where, action string) {
	if s.intent == nil {
		return
	}
	id := s.durableIDForMount(ctx, where)
	if id == "" {
		s.logger.Warn("local-api: intent not recorded — durable-id unresolved", "where", where, "action", action)
		return
	}
	var err error
	switch action {
	case "enrolled":
		err = s.intent.SetEnrolled(id)
	case "ejected":
		err = s.intent.SetEjected(id)
	}
	if err != nil {
		s.logger.Warn("local-api: intent record failed", "where", where, "action", action, "durable_id", id, "err", err)
		return
	}
	s.logger.Info("local-api: drive intent recorded", "where", where, "action", action, "durable_id", id)
}

// hostReader returns the injected root-free host topology reader, or the production default. The seam
// keeps the role classification (SystemDisks) testable without touching the real /proc /dev /sys.
func (s *Server) hostReader() storage.HostReader {
	if s.host != nil {
		return s.host
	}
	return storage.NewProcHostReader()
}

// roleForMountPath resolves the AUTHORITATIVE protection role of the storage mounted at `where`, from
// the agent's OWN storage view + host topology (never the caller's claim). It mirrors deviceRole but
// keys on the mount path (the eject input). It FAILS SAFE to system (most-protected) on any
// ambiguity — a view error, or no storage target found at `where` — so an unresolvable eject is
// refused rather than silently unmounted.
func (s *Server) roleForMountPath(ctx context.Context, where string) storage.DeviceRole {
	sysDisks, sysKnown := storage.SystemDisks(s.hostReader())
	targets, err := s.storage.Observe(ctx)
	if err != nil {
		return storage.RoleSystem // can't read the view → treat as protected
	}
	for _, t := range targets {
		if t.MountPath == where {
			return storage.RoleForStorage(t.Type, t.BackingDevice, sysDisks, sysKnown)
		}
	}
	return storage.RoleSystem // no storage target at this mount → fail safe to protected
}

// deviceRole resolves a device's AUTHORITATIVE protection tier. It prefers a known storage target's
// role (so a PBS-backed device is recognized as backup), falling back to a raw-device classification
// (for a fresh disk not yet a PVE storage — the init flow). Defaults to system on ambiguity.
func (s *Server) deviceRole(ctx context.Context, device string) storage.DeviceRole {
	sysDisks, sysKnown := storage.SystemDisks(s.hostReader())
	if targets, err := s.storage.Observe(ctx); err == nil {
		for _, t := range targets {
			if t.BackingDevice != "" && t.BackingDevice == device {
				return storage.RoleForStorage(t.Type, t.BackingDevice, sysDisks, sysKnown)
			}
		}
	}
	return storage.RoleForRawDevice(device, sysDisks, sysKnown)
}

// dependentGuests returns the VMIDs whose config has a mount whose storage backs the ejected
// mount path — best-effort (a scan failure yields an empty list; the eject still proceeds).
func (s *Server) dependentGuests(ctx context.Context, where string) []int {
	if s.guestList == nil {
		return nil
	}
	guests, err := s.guestList.ListLXC(ctx)
	if err != nil {
		s.logger.Warn("local-api: eject dependent-scan: list guests", "err", err)
		return nil
	}
	// Map each storage id whose mount path == `where` (from the storage view) → dependents.
	storeForPath := map[string]bool{}
	if targets, err := s.storage.Observe(ctx); err == nil {
		for _, t := range targets {
			if t.MountPath == where {
				storeForPath[t.Name] = true
			}
		}
	}
	var out []int
	for _, g := range guests {
		cfg, err := s.guests.GuestConfig(ctx, g.VMID)
		if err != nil {
			continue
		}
		for _, mp := range cfg.MountPoints() {
			store, mpPath, _ := parseMount(mp)
			if storeForPath[store] || mpPath == where {
				out = append(out, g.VMID)
				break
			}
		}
	}
	return out
}
