package main

import (
	"context"
	"encoding/json"
	"log/slog"
	"net/http/httptest"
	"os"
	"strings"
	"testing"
	"time"

	"github.com/dbledeez/panel/controller/internal/events"
	panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)

func testSSEServer() *httpServer {
	return &httpServer{
		log:      slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})),
		bus:      events.New(64),
		registry: newAgentRegistry(),
	}
}

func sseStateEvent(id, detail string) *panelv1.Event {
	return &panelv1.Event{Payload: &panelv1.Event_InstanceState{
		InstanceState: &panelv1.InstanceStateUpdate{InstanceId: id, Detail: detail},
	}}
}

// driveSSE runs streamEvents against a recorder until the request context
// deadline ends the stream, then returns the raw SSE body.
func driveSSE(t *testing.T, h *httpServer, target string) string {
	t.Helper()
	ctx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond)
	defer cancel()
	r := httptest.NewRequest("GET", target, nil).WithContext(ctx)
	w := httptest.NewRecorder()
	h.streamEvents(w, r)
	return w.Body.String()
}

func TestStreamEventsSnapshotShape(t *testing.T) {
	h := testSSEServer()
	body := driveSSE(t, h, "/api/events?exclude=log")

	// The snapshot must be a NAMED event (pre-snapshot clients' onmessage
	// ignores named events) with a one-line JSON body carrying both keys.
	idx := strings.Index(body, "event: snapshot\ndata: ")
	if idx < 0 {
		t.Fatalf("no snapshot event in SSE body:\n%s", body)
	}
	line := body[idx+len("event: snapshot\ndata: "):]
	line = line[:strings.Index(line, "\n")]
	var snap struct {
		Instances []map[string]any `json:"instances"`
		Agents    []map[string]any `json:"agents"`
	}
	if err := json.Unmarshal([]byte(line), &snap); err != nil {
		t.Fatalf("snapshot data is not valid one-line JSON: %v\n%s", err, line)
	}
	if snap.Instances == nil || snap.Agents == nil {
		t.Fatalf("snapshot must contain instances + agents arrays, got: %s", line)
	}
}

func TestStreamEventsLastEventIDReplay(t *testing.T) {
	h := testSSEServer()
	h.bus.Publish(sseStateEvent("a", "one"))   // id 1
	h.bus.Publish(sseStateEvent("a", "two"))   // id 2
	h.bus.Publish(sseStateEvent("a", "three")) // id 3

	body := driveSSE(t, h, "/api/events?last_event_id=1")
	if strings.Contains(body, `"detail":"one"`) {
		t.Fatalf("replay resent event at the resume point (id 1):\n%s", body)
	}
	for _, want := range []string{"id: 2\n", "id: 3\n", `"detail":"two"`, `"detail":"three"`} {
		if !strings.Contains(body, want) {
			t.Fatalf("replay missing %q:\n%s", want, body)
		}
	}
	// Wire format of replayed events stays protojson snake_case.
	if !strings.Contains(body, `"instance_state"`) {
		t.Fatalf("replayed event lost protojson snake_case framing:\n%s", body)
	}
	// Resume point was retained → no gap marker.
	if strings.Contains(body, ": replay-gap") {
		t.Fatalf("unexpected replay-gap for a retained resume point:\n%s", body)
	}
}

func TestStreamEventsLiveEventsCarryIDs(t *testing.T) {
	h := testSSEServer()
	go func() {
		// Give streamEvents time to subscribe, then publish live.
		time.Sleep(50 * time.Millisecond)
		h.bus.Publish(sseStateEvent("live1", "boot"))
	}()
	body := driveSSE(t, h, "/api/events")
	if !strings.Contains(body, "id: 1\n") || !strings.Contains(body, `"instance_id":"live1"`) {
		t.Fatalf("live event missing id line or payload:\n%s", body)
	}
}
