package fasttick

import (
	"io"
	"log/slog"
	"testing"
	"time"
)

func quiet() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }

// flagSource is a fake Source whose convergence the test flips at will.
type flagSource struct {
	unconverged bool
	reason      string
}

func (f *flagSource) Unconverged() (bool, string) { return f.unconverged, f.reason }

// drain reports how many pulses are queued (channel is cap-1 in production; tests may use larger).
func drain(ch chan struct{}) int {
	n := 0
	for {
		select {
		case <-ch:
			n++
		default:
			return n
		}
	}
}

// D1 — any source unconverged → one pulse per tick (drained between ticks).
func TestFastTick_UnconvergedPulses(t *testing.T) {
	out := make(chan struct{}, 1)
	src := &flagSource{unconverged: true, reason: "test drift"}
	l := New(out, time.Hour, quiet(), src)

	for i := 0; i < 3; i++ {
		if !l.step() {
			t.Fatalf("tick %d: step reported converged, want unconverged", i)
		}
		if got := drain(out); got != 1 {
			t.Fatalf("tick %d: pulses = %d, want 1", i, got)
		}
	}
}

// D2 — ALL sources converged → zero pulses across N ticks (the emergent disarm).
func TestFastTick_ConvergedSilent(t *testing.T) {
	out := make(chan struct{}, 4)
	l := New(out, time.Hour, quiet(), &flagSource{unconverged: false}, &flagSource{unconverged: false})
	for i := 0; i < 5; i++ {
		if l.step() {
			t.Fatalf("tick %d: step reported unconverged with all sources converged", i)
		}
	}
	if got := drain(out); got != 0 {
		t.Fatalf("pulses on a fully-converged box = %d, want 0", got)
	}
}

// D3 (the ruled red-proof) — flip unconverged→converged mid-run → pulses STOP from the next tick.
func TestFastTick_ConvergenceDisarms(t *testing.T) {
	out := make(chan struct{}, 8)
	src := &flagSource{unconverged: true, reason: "drift"}
	l := New(out, time.Hour, quiet(), src)

	// Two unconverged ticks pulse.
	l.step()
	l.step()
	if got := drain(out); got != 2 {
		t.Fatalf("pre-convergence pulses = %d, want 2", got)
	}
	// Converge.
	src.unconverged = false
	// Every subsequent tick is silent — the cadence returns to normal.
	for i := 0; i < 4; i++ {
		if l.step() {
			t.Fatalf("post-convergence tick %d still unconverged", i)
		}
	}
	if got := drain(out); got != 0 {
		t.Fatalf("pulses fired after convergence = %d, want 0 (disarm failed)", got)
	}
}

// D4 — the out channel is already full (a poke just landed): the non-blocking send drops, no block,
// no goroutine leak, no queue growth.
func TestFastTick_ChannelFullDrops(t *testing.T) {
	out := make(chan struct{}, 1)
	out <- struct{}{} // pre-fill: an out-of-band report is already pending
	l := New(out, time.Hour, quiet(), &flagSource{unconverged: true, reason: "drift"})

	done := make(chan bool, 1)
	go func() {
		l.step() // must NOT block on the full channel
		l.step()
		done <- true
	}()
	select {
	case <-done:
	case <-time.After(2 * time.Second):
		t.Fatal("step blocked on a full channel (non-blocking send violated)")
	}
	if len(out) != 1 {
		t.Fatalf("channel depth = %d, want 1 (coalesced, no queue growth)", len(out))
	}
}

// Priority ordering: the first unconverged source supplies the reason.
func TestFastTick_FirstReasonWins(t *testing.T) {
	out := make(chan struct{}, 1)
	l := New(out, time.Hour, quiet(),
		&flagSource{unconverged: false},
		&flagSource{unconverged: true, reason: "second"},
		&flagSource{unconverged: true, reason: "third"})
	if u, r := l.evaluate(); !u || r != "second" {
		t.Fatalf("evaluate = (%v, %q), want (true, second)", u, r)
	}
}
