// Package fasttick is the agent-plane immediacy SECONDARY (v0.90.0, R-28). While ANY desired-state
// item is still unapplied — most importantly the pre-tunnel WG-registration window where a hub poke
// is undeliverable by construction — it pulses the hub control loop's out-of-band report trigger on
// a fast (30 s) cadence, and self-disarms EMERGENTLY the instant everything converges. It is the
// state-based complement to the poke: the poke handles hub→box changes once the tunnel exists; the
// fast-tick handles the window before that (and any lingering unapplied drift) from the box side.
//
// By ruling it is STATE-BASED, not a fixed burst and not a timer: there is nothing to journal
// (stateless across restarts) and nothing to leak. A perma-unconverged box fast-ticks at ~2 small
// reports/min, bounded and visible; the LOUD pbsdr states (consumed_failed/verify_failed) are
// deliberately EXCLUDED from the sources so a stuck-loud box does not hammer (§8).
//
// It pulses the SAME cap-1 channel the storage watchdog and the poke listener use, so a pulse
// coalesces with a poke/watchdog nudge for free — no extra debounce here.
package fasttick

import (
	"context"
	"log/slog"
	"time"
)

// DefaultInterval is the ruled fast cadence while unconverged.
const DefaultInterval = 30 * time.Second

// Source reports whether one subsystem still has unapplied desired-state. Implementations MUST be a
// cheap, CACHED read — no exec, no network per call (the fast-tick calls every source each tick).
type Source interface {
	Unconverged() (unconverged bool, reason string)
}

// SourceFunc adapts a plain func to a Source (main.go closes over each subsystem).
type SourceFunc func() (bool, string)

// Unconverged implements Source.
func (f SourceFunc) Unconverged() (bool, string) { return f() }

// Loop evaluates the sources on a ticker and pulses the out-of-band channel while any is unconverged.
type Loop struct {
	sources  []Source
	out      chan<- struct{}
	interval time.Duration
	logger   *slog.Logger
	armed    bool // for armed↔disarmed transition logging (avoids 30 s reason spam)
}

// New builds a fast-tick loop. out is the hub loop's out-of-band trigger channel (cap-1). A
// non-positive interval falls back to DefaultInterval.
func New(out chan<- struct{}, interval time.Duration, logger *slog.Logger, sources ...Source) *Loop {
	if interval <= 0 {
		interval = DefaultInterval
	}
	if logger == nil {
		logger = slog.Default()
	}
	return &Loop{sources: sources, out: out, interval: interval, logger: logger}
}

// Run evaluates the sources every interval until ctx is cancelled. Stateless — nothing to recover.
func (l *Loop) Run(ctx context.Context) error {
	l.logger.Info("fast-tick armed: "+l.interval.String()+" out-of-band cadence while desired-state is unapplied", "interval", l.interval)
	ticker := time.NewTicker(l.interval)
	defer ticker.Stop()
	for {
		select {
		case <-ctx.Done():
			l.logger.Info("fast-tick: shutting down", "reason", ctx.Err())
			return nil
		case <-ticker.C:
			l.step()
		}
	}
}

// step evaluates the sources once. If any is unconverged it pulses the channel (non-blocking: a full
// channel means an out-of-band report is already pending, so the pulse coalesces harmlessly) and, on
// the disarmed→armed edge, logs the reason. When all converge it logs the armed→disarmed edge once.
// Returns whether this tick found the box unconverged (test hook). No per-tick logging when steady.
func (l *Loop) step() bool {
	unconverged, reason := l.evaluate()
	if unconverged {
		select {
		case l.out <- struct{}{}:
		default: // an out-of-band report is already queued — coalesce, never block
		}
		if !l.armed {
			l.logger.Info("fast-tick: desired-state unapplied — pulsing out-of-band reports", "reason", reason, "cadence", l.interval)
			l.armed = true
		}
	} else if l.armed {
		l.logger.Info("fast-tick: desired-state converged — back to the normal cadence")
		l.armed = false
	}
	return unconverged
}

// evaluate returns the first unconverged source's reason (order = priority for the log line).
func (l *Loop) evaluate() (bool, string) {
	for _, s := range l.sources {
		if u, reason := s.Unconverged(); u {
			return true, reason
		}
	}
	return false, ""
}
