package report

import (
	"context"
	"errors"
	"sync"
	"testing"
	"time"
)

// fireRecorder is the fake fire func: counts calls, records their times, and returns a
// settable error — the trigger tests never touch HTTP (felhom-testing doctrine).
type fireRecorder struct {
	mu    sync.Mutex
	calls []time.Time
	err   error
}

func (f *fireRecorder) fire() error {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.calls = append(f.calls, time.Now())
	return f.err
}

func (f *fireRecorder) count() int {
	f.mu.Lock()
	defer f.mu.Unlock()
	return len(f.calls)
}

func (f *fireRecorder) lastCall() time.Time {
	f.mu.Lock()
	defer f.mu.Unlock()
	if len(f.calls) == 0 {
		return time.Time{}
	}
	return f.calls[len(f.calls)-1]
}

func (f *fireRecorder) setErr(err error) {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.err = err
}

// startTrigger runs tr.Run under a test-scoped context and returns a done channel that
// closes when the worker exits.
func startTrigger(t *testing.T, tr *Trigger) chan struct{} {
	t.Helper()
	ctx, cancel := context.WithCancel(context.Background())
	t.Cleanup(cancel)
	done := make(chan struct{})
	go func() {
		tr.Run(ctx)
		close(done)
	}()
	return done
}

// waitForCount polls until the recorder reaches want calls or the deadline passes.
func waitForCount(t *testing.T, rec *fireRecorder, want int, within time.Duration) {
	t.Helper()
	deadline := time.Now().Add(within)
	for time.Now().Before(deadline) {
		if rec.count() >= want {
			return
		}
		time.Sleep(2 * time.Millisecond)
	}
	t.Fatalf("fire count = %d, want >= %d within %s", rec.count(), want, within)
}

// Group A (Scenario A): a single fire produces exactly ONE push, within quiet + ε.
func TestTrigger_SingleFireExactlyOnePush(t *testing.T) {
	rec := &fireRecorder{}
	tr := newTriggerWithPacing(rec.fire, 30*time.Millisecond, 400*time.Millisecond, nil)
	startTrigger(t, tr)

	tr.Fire()
	waitForCount(t, rec, 1, 2*time.Second)

	// Exactly one: no second push may appear (a duplicate would double-report for nothing).
	time.Sleep(600 * time.Millisecond) // > quiet + minInterval
	if got := rec.count(); got != 1 {
		t.Fatalf("single Fire produced %d pushes, want exactly 1", got)
	}
}

// Group A: Fire() is non-blocking even when nothing consumes the signal (the worker is
// mid-sleep in production) — the buffered-1 channel's default branch returns immediately.
func TestTrigger_FireNonBlocking(t *testing.T) {
	rec := &fireRecorder{}
	tr := newTriggerWithPacing(rec.fire, time.Hour, time.Hour, nil)
	// Deliberately NOT started: the signal buffer fills after one Fire, so every
	// subsequent call exercises the "worker not listening" path.
	for i := 0; i < 100; i++ {
		start := time.Now()
		tr.Fire()
		if elapsed := time.Since(start); elapsed > time.Millisecond {
			t.Fatalf("Fire() call %d took %s, want < 1ms (must never block a handler)", i, elapsed)
		}
	}
	if rec.count() != 0 {
		t.Fatalf("Fire without a running worker pushed %d times, want 0", rec.count())
	}
}

// Group B (Scenario B): a 10-fire burst coalesces under the hard ceiling
// (1 + ceil(burst/minInterval) = 2 for this pacing) and the LAST push happens after the
// last fire (trailing edge — the final action's state reaches the hub, never lost).
// RED-PROOF (recorded in REPORT.md): deliver every signal straight to fire (naive
// `for { <-signal; fire() }` loop) → this test fails with ~10 calls.
func TestTrigger_BurstCoalescesTrailingEdge(t *testing.T) {
	rec := &fireRecorder{}
	// quiet 30ms, minInterval 400ms; burst spans ~45ms → ceiling = 1 + ceil(45/400) = 2.
	tr := newTriggerWithPacing(rec.fire, 30*time.Millisecond, 400*time.Millisecond, nil)
	startTrigger(t, tr)

	var lastFire time.Time
	for i := 0; i < 10; i++ {
		tr.Fire()
		lastFire = time.Now()
		time.Sleep(5 * time.Millisecond)
	}

	// Let the burst fully settle: quiet + minInterval + generous margin.
	waitForCount(t, rec, 1, 2*time.Second)
	time.Sleep(700 * time.Millisecond)

	got := rec.count()
	if got < 1 || got > 2 {
		t.Fatalf("10-fire burst produced %d pushes, want 1..2 (ceiling = 1 + ceil(burst/minInterval))", got)
	}
	if last := rec.lastCall(); !last.After(lastFire) {
		t.Fatalf("last push at %s is not after the last fire at %s — trailing edge lost (10th action's state would wait for the 15-min cycle)", last.Format(time.RFC3339Nano), lastFire.Format(time.RFC3339Nano))
	}
}

// Group C (Scenario C): a fire error is isolated — the worker keeps running and a
// SUBSEQUENT fire still pushes (the failed state is reconciled by the next cycle, the
// trigger itself adds no retry).
// RED-PROOF (recorded in REPORT.md): make Run return on fire error → the second
// waitForCount here fails (no push ever comes).
func TestTrigger_FireErrorWorkerContinues(t *testing.T) {
	rec := &fireRecorder{err: errors.New("hub unreachable")}
	tr := newTriggerWithPacing(rec.fire, 20*time.Millisecond, 50*time.Millisecond, nil)
	startTrigger(t, tr)

	tr.Fire()
	waitForCount(t, rec, 1, 2*time.Second) // the failing push was attempted

	rec.setErr(nil) // "hub back up"
	tr.Fire()
	waitForCount(t, rec, 2, 2*time.Second) // the worker survived the error and pushed again
}

// §8: the worker exits promptly on context cancel, even while mid-wait (a pending fire
// may be dropped — the scheduled cycle covers it; shutdown never hangs on the trigger).
func TestTrigger_CancelDuringWaitExitsPromptly(t *testing.T) {
	rec := &fireRecorder{}
	tr := newTriggerWithPacing(rec.fire, time.Hour, time.Hour, nil) // waits would block ~forever
	ctx, cancel := context.WithCancel(context.Background())
	done := make(chan struct{})
	go func() {
		tr.Run(ctx)
		close(done)
	}()

	tr.Fire() // worker enters the hour-long quiet sleep
	time.Sleep(20 * time.Millisecond)
	cancel()

	select {
	case <-done:
	case <-time.After(time.Second):
		t.Fatal("Run did not exit within 1s of context cancel")
	}
	if rec.count() != 0 {
		t.Fatalf("cancelled mid-quiet-window but fired %d times, want 0", rec.count())
	}
}
