// announce_test.go — covers POST /api/agents/announce: happy path,
// invalid public key, hostname collision flag, rate limit, global
// cap (P2-18a).
package http

import (
	"bytes"
	"context"
	"crypto/ed25519"
	"crypto/rand"
	"encoding/base64"
	"encoding/json"
	stdhttp "net/http"
	"strings"
	"testing"
	"time"

	"github.com/oklog/ulid/v2"

	"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)

func newKeypair(t *testing.T) ed25519.PublicKey {
	t.Helper()
	pub, _, err := ed25519.GenerateKey(rand.Reader)
	if err != nil {
		t.Fatalf("ed25519: %v", err)
	}
	return pub
}

func postAnnounce(t *testing.T, url string, req announceRequest) (status int, header stdhttp.Header, body []byte) {
	t.Helper()
	b, _ := json.Marshal(req)
	r, _ := stdhttp.NewRequest("POST", url+"/api/agents/announce", bytes.NewReader(b))
	r.Header.Set("Content-Type", "application/json")
	res, err := stdhttp.DefaultClient.Do(r)
	if err != nil {
		t.Fatalf("do: %v", err)
	}
	defer res.Body.Close()
	out := make([]byte, 4096)
	n, _ := res.Body.Read(out)
	return res.StatusCode, res.Header, out[:n]
}

func TestAnnounceHappyPath(t *testing.T) {
	t.Parallel()
	_, url, st := newTestServerWithHub(t)
	pub := newKeypair(t)
	status, _, body := postAnnounce(t, url, announceRequest{
		Hostname: "alice", OS: "linux", Arch: "amd64",
		AgentVersion: "1.0", ResticVersion: "0.17",
		PublicKey: base64.StdEncoding.EncodeToString(pub),
	})
	if status != stdhttp.StatusOK {
		t.Fatalf("status: %d body=%s", status, body)
	}
	var ar announceResponse
	if err := json.Unmarshal(body, &ar); err != nil {
		t.Fatalf("unmarshal: %v body=%s", err, body)
	}
	if ar.PendingID == "" {
		t.Fatal("missing pending_id")
	}
	if !strings.HasPrefix(ar.Fingerprint, "SHA256:") {
		t.Fatalf("fingerprint shape: %q", ar.Fingerprint)
	}
	if ar.HostnameCollision {
		t.Fatal("first announce shouldn't be a collision")
	}
	// Row exists in the store.
	if _, err := st.GetPendingHost(context.Background(), ar.PendingID); err != nil {
		t.Fatalf("pending row missing: %v", err)
	}
}

func TestAnnounceRejectsBadKey(t *testing.T) {
	t.Parallel()
	_, url, _ := newTestServerWithHub(t)
	status, _, _ := postAnnounce(t, url, announceRequest{
		Hostname: "x", OS: "linux", Arch: "amd64",
		PublicKey: base64.StdEncoding.EncodeToString([]byte("too-short")),
	})
	if status != stdhttp.StatusBadRequest {
		t.Fatalf("status: got %d, want 400", status)
	}
}

func TestAnnounceHostnameCollisionFlag(t *testing.T) {
	t.Parallel()
	_, url, _ := newTestServerWithHub(t)
	pub1 := newKeypair(t)
	pub2 := newKeypair(t)
	_, _, _ = postAnnounce(t, url, announceRequest{
		Hostname: "dup-host", OS: "linux", Arch: "amd64",
		PublicKey: base64.StdEncoding.EncodeToString(pub1),
	})
	status, _, body := postAnnounce(t, url, announceRequest{
		Hostname: "dup-host", OS: "linux", Arch: "amd64",
		PublicKey: base64.StdEncoding.EncodeToString(pub2),
	})
	if status != stdhttp.StatusOK {
		t.Fatalf("status: %d", status)
	}
	var ar announceResponse
	_ = json.Unmarshal(body, &ar)
	if !ar.HostnameCollision {
		t.Fatal("expected hostname_collision=true on second announce")
	}
}

func TestAnnounceRateLimit(t *testing.T) {
	// Not t.Parallel — mutates the package-level announceMaxPerMin
	// var, which would otherwise race other announce tests.
	_, url, _ := newTestServerWithHub(t)
	prev := announceMaxPerMin
	announceMaxPerMin = 2
	t.Cleanup(func() { announceMaxPerMin = prev })

	pub := newKeypair(t)
	body := announceRequest{
		Hostname: "rl-host", OS: "linux", Arch: "amd64",
		PublicKey: base64.StdEncoding.EncodeToString(pub),
	}
	for i := 0; i < 2; i++ {
		status, _, _ := postAnnounce(t, url, body)
		if status != stdhttp.StatusOK {
			t.Fatalf("call %d: status %d", i, status)
		}
	}
	status, _, _ := postAnnounce(t, url, body)
	if status != stdhttp.StatusTooManyRequests {
		t.Fatalf("3rd call: want 429, got %d", status)
	}
}

func TestAnnounceGlobalCap(t *testing.T) {
	// Not t.Parallel — mutates the package-level announceGlobalCap.
	_, url, st := newTestServerWithHub(t)
	prev := announceGlobalCap
	announceGlobalCap = 1
	t.Cleanup(func() { announceGlobalCap = prev })

	// Pre-seed one row directly via the store so the cap is hit.
	pub := newKeypair(t)
	if err := st.CreatePendingHost(context.Background(), &store.PendingHost{
		ID: ulid.Make().String(), Hostname: "x", OS: "linux", Arch: "amd64",
		PublicKey: pub, Fingerprint: store.FingerprintForKey(pub),
		AnnouncedFromIP: "127.0.0.1",
		FirstSeenAt:     time.Now().UTC(),
		LastSeenAt:      time.Now().UTC(),
		ExpiresAt:       time.Now().UTC().Add(time.Hour),
	}); err != nil {
		t.Fatalf("seed: %v", err)
	}
	status, _, _ := postAnnounce(t, url, announceRequest{
		Hostname: "next", OS: "linux", Arch: "amd64",
		PublicKey: base64.StdEncoding.EncodeToString(newKeypair(t)),
	})
	if status != stdhttp.StatusServiceUnavailable {
		t.Fatalf("want 503 over cap, got %d", status)
	}
}
