package stacks

import (
	"fmt"
	"path/filepath"
)

// Desired-state values for AppConfig.DesiredState (R-166, decision D-b).
//
// THREE values, and the empty one is load-bearing — see the field's own comment in deploy.go.
// Named constants rather than bare strings so a typo is a compile error and every reader can be
// found with one grep.
const (
	// DesiredStateUnknown is the absent value: nobody has told us what the customer wants. It is the
	// value of every app.yaml written before v0.189.0. It NEVER means "running".
	DesiredStateUnknown = ""
	// DesiredStateRunning — the customer asked for this app to be running. An app in this state that
	// is not running is a fault the boot reconciler repairs, HOWEVER it came to be down.
	DesiredStateRunning = "running"
	// DesiredStateStopped — the customer pressed Stop. Nothing may start it again on its own.
	DesiredStateStopped = "stopped"
)

// SetDesiredState records the CUSTOMER's intent for a stack in its app.yaml.
//
// THE OWNERSHIP RULE, and the reason this is a separate function rather than a line inside
// StopStack/StartStack: desired state is written by the customer's own action and by nothing else.
// A census of the two primitives on 2026-08-02 found fourteen call sites, of which exactly two are
// the customer (the API action switch and the deploy path). The other twelve are machines — the
// quiesce loop, the backup volume dump, offbox reconstitution, app export/restore, the storage
// drive-absent gate, the migration engine and the boot reconciler itself. If the primitive recorded
// intent, a nightly backup stopping an app for a consistent volume dump would be indistinguishable
// from the customer stopping it, and the app would never come back. That confusion is the defect
// R-166 exists to end, so it must not be reintroduced one layer down.
//
// Callers MUST write intent BEFORE performing the act (§8.2), and MUST refuse the act if this
// returns an error. The asymmetry is deliberate:
//
//   - Stop: intent first. If the write lands and the stop then fails, the record says "stopped"
//     while the app runs — harmless, because the reconciler only ever acts on apps that are DOWN.
//     The reverse order risks an app with zero containers and "running" still recorded, i.e. a
//     deliberate stop undone at the next boot.
//   - Start: intent first. If the start then fails, the reconciler retries it later — which is
//     exactly what is wanted.
//
// An app with no app.yaml is a no-op, not an error: no app.yaml means nothing is deployed in that
// directory, and every consumer of desired state gates on Deployed first, so there is no intent to
// record and nothing that could read one.
func (m *Manager) SetDesiredState(name, desired string) error {
	switch desired {
	case DesiredStateRunning, DesiredStateStopped:
	default:
		// DesiredStateUnknown is deliberately NOT settable. "Unknown" is the absence of a record,
		// and a caller asking to write it is a caller that has confused "no opinion" with "stopped".
		return fmt.Errorf("desired state %q is not one of %q/%q", desired, DesiredStateRunning, DesiredStateStopped)
	}

	stack, ok := m.GetStack(name)
	if !ok {
		return fmt.Errorf("stack %q not found", name)
	}
	stackDir := filepath.Dir(stack.ComposePath)

	cfg := LoadAppConfig(stackDir)
	if cfg == nil {
		m.logger.Printf("[DEBUG] [stacks] desired state %s=%s: no app.yaml — nothing deployed here, nothing to record", name, desired)
		return nil
	}
	if cfg.DesiredState == desired {
		return nil // already recorded — do not rewrite app.yaml for no change
	}

	previous := cfg.DesiredState
	cfg.DesiredState = desired
	meta := LoadMetadata(stackDir)
	if err := SaveAppConfig(stackDir, cfg, m.encKey, SensitiveEnvVars(&meta)); err != nil {
		// NEVER swallowed: the caller refuses the action on this error, because an act whose intent
		// could not be recorded is exactly the ambiguity this feature removes.
		return fmt.Errorf("recording desired state %q for stack %s: %w", desired, name, err)
	}
	m.logger.Printf("[INFO] [stacks] desired state for %s recorded as %q (was %q)", name, desired, previous)

	// Keep the in-memory view in step so nothing reads a stale intent between here and the next
	// ScanStacks. Under the same lock every other AppConfig mutation uses.
	m.mu.Lock()
	if s, ok := m.stacks[name]; ok && s.AppConfig != nil {
		s.AppConfig.DesiredState = desired
	}
	m.mu.Unlock()
	return nil
}

// DesiredStateOf returns the recorded customer intent for a stack, or DesiredStateUnknown when
// there is none (no app.yaml, or an app.yaml predating v0.189.0).
func DesiredStateOf(s Stack) string {
	if s.AppConfig == nil {
		return DesiredStateUnknown
	}
	return s.AppConfig.DesiredState
}

// BackfillDesiredState writes DesiredStateRunning for every deployed app that has NO recorded
// desired state AND is observed UP right now. Returns how many were backfilled. Call ONCE at
// startup, before the boot reconciler.
//
// RUNNING-ONLY, AND THAT IS NOT AN OVERSIGHT. The one inference available for the other direction —
// "zero containers, therefore the customer stopped it" — IS THE DEFECT R-166 exists to remove. A
// power cut mid-compose, an interrupted deploy and a deliberate Stop all leave an app with zero
// containers, and nothing on disk distinguishes them. So an ambiguous app is left ambiguous: it
// keeps the legacy boot behaviour (never auto-started) until the customer next presses a button,
// which is both the safe outcome and byte-identical to what the box did before this feature.
//
// A running app is the one observation that IS unambiguous — an app that is up was, at some point,
// asked to be up — so it converges without waiting for a button press.
func (m *Manager) BackfillDesiredState() int {
	backfilled := 0
	skippedAmbiguous := 0
	for _, s := range m.GetStacks() {
		if !s.Deployed || s.Protected || s.Deploying {
			continue
		}
		if DesiredStateOf(s) != DesiredStateUnknown {
			continue
		}
		if !isObservedUp(s) {
			skippedAmbiguous++
			continue
		}
		if err := m.SetDesiredState(s.Name, DesiredStateRunning); err != nil {
			m.logger.Printf("[WARN] [stacks] desired-state backfill: %s: %v", s.Name, err)
			continue
		}
		backfilled++
	}
	// A positive observable either way (standing rule 3): "0 backfilled" and "the backfill never
	// ran" must not look the same in a log.
	m.logger.Printf("[INFO] [stacks] desired-state backfill: %d app(s) recorded as running, %d left unrecorded (state ambiguous — legacy boot behaviour retained)",
		backfilled, skippedAmbiguous)
	return backfilled
}

// isObservedUp reports whether a stack's AGGREGATE state is up right now.
//
// It is deliberately an allow-list of up-states rather than !IsDownState: IsDownState excludes
// restarting, unknown and deploying, so its negation would call a crash-looping or unreadable stack
// "up" and backfill an intent from it. Only a positive reading may seed a durable record.
//
// aggregateState (manager.go) already walks EVERY container and lets any unhealthy or mixed result
// win, so a partly-dead app cannot reach here reading healthy — D-b's every-container requirement is
// met upstream and is deliberately not re-implemented.
func isObservedUp(s Stack) bool {
	switch s.State {
	case StateRunning, StateStarting:
		return true
	default:
		return false
	}
}
