// Package bootrecon implements R-52: the bounded, start-ONCE recovery of apps that were left
// behind by an interrupted boot.
//
// The live failure it closes (AUDIT-vacation-remote-ops-2026-07-20, finding F5): a pre-transport
// shutdown left `immich-server` and `calibre-web` Exited; ten sibling containers came back and
// those two did not, and they were still down ~18 hours later. The controller REPORTED them (the
// 30 s deadapp-check) but never started them — deployed-but-stopped was an alarm with no recovery.
//
// Two deliberate boundaries, both load-bearing:
//
//   - **Bounded, never a loop.** At most `attempts` tries, `retryDelay` apart, then it stops and the
//     alarm owns the problem. A restart loop would paper over a genuinely broken app forever and
//     hammer docker while doing it.
//   - **A user's Stop survives a reboot.** This is still the whole safety argument; only the way it
//     is established changed. Until v0.189.0 it was inferred — the UI's Stop is `docker compose
//     down`, which REMOVES containers, so "zero containers" was read as "the customer stopped it"
//     and left alone. Since v0.189.0 (R-166) the customer's intent is RECORDED in app.yaml and read
//     directly, because the inference could not distinguish a deliberate Stop from a power cut or an
//     interrupted backup, and silently stranded both. An app.yaml with no recorded intent — every
//     app on every box predating the field — keeps the old inference exactly. See isBootOrphan,
//     TestReconcile_UserStoppedAppIsNeverStarted and
//     TestReconcile_LegacyNoDesiredState_BehavesExactlyAsBefore.
//
// It runs inside the notifier's boot grace (cmd/controller/main.go `deadAppBootGrace`), so a
// successful recovery never fires an alert and a failed one alerts honestly once the grace expires.
package bootrecon

import (
	"context"
	"log"
	"time"

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

// StackProvider is the slice of *stacks.Manager this package needs. Declared consumer-side so the
// tests can count StartStack calls without a docker anywhere near them.
type StackProvider interface {
	GetStacks() []stacks.Stack
	StartStack(name string) error
	RefreshStatus() error
}

// StartGate answers the one question this package must ask before starting anything: **may this app
// be started right now?** Declared consumer-side, in the style of StackProvider, so `bootrecon`
// still imports `stacks` alone and knows nothing about settings, the agent, quiesce or the web layer.
//
// It is ONE seam rather than three because the three reasons a boot orphan must NOT be started share
// a shape — something else is deliberately holding this app — and differ only in the reason string:
//
//	the drive-absent gate stopped it     → its drive is not live (R-171, below)
//	a quiesce is holding it for a backup → the quiesce loop restarts its own stacks
//	an app-data operation stopped it     → the app-stop guard's own Recover owns it
//
// A widened boot window (R-157 mechanism A) is what makes the last two reachable at all: the old
// T+5 s single sweep never overlapped them.
//
// R-171 — WHY THIS EXISTS, and it is a regression this package caused. Until v0.189.0 the sweep
// required a stack to still HAVE containers, and an app the drive-absent gate had stopped has zero,
// so such apps were skipped by accident. v0.189.0 replaced that term with the customer's recorded
// intent — correctly — and the drive gate does NOT change `desired_state` (it is not the customer),
// so a gate-stopped app now reads as `running` + zero containers, i.e. a boot orphan. Observed live
// on 2026-08-02: the sweep found and started an app whose drive was unmounted, burned both attempts,
// and handed it to the dead-app alarm — a false alarm about an app the drive gate is deliberately
// holding (audits/DIAG-bootrecon-drive-absent-2026-08-02.md).
//
// The rule itself is not new and is not invented here: the API's own start path already refuses this
// (`startGatedByMissingDrive`, internal/api/router.go) with a Hungarian message to the customer. The
// sweep simply bypassed it by calling Manager.StartStack directly. This seam gives the sweep the
// same question to ask.
//
// CONTRACT — the answer is fail-safe by design (§8.4): an implementation that CANNOT DETERMINE
// whether the drive is live must return false, not true. Not starting is recoverable — the drive
// gate's `Return` branch restarts the app when the drive comes back, and the dead-app alarm reports
// it meanwhile. Starting on an absent drive is not recoverable by anything automatic: compose
// creates the bind sources wherever the mountpoint currently points, which is the guest rootfs.
type StartGate interface {
	// MayStart reports whether the named stack may be started. The reason is for the log line and is
	// only read when may is false.
	MayStart(stackName string) (may bool, reason string)
}

const (
	// DefaultAttempts is the total number of start attempts per boot (not per app per retry-forever).
	DefaultAttempts = 2
	// DefaultRetryDelay spaces the attempts. 2 × 30 s fits comfortably inside the 90 s boot grace,
	// so a recovery that works is silent and one that does not is honest.
	DefaultRetryDelay = 30 * time.Second
)

// Reconciler performs the start-once sweep. Zero value is not usable — use New.
type Reconciler struct {
	stacks     StackProvider
	logger     *log.Logger
	attempts   int
	retryDelay time.Duration

	// sleep is the inter-attempt wait; injectable so tests never spend 30 real seconds.
	sleep func(context.Context, time.Duration)

	// startGate (R-171) refuses to start an app something else is deliberately holding. nil = NOT
	// WIRED, which means "this caller has no such concept" and is permissive — the test fixtures'
	// case. It is NOT the same as "cannot determine", which the gate itself answers with false (see
	// StartGate's contract). Production MUST wire it; TestMainWiresBootDriveGate walks main.go's AST
	// for the call, because an unwired seam here is silently the pre-v0.190.0 behaviour.
	startGate StartGate
}

// Result is the outcome, returned for logging/testing (the hub learns about failures only through
// the existing app_start_failed alarm — this package deliberately pushes no events of its own).
type Result struct {
	Candidates []string // boot-orphaned apps found
	Recovered  []string // running again by the end
	StillDown  []string // still down after the last attempt — the alarm's problem now
	Attempts   int      // attempts actually made (0 when there was nothing to do)
	// HeldByDrive (R-171) are apps that ARE boot orphans by intent but which something else is
	// deliberately holding (an absent drive, a quiesce, an app-data operation), so they were not
	// started. Reported separately from StillDown because they are not a fault this sweep failed to
	// fix — the holder owns their recovery. Collapsing the two would put a deliberately-held app in
	// the same bucket as a broken one, which is the false alarm R-171 removes.
	HeldByDrive []string
}

// SetDriveGate wires the R-171 start refusal. INIT-ONLY — call once, before Run.
func (r *Reconciler) SetDriveGate(g StartGate) { r.startGate = g }

// mayStart asks the gate, or allows when none is wired (see the startGate field comment).
func (r *Reconciler) mayStart(stackName string) (bool, string) {
	if r.startGate == nil {
		return true, ""
	}
	return r.startGate.MayStart(stackName)
}

// New builds a Reconciler with the shipped defaults.
func New(p StackProvider, logger *log.Logger) *Reconciler {
	return &Reconciler{
		stacks:     p,
		logger:     logger,
		attempts:   DefaultAttempts,
		retryDelay: DefaultRetryDelay,
		sleep:      sleepCtx,
	}
}

func sleepCtx(ctx context.Context, d time.Duration) {
	t := time.NewTimer(d)
	defer t.Stop()
	select {
	case <-ctx.Done():
	case <-t.C:
	}
}

// isBootOrphan reports whether a stack is an app the boot left behind.
//
// The gate, term by term:
//   - Deployed — an app that is installed. NOTE: `Deployed` means INSTALLED, not "wanted running";
//     the two were conflated until v0.189.0 and that conflation is what the desired-state term below
//     repairs.
//   - not Protected — traefik/cloudflared/felhom-controller have their own supervision; this must
//     never race the base-stack self-heal.
//   - not Deploying — mid-deploy is not a fault.
//   - desired state — see below. REPLACES the old container-count term.
//   - IsDownState — stopped/exited/degraded (R-51 included: a boot that half-started a stack is the
//     same interrupted-boot shape).
//
// ── WHY INTENT REPLACED THE CONTAINER COUNT (R-166, closing R-157 mechanism B) ────────────────────
//
// This gate used to end in `len(s.Containers) > 0`, and its comment called that "the D-case guard":
// a UI Stop is `compose down`, which REMOVES containers, so zero containers was read as "the
// customer stopped this" and left alone. The safety goal was right and still holds. The SIGNAL was
// wrong, because zero containers has at least three causes and the count cannot tell them apart:
//
//	a deliberate Stop   → must stay down
//	a power cut mid-compose, or an interrupted deploy → must come back
//	a backup that stopped the app and died before restarting it → must come back
//
// Two of those three were silently unrecoverable: the app simply stayed gone until a human noticed.
// The count was never capable of separating them, so the fix is not a better inference — it is to
// stop inferring and read what the customer actually asked for, which app.yaml now records.
//
// ── WHAT ABSENT STILL MEANS, AND WHY THE OLD BEHAVIOUR IS KEPT ────────────────────────────────────
//
// DesiredStateUnknown falls back to the ORIGINAL container-count rule, byte-for-byte. This is the
// single most important line in the change. Every app.yaml on every existing box predates the field,
// so absent is what the whole fleet reads on upgrade; treating absent as "running" would start, on
// the first boot after the upgrade, every app its owner had deliberately stopped. The fallback is
// what makes this feature inert for an app nobody has pressed a button on since — see
// TestReconcile_LegacyNoDesiredState_BehavesExactlyAsBefore and its red-proof.
//
// The full decision table (§8.1):
//
//	desired   containers  state        → result
//	stopped   any         any          → never an orphan (the customer said so)
//	running   0           —            → ORPHAN  ← the R-157 case, invisible before v0.189.0
//	running   >0          IsDownState  → ORPHAN  (unchanged)
//	running   >0          up           → not an orphan
//	absent    0           —            → not an orphan  (exactly the pre-v0.189.0 behaviour)
//	absent    >0          IsDownState  → ORPHAN          (exactly the pre-v0.189.0 behaviour)
func isBootOrphan(s stacks.Stack) bool {
	if !s.Deployed || s.Protected || s.Deploying {
		return false
	}
	switch stacks.DesiredStateOf(s) {
	case stacks.DesiredStateStopped:
		// The customer pressed Stop. No observation may overturn that — not a missing container, not
		// a down state, not a reboot. Nothing else in this package starts an app.
		return false
	case stacks.DesiredStateRunning:
		// Wanted running. ANY way of not being up is a fault to repair, including having no
		// containers at all — which is the case the old count term structurally could not see.
		return len(s.Containers) == 0 || stacks.IsDownState(s.State)
	default:
		// DesiredStateUnknown — legacy. Keep the pre-R-166 rule exactly.
		return len(s.Containers) > 0 && stacks.IsDownState(s.State)
	}
}

// Run performs the sweep once and returns what happened. It is safe to call with no boot orphans
// (the quiet path logs one DEBUG-free INFO-free line — see below) and it never returns an error:
// a failure to start is an app-level fact the alarm reports, not a controller startup failure.
func (r *Reconciler) Run(ctx context.Context) Result {
	var res Result

	pending := map[string]bool{}
	for _, s := range r.stacks.GetStacks() {
		if !isBootOrphan(s) {
			continue
		}
		// R-171: intent says this app should be running and it is not — but if something else is
		// deliberately holding it (absent drive, quiesce, an app-data operation), starting it is the
		// wrong repair. Refuse, loudly, and let the holder own it.
		if live, reason := r.mayStart(s.Name); !live {
			res.HeldByDrive = append(res.HeldByDrive, s.Name)
			r.logger.Printf("[INFO] [bootrecon] %q is a boot orphan by intent but is HELD (%s) — NOT starting it; whatever is holding it owns its recovery",
				s.Name, reason)
			continue
		}
		pending[s.Name] = true
		res.Candidates = append(res.Candidates, s.Name)
	}
	sortStrings(res.Candidates)
	sortStrings(res.HeldByDrive)

	if len(pending) == 0 {
		// The healthy path must be observable — "no alarms" and "never ran" have to be
		// distinguishable in a log (the v0.91.2 lesson). "Nothing to start" and "everything I found
		// is held by an absent drive" must be distinguishable too, or the held case reads as healthy.
		if len(res.HeldByDrive) > 0 {
			r.logger.Printf("[INFO] [bootrecon] Boot reconciliation: nothing to start — %d app(s) held (absent drive / quiesce / app-data operation): %v",
				len(res.HeldByDrive), res.HeldByDrive)
			return res
		}
		r.logger.Printf("[INFO] [bootrecon] Boot reconciliation: no boot-orphaned apps (nothing to start)")
		return res
	}

	r.logger.Printf("[INFO] [bootrecon] Boot reconciliation: %d boot-orphaned app(s) found: %v — up to %d attempt(s)",
		len(res.Candidates), res.Candidates, r.attempts)

	for attempt := 1; attempt <= r.attempts && len(pending) > 0; attempt++ {
		res.Attempts = attempt
		for _, name := range sortedKeys(pending) {
			if ctx.Err() != nil {
				break
			}
			start := time.Now()
			if err := r.stacks.StartStack(name); err != nil {
				r.logger.Printf("[WARN] [bootrecon] Boot reconciliation attempt %d/%d: start %q failed after %.1fs: %v",
					attempt, r.attempts, name, time.Since(start).Seconds(), err)
				continue
			}
			r.logger.Printf("[INFO] [bootrecon] Boot reconciliation attempt %d/%d: started %q (took %.1fs)",
				attempt, r.attempts, name, time.Since(start).Seconds())
		}
		if ctx.Err() != nil {
			break
		}

		// Re-read reality rather than trusting a nil error: `compose up -d` exits 0 on a crash-loop
		// (a session-critical invariant of this repo), so only a fresh docker ps can say whether the
		// app is actually up.
		if err := r.stacks.RefreshStatus(); err != nil {
			r.logger.Printf("[WARN] [bootrecon] Boot reconciliation: status refresh failed: %v", err)
		}
		for _, s := range r.stacks.GetStacks() {
			if pending[s.Name] && !stacks.IsDownState(s.State) {
				delete(pending, s.Name)
				res.Recovered = append(res.Recovered, s.Name)
			}
		}

		if len(pending) > 0 && attempt < r.attempts {
			r.sleep(ctx, r.retryDelay)
		}
	}

	res.StillDown = sortedKeys(pending)
	sortStrings(res.Recovered)

	if len(res.StillDown) == 0 {
		r.logger.Printf("[INFO] [bootrecon] Boot reconciliation complete: %d app(s) recovered in %d attempt(s): %v",
			len(res.Recovered), res.Attempts, res.Recovered)
	} else {
		// Deliberately no hub event here: the app_start_failed alarm fires on its own once the boot
		// grace expires, and two events for one dead app is how an operator inbox becomes noise.
		r.logger.Printf("[WARN] [bootrecon] Boot reconciliation gave up after %d attempt(s): recovered=%v still down=%v (the dead-app alarm now owns these)",
			res.Attempts, res.Recovered, res.StillDown)
	}
	return res
}

// --- tiny local helpers (no dependency on sort ordering semantics elsewhere) ---

func sortedKeys(m map[string]bool) []string {
	out := make([]string, 0, len(m))
	for k := range m {
		out = append(out, k)
	}
	sortStrings(out)
	return out
}

func sortStrings(s []string) {
	for i := 1; i < len(s); i++ {
		for j := i; j > 0 && s[j] < s[j-1]; j-- {
			s[j], s[j-1] = s[j-1], s[j]
		}
	}
}
