package hub

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

// interval clamp bounds (locked decision 3).
const (
	MinPollSeconds = 60
	MaxPollSeconds = 3600
)

// reporter and collectorIface are the loop's deps as interfaces (tests inject fakes).
type reporter interface {
	Report(ctx context.Context, r *HostReport) (*ControlEnvelope, error)
}
type collectorIface interface {
	Collect(ctx context.Context) (*HostReport, error)
}

// EnvelopeObserver is notified of the hub's control envelope on every heartbeat (slice 10A).
// The desired-state sync layer (internal/desired) implements it: when DesiredGeneration advances
// past its cache it fetches the full desired-state and updates the engine's provider. Defined
// here (consumer-side) so hub does NOT import the desired/reconcile packages — same seam pattern
// as the collector's StorageObserver. A nil observer (no desired-state wiring) is a clean no-op.
type EnvelopeObserver interface {
	OnEnvelope(ctx context.Context, env *ControlEnvelope)
}

// MultiObserver fans one envelope out to several observers in order (e.g. the desired-state
// syncer + the signed-jobs runner). nil entries are skipped.
func MultiObserver(observers ...EnvelopeObserver) EnvelopeObserver {
	return multiObserver(observers)
}

type multiObserver []EnvelopeObserver

func (m multiObserver) OnEnvelope(ctx context.Context, env *ControlEnvelope) {
	for _, o := range m {
		if o != nil {
			o.OnEnvelope(ctx, env)
		}
	}
}

// Loop is the agent's first daemon run loop: collect a host-report, POST it, adopt
// the hub's cadence, repeat. It is resilient — a collect or report error is logged
// and the loop continues (the data plane is independent of the agent; a hub outage
// must not kill it). There are NO Proxmox mutations here (read-only report), so no
// per-guest work queue yet (that lands with reconcile, slice 4).
type Loop struct {
	collector collectorIface
	client    reporter
	interval  time.Duration
	logger    *slog.Logger
	trigger   <-chan struct{}  // optional: an out-of-band report request (storage watchdog)
	observer  EnvelopeObserver // optional: the slice-10A desired-state sync hook
}

// NewLoop builds the loop. interval is the starting cadence (the hub may override it
// per-cycle via the control envelope).
func NewLoop(collector collectorIface, client reporter, interval time.Duration, logger *slog.Logger) *Loop {
	if logger == nil {
		logger = slog.Default()
	}
	return &Loop{collector: collector, client: client, interval: interval, logger: logger}
}

// SetTrigger wires an out-of-band report channel. A receive on it runs one extra
// collect→report cycle immediately WITHOUT disturbing the regular ticker cadence — used by
// the storage watchdog to push a disconnect to the hub in seconds. The watchdog debounces,
// so this fires at most once per debounce window.
func (l *Loop) SetTrigger(ch <-chan struct{}) { l.trigger = ch }

// SetEnvelopeObserver wires the slice-10A desired-state sync hook. It is called once per cycle
// with the hub's control envelope (after the interval is adopted), so the sync layer can fetch
// desired-state when the generation advances. Optional — unset is a clean no-op.
func (l *Loop) SetEnvelopeObserver(o EnvelopeObserver) { l.observer = o }

// Run reports immediately, then on each tick, until ctx is cancelled (then nil).
func (l *Loop) Run(ctx context.Context) error {
	interval := l.interval
	interval = l.cycle(ctx, interval) // immediate first report

	ticker := time.NewTicker(interval)
	defer ticker.Stop()
	for {
		select {
		case <-ctx.Done():
			l.logger.Info("hub: loop shutting down", "reason", ctx.Err())
			return nil
		case <-ticker.C:
			next := l.cycle(ctx, interval)
			if next != interval {
				l.logger.Info("hub: poll interval changed", "from", interval, "to", next)
				interval = next
				ticker.Reset(interval)
			}
		case <-l.trigger:
			// Out-of-band report (storage watchdog). Run a cycle now; keep the regular
			// cadence (do not reset the ticker). The envelope's interval is still adopted
			// if it changed, mirroring the normal path.
			l.logger.Info("hub: out-of-band report triggered (storage watchdog)")
			next := l.cycle(ctx, interval)
			if next != interval {
				l.logger.Info("hub: poll interval changed", "from", interval, "to", next)
				interval = next
				ticker.Reset(interval)
			}
		}
	}
}

// cycle runs one collect→report→adopt. It never returns an error: failures are
// logged and the current interval is kept, so the loop keeps running.
func (l *Loop) cycle(ctx context.Context, current time.Duration) time.Duration {
	report, err := l.collector.Collect(ctx)
	if err != nil {
		l.logger.Warn("hub: collect failed; skipping this cycle's report", "err", err)
		return current
	}
	env, err := l.client.Report(ctx, report)
	if err != nil {
		l.logger.Warn("hub: report failed; keeping current interval", "err", err)
		return current
	}
	l.logger.Debug("hub: report sent",
		"guests", len(report.Guests),
		"blocked", env.Blocked, "desired_generation", env.DesiredGeneration, "has_signed_ops", env.HasSignedOps)

	// Slice 10A: hand the envelope to the desired-state sync hook (fetch desired-state on a
	// generation advance). Done off the report's critical path semantics — a sync/fetch failure
	// is the observer's concern and never affects the heartbeat cadence below.
	if l.observer != nil {
		l.observer.OnEnvelope(ctx, env)
	}

	if env.PollIntervalSeconds == nil {
		return current
	}
	d, clamped := clampInterval(*env.PollIntervalSeconds)
	if clamped {
		l.logger.Warn("hub: poll_interval_seconds out of range; clamped",
			"requested", *env.PollIntervalSeconds, "applied", int(d.Seconds()))
	}
	return d
}

// clampInterval clamps a requested seconds value to [60,3600]; clamped reports
// whether it was out of range.
func clampInterval(sec int) (time.Duration, bool) {
	clamped := false
	if sec < MinPollSeconds {
		sec, clamped = MinPollSeconds, true
	}
	if sec > MaxPollSeconds {
		sec, clamped = MaxPollSeconds, true
	}
	return time.Duration(sec) * time.Second, clamped
}
