package web

// v0.63.0 system-initiated immediacy — the agent-plane pokes wired into the pbsdr mutation sites.
// These assert the EFFECT (the resolved WG /32 received a nudge), synchronize on a channel with a
// bounded timeout (never sleep-poll), and carry explicit zero-count negatives on the error/blocked
// paths. The notifier is a REAL poke.Notifier built over the test store + a channel-carrying fake
// sender (structural satisfaction of poke's unexported sender seam — the value is passed, the type
// is never named), exactly as main.go builds it.

import (
	"context"
	"errors"
	"io"
	"log"
	"net/http/httptest"
	"net/url"
	"sync"
	"testing"
	"time"

	"gitea.dooplex.hu/admin/felhom-hub/internal/poke"
	"gitea.dooplex.hu/admin/felhom-hub/internal/store"
	"gitea.dooplex.hu/admin/felhom-hub/internal/tenantsync"
)

func quietWebLogger() *log.Logger { return log.New(io.Discard, "", 0) }

// chanSender is a fake poke sender: it records every target and, for async assertions, publishes
// each one on a buffered channel so a test can select-with-timeout instead of sleeping.
type chanSender struct {
	mu      sync.Mutex
	targets []string
	ch      chan string
	err     error
}

func newChanSender() *chanSender { return &chanSender{ch: make(chan string, 8)} }

func (f *chanSender) Poke(_ context.Context, boxWGIP string) error {
	f.mu.Lock()
	f.targets = append(f.targets, boxWGIP)
	f.mu.Unlock()
	select {
	case f.ch <- boxWGIP:
	default:
	}
	return f.err
}

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

// wirePoke builds a real notifier over the test store + fake sender and installs it on the server.
func wirePoke(s *Server, st *store.Store, snd *chanSender) {
	s.SetPoke(poke.NewNotifier(st, snd, quietWebLogger()))
}

// expectPoke asserts exactly one poke to wantIP arrives within the bound, and none trails it.
func expectPoke(t *testing.T, snd *chanSender, wantIP string) {
	t.Helper()
	select {
	case got := <-snd.ch:
		if got != wantIP {
			t.Fatalf("poke target = %q, want %q", got, wantIP)
		}
	case <-time.After(2 * time.Second):
		t.Fatalf("no poke within 2s (want one to %s)", wantIP)
	}
	// No second poke should follow (a short settle window; the send is buffered so a stray fires fast).
	select {
	case extra := <-snd.ch:
		t.Fatalf("a second, unexpected poke fired to %q", extra)
	case <-time.After(150 * time.Millisecond):
	}
}

// expectNoPoke asserts no poke arrives within a bounded settle window (the negative-path contract).
func expectNoPoke(t *testing.T, snd *chanSender) {
	t.Helper()
	select {
	case got := <-snd.ch:
		t.Fatalf("a poke fired to %q on a path that must not poke", got)
	case <-time.After(300 * time.Millisecond):
	}
	if n := snd.count(); n != 0 {
		t.Fatalf("sender received %d pokes, want 0", n)
	}
}

func peerIP(t *testing.T, st *store.Store, hostID string) string {
	t.Helper()
	p, err := st.GetWGPeerForHost(hostID)
	if err != nil || p == nil {
		t.Fatalf("peer for %s: %v", hostID, err)
	}
	return p.AssignedIP
}

// Scenario A — the observed slice-C lag: PBSDRAutoProvision pokes the box after a hands-free
// provision. Red-proof: remove the 1.1 insertion → expectPoke times out.
func TestPBSDR_AutoProvisionPokes(t *testing.T) {
	fake := &fakeTenancy{secret: "AUTO-SECRET"}
	s, st, _ := newPBSDRServer(t, fake)
	if err := st.RemoveWGPeer("PETIPUBKEY"); err != nil {
		t.Fatalf("remove seed peer: %v", err)
	}
	// Flag ON while the peer is missing → stored intent, no provision (and so no poke path).
	postUpdate(t, s, url.Values{"dr_tier": {"on"}})

	// The agent registers its WG key; the hook fires the atom → provision succeeds → poke.
	if _, _, err := st.RegisterWGPeerForHost("peti-01", "PETIPUBKEY"); err != nil {
		t.Fatalf("register peer: %v", err)
	}
	snd := newChanSender()
	wirePoke(s, st, snd)
	wantIP := peerIP(t, st, "peti-01")

	s.PBSDRAutoProvision(context.Background(), "peti")
	expectPoke(t, snd, wantIP)
}

// Scenario A (negative) — a blocked precondition (no WG peer) mints nothing, so NO poke fires.
func TestPBSDR_AutoProvisionBlockedDoesNotPoke(t *testing.T) {
	fake := &fakeTenancy{secret: "S"}
	s, st, _ := newPBSDRServer(t, fake)
	if err := st.RemoveWGPeer("PETIPUBKEY"); err != nil {
		t.Fatalf("remove seed peer: %v", err)
	}
	postUpdate(t, s, url.Values{"dr_tier": {"on"}})

	snd := newChanSender()
	wirePoke(s, st, snd)
	s.PBSDRAutoProvision(context.Background(), "peti") // peer still absent → blocked, no mint
	expectNoPoke(t, snd)
}

// Scenario B1 — ReissuePBSDR (the reconciler-escalation core) pokes after the descriptor bump.
// Red-proof: remove the 1.2 insertion → expectPoke times out.
func TestPBSDR_ReissueCorePokes(t *testing.T) {
	fake := &fakeTenancy{secret: "OLD"}
	s, st, _ := newPBSDRServer(t, fake) // poke NOT wired yet → the provision below fires none
	postUpdate(t, s, url.Values{"dr_tier": {"on"}})
	st.ConsumeHostPBSSecret("peti-01")

	snd := newChanSender()
	wirePoke(s, st, snd)
	wantIP := peerIP(t, st, "peti-01")

	fake.secret = "FRESH"
	if err := s.ReissuePBSDR(context.Background(), "peti"); err != nil {
		t.Fatalf("ReissuePBSDR: %v", err)
	}
	expectPoke(t, snd, wantIP)
}

// Scenario B (negative) — a reissue that FAILS (tenantsync error) never reaches SetHostDesired,
// so NO poke fires (fire-after-commit).
func TestPBSDR_ReissueCoreErrorDoesNotPoke(t *testing.T) {
	fake := &fakeTenancy{secret: "OLD"}
	s, st, _ := newPBSDRServer(t, fake)
	postUpdate(t, s, url.Values{"dr_tier": {"on"}})

	snd := newChanSender()
	wirePoke(s, st, snd)

	fake.err = errors.New("ssh boom")
	if err := s.ReissuePBSDR(context.Background(), "peti"); err == nil {
		t.Fatal("ReissuePBSDR returned nil on a tenantsync error")
	}
	expectNoPoke(t, snd)
}

// Scenario B2 — the operator button (handlePBSDRReissue) pokes after the descriptor bump.
func TestPBSDR_HandleReissuePokes(t *testing.T) {
	fake := &fakeTenancy{secret: "OLD"}
	s, st, _ := newPBSDRServer(t, fake)
	postUpdate(t, s, url.Values{"dr_tier": {"on"}})
	st.ConsumeHostPBSSecret("peti-01")

	snd := newChanSender()
	wirePoke(s, st, snd)
	wantIP := peerIP(t, st, "peti-01")

	fake.secret = "FRESH"
	req := httptest.NewRequest("POST", "/configs/peti/pbsdr-reissue", nil)
	rr := httptest.NewRecorder()
	s.handlePBSDRReissue(rr, req, "peti")
	if rr.Code != 303 {
		t.Fatalf("reissue = %d (%s), want 303", rr.Code, rr.Body.String())
	}
	expectPoke(t, snd, wantIP)
}

var _ = tenantsync.ErrTokenExists // keep the tenantsync import stable across edits
