// Package guesthook is the LXC guest pre-start self-heal (C1 net, transitional).
//
// THE BUG IT FIXES (C1, B3 audit): in the per-drive bind model an external data drive is bound into the
// guest as `pct set <vmid> -mpN <drive>/felhom-data,mp=/mnt/<name>`. When that drive is ABSENT at guest
// boot, the bind SOURCE `<drive>/felhom-data` does not exist, `pct start` fails the mount, and the guest
// BRICKS (pre-start exit 255 — ALL apps down). Today nothing recovers it.
//
// THE FIX: a PVE `pre-start` hookscript runs this code; for every BIND mountpoint whose source path is
// missing it CREATES an empty, host-root-owned placeholder directory so the mount succeeds and the guest
// boots. It is fail-closed: the placeholder is owned by host root (uid 0), which is UNMAPPED in the
// unprivileged-LXC user namespace, so the in-guest controller/apps (even as guest-root) cannot write to
// it — and a returning drive simply shadows it (the agent mounts over it).
//
// WHY CREATE, NOT DELETE: removing the dead mp would need `pct set <vmid> --delete mpN`, which takes the
// per-guest config lock the start task ALREADY holds → it dead-times-out (~10s) and the guest still
// bricks. So in pre-start we NEUTRALISE (placeholder) rather than mutate config. Proper mp removal runs
// OUTSIDE the start lock — at decommission (handleDiskDecommission → DetachBind) and the startup
// reconcile. The intermediary-mount re-architecture later makes C1 STRUCTURAL (the only bind source is
// the permanent, always-present /mnt/felhom-drives parent), after which this hook is pure defense-in-depth.
package guesthook

import (
	"fmt"
	"os"
	"path/filepath"
	"sort"
	"strings"
)

// PhasePreStart is the PVE hook phase at which we self-heal (before the container mounts are set up).
const PhasePreStart = "pre-start"

// PhasePostStart is the PVE hook phase after the container started — the NAS automount reassert
// point (the fresh guest namespace has no idle autofs triggers; see netreassert.go).
const PhasePostStart = "post-start"

// placeholderMode is the mode for a created bind-source placeholder. Host-root-owned + this mode =
// fail-closed against the unprivileged guest (host uid 0 is unmapped in the guest userns).
const placeholderMode = 0o755

// ParseConfMounts parses an LXC config file body (/etc/pve/lxc/<vmid>.conf) and returns each mount key
// (`mp0`..`mp255`, plus `rootfs`) mapped to its SOURCE — the first comma-field of the value, before any
// `mp=`/`size=`/`backup=` options. A BIND mount has an absolute-path source (`/mnt/...`); a storage
// volume has a `<storage>:<volid>` source (no leading slash). Lines that aren't a mountpoint/rootfs key
// are ignored.
func ParseConfMounts(conf string) map[string]string {
	out := map[string]string{}
	for _, line := range strings.Split(conf, "\n") {
		line = strings.TrimSpace(line)
		colon := strings.IndexByte(line, ':')
		if colon <= 0 {
			continue
		}
		key := line[:colon]
		if key != "rootfs" && !(strings.HasPrefix(key, "mp") && isAllDigits(strings.TrimPrefix(key, "mp"))) {
			continue
		}
		val := strings.TrimSpace(line[colon+1:])
		if val == "" {
			continue
		}
		src := val
		if c := strings.IndexByte(val, ','); c >= 0 {
			src = val[:c]
		}
		out[key] = strings.TrimSpace(src)
	}
	return out
}

// isBindSource reports whether an mp source is a host-path BIND (an absolute path) rather than a PVE
// storage volume (`<storage>:<volid>`, never absolute). On the Linux host a bind source is `/mnt/...`
// (leading slash); the filepath.IsAbs arm additionally recognises an OS-absolute path so the real-IO
// tests pass under a Windows temp dir too — on Linux both arms agree and a storage volid matches neither.
func isBindSource(src string) bool {
	return strings.HasPrefix(src, "/") || filepath.IsAbs(src)
}

func isAllDigits(s string) bool {
	if s == "" {
		return false
	}
	for _, c := range s {
		if c < '0' || c > '9' {
			return false
		}
	}
	return true
}

// MissingBindSources returns the BIND-mount source paths (absolute host paths) that do NOT exist, sorted
// and de-duplicated. Storage-volume sources (`<storage>:<volid>`, no leading '/') are NEVER returned —
// only a real host-path bind can have a vanished source we must heal; a storage volume that's missing is
// PVE's own concern, not ours to mkdir. `exists` reports whether a path is present (injected for tests).
func MissingBindSources(mounts map[string]string, exists func(string) bool) []string {
	seen := map[string]bool{}
	var miss []string
	for _, src := range mounts {
		if !isBindSource(src) { // storage volume (<storage>:<volid>), not a host-path bind — never touch
			continue
		}
		if seen[src] || exists(src) {
			continue
		}
		seen[src] = true
		miss = append(miss, src)
	}
	sort.Strings(miss)
	return miss
}

// Heal reads the LXC config at confPath and creates a placeholder directory for every bind-mount source
// that is missing, returning the list of paths it created. It never returns a fatal error for an
// unreadable/empty config (a guest with no config simply has nothing to heal) — the hook must NEVER block
// a start. A mkdir failure on one path is collected into err but the others still proceed.
func Heal(confPath string) (created []string, err error) {
	data, readErr := os.ReadFile(confPath)
	if readErr != nil {
		// No config = nothing to heal. Never block the start over a read error.
		return nil, nil
	}
	mounts := ParseConfMounts(string(data))
	miss := MissingBindSources(mounts, func(p string) bool {
		_, statErr := os.Stat(p)
		return statErr == nil
	})
	var errs []string
	for _, p := range miss {
		if mkErr := os.MkdirAll(p, placeholderMode); mkErr != nil {
			errs = append(errs, fmt.Sprintf("%s: %v", p, mkErr))
			continue
		}
		created = append(created, p)
	}
	if len(errs) > 0 {
		return created, fmt.Errorf("guesthook: placeholder creation failed for: %s", strings.Join(errs, "; "))
	}
	return created, nil
}
