// trigger.go — the generic, debounced out-of-cycle report trigger (v0.139.0, Direction 1).
//
// User actions with hub-side effects (escrow claim, settings save, offsite toggle, app
// deploy/remove, customer claim) fire this trigger so the hub sees the new state in seconds
// instead of after the next ~15-min hub-report cycle. The scheduled cycle stays the
// reconciliation backbone — the trigger is best-effort on top, and every failure degrades
// to the cycle (no retry loop of its own; the Pusher owns retries).
//
// Semantics: COALESCE AND EVENTUALLY FIRE (trailing edge). A burst of fires collapses into
// at most 1 + ceil(burst/minInterval) pushes, and the LAST push always happens after the
// last fire — never a refused/lost update (the internal/sync refuse-debounce is the wrong
// shape here on purpose). Shape copied from felhom.eu/hub/internal/wgsync/reconciler.go
// (buffered-1 trigger channel + worker loop).
package report

import (
	"context"
	"log"
	"time"

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

const (
	// triggerQuietWindow is how long the worker waits after the first fire before pushing,
	// so a burst of related saves (multi-field form, wizard steps) coalesces into one report.
	triggerQuietWindow = 2 * time.Second
	// triggerMinInterval is the minimum spacing between out-of-cycle pushes — the hard
	// ceiling against report storms (pushes <= 1 + ceil(burst/minInterval) per burst).
	triggerMinInterval = 15 * time.Second
)

// Trigger coalesces "push a report now" requests into paced calls of an opaque fire func.
// Fire() is safe from any goroutine and never blocks (HTTP handlers call it); the single
// worker (Run) does the waiting and the pushing. Report building stays with the caller —
// the fire closure in main.go does BuildReport + Pusher.Push.
type Trigger struct {
	fire        func() error
	quiet       time.Duration
	minInterval time.Duration
	signal      chan struct{}
	logger      *log.Logger
}

// NewTrigger builds a Trigger with the production pacing. fire is the full report
// build+push closure; it must be safe to call repeatedly from one goroutine.
func NewTrigger(fire func() error, logger *log.Logger) *Trigger {
	return newTriggerWithPacing(fire, triggerQuietWindow, triggerMinInterval, logger)
}

// newTriggerWithPacing is the test constructor — tests shrink the windows to keep runs fast.
func newTriggerWithPacing(fire func() error, quiet, minInterval time.Duration, logger *log.Logger) *Trigger {
	return &Trigger{
		fire:        fire,
		quiet:       quiet,
		minInterval: minInterval,
		signal:      make(chan struct{}, 1),
		logger:      logger,
	}
}

// Fire requests an out-of-cycle report push. Non-blocking: a pending signal already covers
// this request (the eventual push carries the FULL current state either way).
func (t *Trigger) Fire() {
	select {
	case t.signal <- struct{}{}:
	default:
	}
}

// Run is the worker loop; main.go starts it under the process context. Per signal:
// wait the quiet window (coalescing the burst), drain, enforce minInterval spacing since
// the last push, drain again, then fire ONCE. A fire error is logged and the loop
// continues — the scheduled cycle reconciles. Exits on context cancel (a pending fire may
// be dropped then; the cycle covers it).
func (t *Trigger) Run(ctx context.Context) {
	var lastPush time.Time
	for {
		select {
		case <-ctx.Done():
			return
		case <-t.signal:
		}
		// Quiet window: let the burst finish, then collapse it into one push.
		if !t.sleep(ctx, t.quiet) {
			return
		}
		t.drain()
		// Pacing: never push more often than minInterval. time.Since(zero) is huge,
		// so the first push after startup is never delayed.
		if remaining := t.minInterval - time.Since(lastPush); remaining > 0 {
			if !t.sleep(ctx, remaining) {
				return
			}
			t.drain()
		}
		if err := t.fire(); err != nil {
			logx.Warnf(t.logger, "[report] out-of-cycle push failed: %v — next cycle reconciles", err)
		}
		lastPush = time.Now()
	}
}

// drain clears a pending signal that arrived during a wait — those requests are covered
// by the push about to happen (full state, not deltas).
func (t *Trigger) drain() {
	select {
	case <-t.signal:
	default:
	}
}

// sleep waits d or until ctx is cancelled; false = cancelled (caller returns promptly,
// never blocking shutdown on a pending wait).
func (t *Trigger) sleep(ctx context.Context, d time.Duration) bool {
	timer := time.NewTimer(d)
	defer timer.Stop()
	select {
	case <-ctx.Done():
		return false
	case <-timer.C:
		return true
	}
}
